Explorar el Código

Merge pull request #9611 from weseek/master

Release v7.2.0
Yuki Takei hace 1 año
padre
commit
cdb8004829
Se han modificado 100 ficheros con 3355 adiciones y 1112 borrados
  1. 1 0
      .devcontainer/.gitignore
  2. 8 0
      .devcontainer/compose.yml
  3. 1 1
      .devcontainer/devcontainer.json
  4. 1 1
      .github/workflows/draft-release.yml
  5. 1 1
      .github/workflows/release-rc-scheduled.yml
  6. 1 1
      .github/workflows/release-rc.yml
  7. 3 3
      .github/workflows/release-slackbot-proxy.yml
  8. 3 3
      .github/workflows/release.yml
  9. 1 1
      .github/workflows/reusable-app-create-manifests.yml
  10. 2 0
      .npmrc
  11. 4 0
      apps/app/.env.development
  12. 4 0
      apps/app/.env.production
  13. 1 1
      apps/app/docker/Dockerfile
  14. 10 2
      apps/app/package.json
  15. 4 11
      apps/app/public/static/locales/en_US/admin.json
  16. 108 14
      apps/app/public/static/locales/en_US/translation.json
  17. 4 11
      apps/app/public/static/locales/fr_FR/admin.json
  18. 108 13
      apps/app/public/static/locales/fr_FR/translation.json
  19. 4 12
      apps/app/public/static/locales/ja_JP/admin.json
  20. 108 13
      apps/app/public/static/locales/ja_JP/translation.json
  21. 4 11
      apps/app/public/static/locales/zh_CN/admin.json
  22. 111 15
      apps/app/public/static/locales/zh_CN/translation.json
  23. 14 0
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx
  24. 1 1
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  25. 0 36
      apps/app/src/client/components/Admin/Security/FacebookSecuritySetting.jsx
  26. 0 8
      apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx
  27. 2 1
      apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx
  28. 1 1
      apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss
  29. 9 9
      apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx
  30. 10 90
      apps/app/src/client/components/ItemsTree/ItemsTree.tsx
  31. 2 3
      apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx
  32. 0 6
      apps/app/src/client/components/LoginForm/LoginForm.module.scss
  33. 1 1
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  34. 0 6
      apps/app/src/client/components/Me/AssociateModal.tsx
  35. 3 2
      apps/app/src/client/components/Me/DisassociateModal.tsx
  36. 0 5
      apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.module.scss
  37. 0 45
      apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.tsx
  38. 2 2
      apps/app/src/client/components/PageControls/PageControls.tsx
  39. 9 5
      apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  40. 38 32
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  41. 19 6
      apps/app/src/client/components/PageHeader/PagePathHeader.tsx
  42. 53 29
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  43. 15 1
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  44. 6 66
      apps/app/src/client/components/ShortcutsModal.module.scss
  45. 213 121
      apps/app/src/client/components/ShortcutsModal.tsx
  46. 41 0
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.module.scss
  47. 27 11
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx
  48. 4 7
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  49. 2 1
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  50. 1 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  51. 20 5
      apps/app/src/client/components/Sidebar/Sidebar.tsx
  52. 3 0
      apps/app/src/client/components/Sidebar/SidebarContents.tsx
  53. 6 2
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx
  54. 12 0
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx
  55. 5 1
      apps/app/src/client/components/SystemVersion.module.scss
  56. 8 6
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  57. 2 2
      apps/app/src/client/components/TreeItem/interfaces/index.ts
  58. 11 0
      apps/app/src/client/services/AdminCustomizeContainer.js
  59. 1 1
      apps/app/src/client/util/bookmark-utils.ts
  60. 18 15
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  61. 15 2
      apps/app/src/components/Layout/BasicLayout.tsx
  62. 4 0
      apps/app/src/components/PageView/PageViewLayout.module.scss
  63. 1 1
      apps/app/src/components/ShareLinkPageView/ShareLinkAlert.tsx
  64. 28 28
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  65. 7 6
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  66. 2 1
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  67. 14 13
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  68. 12 10
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  69. 2 1
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  70. 0 329
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx
  71. 0 1
      apps/app/src/features/openai/chat/components/AiChatModal/index.ts
  72. 8 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss
  73. 455 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx
  74. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.module.scss
  75. 5 5
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx
  76. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/ResizableTextArea.tsx
  77. 66 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AccessScopeDropdown.tsx
  78. 40 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx
  79. 58 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  80. 149 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  81. 26 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx
  82. 221 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  83. 15 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.module.scss
  84. 293 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  85. 74 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx
  86. 43 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx
  87. 52 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeSwitch.tsx
  88. 81 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  89. 2 2
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.module.scss
  90. 55 0
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx
  91. 37 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistant.tsx
  92. 5 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss
  93. 57 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  94. 45 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.module.scss
  95. 319 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  96. 0 31
      apps/app/src/features/openai/client/components/AiIntegration/AiIntegration.tsx
  97. 0 36
      apps/app/src/features/openai/client/components/RagSearchButton.tsx
  98. 19 0
      apps/app/src/features/openai/client/services/ai-assistant.ts
  99. 7 0
      apps/app/src/features/openai/client/services/thread.ts
  100. 87 0
      apps/app/src/features/openai/client/stores/ai-assistant.tsx

+ 1 - 0
.devcontainer/.gitignore

@@ -0,0 +1 @@
+.env

+ 8 - 0
.devcontainer/compose.yml

@@ -9,6 +9,9 @@ services:
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../share:/workspace/share:delegated
       - ../../share:/workspace/share:delegated
     tty: true
     tty: true
+    networks:
+    - default
+    - opentelemetry-collector-dev-setup_default
 
 
   mongo:
   mongo:
     image: mongo:6.0
     image: mongo:6.0
@@ -45,3 +48,8 @@ volumes:
   pnpm-store:
   pnpm-store:
   node_modules:
   node_modules:
   buildcache_app:
   buildcache_app:
+
+networks:
+  default:
+  opentelemetry-collector-dev-setup_default:
+    external: ${OPENTELEMETRY_COLLECTOR_DEV_ENABLED:-false}

+ 1 - 1
.devcontainer/devcontainer.json

@@ -8,7 +8,7 @@
 
 
   "features": {
   "features": {
     "ghcr.io/devcontainers/features/node:1": {
     "ghcr.io/devcontainers/features/node:1": {
-      "version": "20.18.0"
+      "version": "20.18.3"
     }
     }
   },
   },
 
 

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

@@ -26,7 +26,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
       - name: Retrieve information from package.json
       - name: Retrieve information from package.json
-        uses: myrotvorets/info-from-package-json-action@2.0.1
+        uses: myrotvorets/info-from-package-json-action@v2.0.2
         id: package-json
         id: package-json
 
 
       - uses: release-drafter/release-drafter@v5
       - uses: release-drafter/release-drafter@v5

+ 1 - 1
.github/workflows/release-rc-scheduled.yml

@@ -23,7 +23,7 @@ jobs:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Docker meta for docker.io
     - name: Docker meta for docker.io

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

@@ -23,7 +23,7 @@ jobs:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Docker meta for docker.io
     - name: Docker meta for docker.io

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

@@ -17,7 +17,7 @@ jobs:
         ref: ${{ github.event.pull_request.base.ref }}
         ref: ${{ github.event.pull_request.base.ref }}
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
       with:
       with:
         workingDir: apps/slackbot-proxy
         workingDir: apps/slackbot-proxy
@@ -36,7 +36,7 @@ jobs:
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
 
     - name: Authenticate to Google Cloud for GROWI.cloud
     - name: Authenticate to Google Cloud for GROWI.cloud
-      uses: google-github-actions/auth@v1
+      uses: google-github-actions/auth@v2
       with:
       with:
         credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
         credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
 
 
@@ -110,7 +110,7 @@ jobs:
         turbo run version:prerelease --filter=@growi/slackbot-proxy
         turbo run version:prerelease --filter=@growi/slackbot-proxy
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
       with:
       with:
         workingDir: apps/slackbot-proxy
         workingDir: apps/slackbot-proxy

+ 3 - 3
.github/workflows/release.yml

@@ -40,7 +40,7 @@ jobs:
         sh ./apps/app/bin/github-actions/update-readme.sh
         sh ./apps/app/bin/github-actions/update-readme.sh
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Update Changelog
     - name: Update Changelog
@@ -86,7 +86,7 @@ jobs:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Docker meta for docker.io
     - name: Docker meta for docker.io
@@ -179,7 +179,7 @@ jobs:
         turbo run version:prepatch --filter=@growi/slackbot-proxy
         turbo run version:prepatch --filter=@growi/slackbot-proxy
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Commit
     - name: Commit

+ 1 - 1
.github/workflows/reusable-app-create-manifests.yml

@@ -38,7 +38,7 @@ jobs:
           type=raw,value=${{ inputs.tag-temporary }}-arm64
           type=raw,value=${{ inputs.tag-temporary }}-arm64
 
 
     - name: Login to Container Registry
     - name: Login to Container Registry
-      uses: docker/login-action@v2
+      uses: docker/login-action@v3
       with:
       with:
         registry: ${{ inputs.registry }}
         registry: ${{ inputs.registry }}
         username: wsmoogle
         username: wsmoogle

+ 2 - 0
.npmrc

@@ -0,0 +1,2 @@
+# see: https://pnpm.io/next/npmrc#force-legacy-deploy
+force-legacy-deploy=true

+ 4 - 0
apps/app/.env.development

@@ -30,3 +30,7 @@ QUESTIONNAIRE_SERVER_ORIGIN="http://host.docker.internal:3003"
 # AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
 # AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
 # AUDIT_LOG_ADDITIONAL_ACTIONS=
 # AUDIT_LOG_ADDITIONAL_ACTIONS=
 # AUDIT_LOG_EXCLUDE_ACTIONS=
 # AUDIT_LOG_EXCLUDE_ACTIONS=
+
+# OpenTelemetry Configuration
+OPENTELEMETRY_ENABLED=false
+OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

+ 4 - 0
apps/app/.env.production

@@ -4,3 +4,7 @@
 ##
 ##
 FORMAT_NODE_LOG=false
 FORMAT_NODE_LOG=false
 MIGRATIONS_DIR=dist/migrations/
 MIGRATIONS_DIR=dist/migrations/
+
+# OpenTelemetry Configuration
+OTEL_TRACES_SAMPLER_ARG=0.1
+OTEL_EXPORTER_OTLP_ENDPOINT="https://telemetry.growi.org"

+ 1 - 1
apps/app/docker/Dockerfile

@@ -14,7 +14,7 @@ WORKDIR ${optDir}
 RUN apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
 RUN apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
 
 
 # install pnpm
 # install pnpm
-RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -
+RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
 ENV PNPM_HOME="/root/.local/share/pnpm"
 ENV PNPM_HOME="/root/.local/share/pnpm"
 ENV PATH="$PNPM_HOME:$PATH"
 ENV PATH="$PNPM_HOME:$PATH"
 
 

+ 10 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.1.9",
+  "version": "7.2.0-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -21,7 +21,6 @@
     "dev:pre:styles": "pnpm run pre:styles --mode dev",
     "dev:pre:styles": "pnpm run pre:styles --mode dev",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
-    "dev:migrate:create": "pnpm run dev:migrate-mongo create -f config/migrate-mongo-config.js",
     "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:up": "pnpm run dev:migrate-mongo up -f config/migrate-mongo-config.js",
     "dev:migrate:up": "pnpm run dev:migrate-mongo up -f config/migrate-mongo-config.js",
     "dev:migrate:down": "pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
     "dev:migrate:down": "pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
@@ -84,6 +83,15 @@
     "@growi/remark-lsx": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
     "@growi/slack": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@keycloak/keycloak-admin-client": "^18.0.0",
+    "@opentelemetry/api": "^1.9.0",
+    "@opentelemetry/auto-instrumentations-node": "^0.55.1",
+    "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0",
+    "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
+    "@opentelemetry/resources": "^1.28.0",
+    "@opentelemetry/semantic-conventions": "^1.28.0",
+    "@opentelemetry/sdk-metrics": "^1.28.0",
+    "@opentelemetry/sdk-node": "^0.57.0",
+    "@opentelemetry/sdk-trace-node": "^1.28.0",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",

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

@@ -184,9 +184,6 @@
         "register_5": "Copy and paste your ClientID and Client Secret above",
         "register_5": "Copy and paste your ClientID and Client Secret above",
         "updated_google": "Succeeded to update Google OAuth setting"
         "updated_google": "Succeeded to update Google OAuth setting"
       },
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
       "GitHub": {
         "enable_github": "Enable GitHub OAuth",
         "enable_github": "Enable GitHub OAuth",
         "name": "GitHub OAuth",
         "name": "GitHub OAuth",
@@ -496,7 +493,9 @@
       "show_all_reply_comments": "Show all reply comments",
       "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.",
       "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": "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": "Presentation",
     "presentation_options": {
     "presentation_options": {
@@ -1142,12 +1141,6 @@
   "ai_integration": {
   "ai_integration": {
     "ai_integration": "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>.",
     "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"
   }
   }
 }
 }

+ 108 - 14
apps/app/public/static/locales/en_US/translation.json

@@ -152,6 +152,8 @@
   "Page Tree": "Page Tree",
   "Page Tree": "Page Tree",
   "Bookmarks": "Bookmarks",
   "Bookmarks": "Bookmarks",
   "In-App Notification": "Notifications",
   "In-App Notification": "Notifications",
+  "AI Assistant": "AI Assistant",
+  "Knowledge Assistant": "Knowledge Assistant (Beta)",
   "original_path": "Original path",
   "original_path": "Original path",
   "new_path": "New path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
   "duplicated_path": "Duplicated path",
@@ -184,7 +186,9 @@
   },
   },
   "author_info": {
   "author_info": {
     "created_at": "Created at",
     "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": {
   "installer": {
     "tab": "Create account",
     "tab": "Create account",
@@ -454,7 +458,7 @@
   "modal_shortcuts": {
   "modal_shortcuts": {
     "global": {
     "global": {
       "title": "Global shortcuts",
       "title": "Global shortcuts",
-      "Open/Close shortcut help": "Open/Close<br>shortcut help",
+      "Open/Close shortcut help": "Open/Close Shortcut Help",
       "Edit Page": "Edit Page",
       "Edit Page": "Edit Page",
       "Create Page": "Create Page",
       "Create Page": "Create Page",
       "Search": "Search",
       "Search": "Search",
@@ -468,11 +472,14 @@
       "Indent": "Indent",
       "Indent": "Indent",
       "Outdent": "Outdent",
       "Outdent": "Outdent",
       "Save Page": "Save Page",
       "Save Page": "Save Page",
-      "Delete Line": "Delete Line"
-    },
-    "commentform": {
-      "title": "Comment Form shortcuts",
-      "Post": "Post"
+      "Only Editor": "(Editor Only)",
+      "Delete Line": "Delete Line",
+      "Search in Editor": "Search in Editor",
+      "Move Line": "Move Line",
+      "Copy Line": "Copy Line",
+      "Toggle Line": "Toggle Line Comment",
+      "Insert Line": "Insert Line",
+      "Post Comment": "(Post Comment)"
     }
     }
   },
   },
   "modal_resolve_conflict": {
   "modal_resolve_conflict": {
@@ -486,9 +493,9 @@
     "latest_revision": "theirs",
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
     "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.",
     "placeholder": "Ask me anything.",
     "summary_mode_label": "Summary mode",
     "summary_mode_label": "Summary mode",
     "summary_mode_help": "Concise answer within 2-3 sentences",
     "summary_mode_help": "Concise answer within 2-3 sentences",
@@ -499,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.",
     "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",
     "error_message": "An error has occurred",
     "show_error_detail": "Show error details"
     "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": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",
@@ -743,7 +837,8 @@
   "pagetree": {
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
     "you_cannot_move_this_page_now": "You cannot move this page now",
     "you_cannot_move_this_page_now": "You cannot move this page now",
-    "something_went_wrong_with_moving_page": "Something went wrong with moving page"
+    "something_went_wrong_with_moving_page": "Something went wrong with moving page",
+    "error_retrieving_the_pagetree": "Error occurred while retrieving the PageTree"
   },
   },
   "duplicated_page_alert": {
   "duplicated_page_alert": {
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",
@@ -869,8 +964,7 @@
   },
   },
   "sidebar_header": {
   "sidebar_header": {
     "show_wip_page": "Show WIP",
     "show_wip_page": "Show WIP",
-    "size_s": "Size: S",
-    "size_l": "Size: L"
+    "compact_view": "Compact View"
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"

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

@@ -184,9 +184,6 @@
         "register_5": "Copier l'ID client et Secret client ci-dessus",
         "register_5": "Copier l'ID client et Secret client ci-dessus",
         "updated_google": "Paramètres mis à jour"
         "updated_google": "Paramètres mis à jour"
       },
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
       "GitHub": {
         "enable_github": "Activer GitHub OAuth",
         "enable_github": "Activer GitHub OAuth",
         "name": "GitHub OAuth",
         "name": "GitHub OAuth",
@@ -496,7 +493,9 @@
       "show_all_reply_comments": "Afficher tout les commentaires",
       "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",
       "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": "'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": "Présentation",
     "presentation_options": {
     "presentation_options": {
@@ -1141,12 +1140,6 @@
   "ai_integration": {
   "ai_integration": {
     "ai_integration": "Intégration de l'IA",
     "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>.",
     "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"
   }
   }
 }
 }

+ 108 - 13
apps/app/public/static/locales/fr_FR/translation.json

@@ -153,6 +153,8 @@
   "Page Tree": "Arborescence",
   "Page Tree": "Arborescence",
   "Bookmarks": "Favoris",
   "Bookmarks": "Favoris",
   "In-App Notification": "Notifications",
   "In-App Notification": "Notifications",
+  "AI Assistant": "Assistant IA",
+  "Knowledge Assistant": "Assistant de Connaissances (Bêta)",
   "original_path": "Chemin originel",
   "original_path": "Chemin originel",
   "new_path": "Nouveau chemin",
   "new_path": "Nouveau chemin",
   "duplicated_path": "Chemin dupliqué",
   "duplicated_path": "Chemin dupliqué",
@@ -185,7 +187,9 @@
   },
   },
   "author_info": {
   "author_info": {
     "created_at": "Crée le",
     "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": {
   "installer": {
     "tab": "Créer compte",
     "tab": "Créer compte",
@@ -456,18 +460,21 @@
       "Show Contributors": "Voir contributeurs",
       "Show Contributors": "Voir contributeurs",
       "MirrorMode": "Mode mirroir",
       "MirrorMode": "Mode mirroir",
       "Konami Code": "Code Konami",
       "Konami Code": "Code Konami",
-      "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
+      "konami_code_url": "https://fr.wikipedia.org/wiki/Code_Konami"
     },
     },
     "editor": {
     "editor": {
       "title": "Raccourcis d'édition",
       "title": "Raccourcis d'édition",
       "Indent": "Indentation",
       "Indent": "Indentation",
       "Outdent": "Retrait",
       "Outdent": "Retrait",
       "Save Page": "Sauvegarder la page",
       "Save Page": "Sauvegarder la page",
-      "Delete Line": "Supprimer la ligne"
-    },
-    "commentform": {
-      "title": "Raccourcis de commentaires",
-      "Post": "Poster"
+      "Only Editor": "(Éditeur uniquement)",
+      "Delete Line": "Supprimer la ligne",
+      "Search in Editor": "Rechercher dans l'éditeur",
+      "Move Line": "Déplacer la ligne",
+      "Copy Line": "Copier la ligne",
+      "Toggle Line": "Commenter/Décommenter la ligne",
+      "Insert Line": "Insérer une ligne",
+      "Post Comment": "(Publier le commentaire)"
     }
     }
   },
   },
   "modal_resolve_conflict": {
   "modal_resolve_conflict": {
@@ -481,9 +488,9 @@
     "latest_revision": "les autres",
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
     "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.",
     "placeholder": "Demandez-moi n'importe quoi.",
     "summary_mode_label": "Mode résumé",
     "summary_mode_label": "Mode résumé",
     "summary_mode_help": "Réponse concise en 2-3 phrases",
     "summary_mode_help": "Réponse concise en 2-3 phrases",
@@ -495,6 +502,94 @@
     "error_message": "Erreur",
     "error_message": "Erreur",
     "show_error_detail": "Détails de l'exposition"
     "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": {
   "link_edit": {
     "edit_link": "Modifier lien",
     "edit_link": "Modifier lien",
     "set_link_and_label": "Ajouter lien et étiquette",
     "set_link_and_label": "Ajouter lien et étiquette",
@@ -737,7 +832,8 @@
   "pagetree": {
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "Renommage impossible lorsque le titre contient '/'",
     "cannot_rename_a_title_that_contains_slash": "Renommage impossible lorsque le titre contient '/'",
     "you_cannot_move_this_page_now": "Déplacement de la page impossible",
     "you_cannot_move_this_page_now": "Déplacement de la page impossible",
-    "something_went_wrong_with_moving_page": "Échec de déplacement de la page"
+    "something_went_wrong_with_moving_page": "Échec de déplacement de la page",
+    "error_retrieving_the_pagetree": "Une erreur s'est produite lors de la récupération de l'arbre des pages"
   },
   },
   "duplicated_page_alert": {
   "duplicated_page_alert": {
     "same_page_name_exists": "Une page avec ce nom 「{{pageName}}」 existe déjà",
     "same_page_name_exists": "Une page avec ce nom 「{{pageName}}」 existe déjà",
@@ -863,8 +959,7 @@
   },
   },
   "sidebar_header": {
   "sidebar_header": {
     "show_wip_page": "Voir brouillon",
     "show_wip_page": "Voir brouillon",
-    "size_s": "Taille: P",
-    "size_l": "Taille: G"
+    "compact_view": "Vue compacte"
   },
   },
   "sync-latest-revision-body": {
   "sync-latest-revision-body": {
     "menuitem": "Synchroniser avec la dernière révision",
     "menuitem": "Synchroniser avec la dernière révision",

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

@@ -193,9 +193,6 @@
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
         "updated_google": "Google OAuth を更新しました"
         "updated_google": "Google OAuth を更新しました"
       },
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
       "GitHub": {
         "enable_github": "GitHub OAuth を有効にする",
         "enable_github": "GitHub OAuth を有効にする",
         "name": "GitHub OAuth",
         "name": "GitHub OAuth",
@@ -505,8 +502,9 @@
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。",
       "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。",
       "select_search_scope_children_as_default": "検索範囲のデフォルト設定を「この階層下の子ページ」にする",
       "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":"プレゼンテーション",
     "presentation_options":{
     "presentation_options":{
@@ -1152,12 +1150,6 @@
   "ai_integration": {
   "ai_integration": {
     "ai_integration": "AI 連携",
     "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>を参照してください。",
     "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 検索管理"
   }
   }
 }
 }

+ 108 - 13
apps/app/public/static/locales/ja_JP/translation.json

@@ -153,6 +153,8 @@
   "Page Tree": "ページツリー",
   "Page Tree": "ページツリー",
   "Bookmarks": "ブックマーク",
   "Bookmarks": "ブックマーク",
   "In-App Notification": "通知",
   "In-App Notification": "通知",
+  "AI Assistant": "AI アシスタント",
+  "Knowledge Assistant": "ナレッジアシスタント (ベータ版)",
   "original_path": "元のパス",
   "original_path": "元のパス",
   "new_path": "新しいパス",
   "new_path": "新しいパス",
   "duplicated_path": "重複したパス",
   "duplicated_path": "重複したパス",
@@ -185,7 +187,9 @@
   },
   },
   "author_info": {
   "author_info": {
     "created_at": "作成日",
     "created_at": "作成日",
-    "last_revision_posted_at": "最終更新日"
+    "created_by": "作成者:",
+    "last_revision_posted_at": "最終更新日",
+    "updated_by": "最終更新者:"
   },
   },
   "installer": {
   "installer": {
     "tab": "アカウント作成",
     "tab": "アカウント作成",
@@ -487,7 +491,7 @@
   "modal_shortcuts": {
   "modal_shortcuts": {
     "global": {
     "global": {
       "title": "グローバルショートカット",
       "title": "グローバルショートカット",
-      "Open/Close shortcut help": "ショートカットヘルプ<br>表示/非表示",
+      "Open/Close shortcut help": "ショートカットヘルプ<br>表示/非表示",
       "Edit Page": "ページ編集",
       "Edit Page": "ページ編集",
       "Create Page": "ページ作成",
       "Create Page": "ページ作成",
       "Search": "検索",
       "Search": "検索",
@@ -501,11 +505,14 @@
       "Indent": "インデント",
       "Indent": "インデント",
       "Outdent": "左インデント",
       "Outdent": "左インデント",
       "Save Page": "保存",
       "Save Page": "保存",
-      "Delete Line": "行削除"
-    },
-    "commentform": {
-      "title": "コメントフォームショートカット",
-      "Post": "投稿"
+      "Only Editor": "(エディターのみ)",
+      "Delete Line": "行削除",
+      "Search in Editor": "エディター内検索",
+      "Move Line": "行の移動",
+      "Copy Line": "行のコピー",
+      "Toggle Line": "行の非表示化",
+      "Insert Line": "行を挿入",
+      "Post Comment": "(コメント投稿)"
     }
     }
   },
   },
   "modal_resolve_conflict": {
   "modal_resolve_conflict": {
@@ -519,9 +526,9 @@
     "latest_revision": "最新の本文",
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
   },
-  "modal_aichat": {
-    "title": "ナレッジアシスタント",
-    "title_beta_label": "(ベータ)",
+  "sidebar_aichat": {
+    "instruction_label": "アシスタントへの指示",
+    "reference_pages_label": "参照するページ",
     "placeholder": "ききたいことを入力してください",
     "placeholder": "ききたいことを入力してください",
     "summary_mode_label": "要約モード",
     "summary_mode_label": "要約モード",
     "summary_mode_help": "2~3文以内の簡潔な回答",
     "summary_mode_help": "2~3文以内の簡潔な回答",
@@ -533,6 +540,94 @@
     "error_message": "エラーが発生しました",
     "error_message": "エラーが発生しました",
     "show_error_detail": "詳細を表示"
     "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": {
   "link_edit": {
     "edit_link": "リンク編集",
     "edit_link": "リンク編集",
     "set_link_and_label": "リンク情報",
     "set_link_and_label": "リンク情報",
@@ -775,7 +870,8 @@
   "pagetree": {
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
-    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"
+    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました",
+    "error_retrieving_the_pagetree": "ページツリーの取得中にエラーが発生しました"
   },
   },
   "duplicated_page_alert": {
   "duplicated_page_alert": {
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
@@ -901,8 +997,7 @@
   },
   },
   "sidebar_header": {
   "sidebar_header": {
     "show_wip_page": "WIP を表示",
     "show_wip_page": "WIP を表示",
-    "size_s": "サイズ: S",
-    "size_l": "サイズ: L"
+    "compact_view": "コンパクト表示"
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "無題のページ"
     "untitled": "無題のページ"

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

@@ -193,9 +193,6 @@
 				"register_5": "Copy and paste your ClientID and Client Secret above",
 				"register_5": "Copy and paste your ClientID and Client Secret above",
 				"updated_google": "Succeeded to update Google OAuth setting"
 				"updated_google": "Succeeded to update Google OAuth setting"
 			},
 			},
-			"Facebook": {
-				"name": "Facebook OAuth"
-			},
 			"GitHub": {
 			"GitHub": {
 				"enable_github": "Enable GitHub OAuth",
 				"enable_github": "Enable GitHub OAuth",
 				"name": "GitHub OAuth",
 				"name": "GitHub OAuth",
@@ -505,7 +502,9 @@
       "show_all_reply_comments": "显示所有回复评论",
       "show_all_reply_comments": "显示所有回复评论",
       "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。",
       "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。",
       "select_search_scope_children_as_default": "选择“当前分支以下内容”, 作为搜索范围的默认值",
       "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": "表达",
       "presentation_options": {
       "presentation_options": {
@@ -1151,12 +1150,6 @@
   "ai_integration": {
   "ai_integration": {
     "ai_integration": "AI 集成",
     "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>。",
     "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 搜索管理"
   }
   }
 }
 }

+ 111 - 15
apps/app/public/static/locales/zh_CN/translation.json

@@ -158,6 +158,8 @@
   "Page Tree": "页面树",
   "Page Tree": "页面树",
   "Bookmarks": "书签",
   "Bookmarks": "书签",
   "In-App Notification": "通知",
   "In-App Notification": "通知",
+  "AI Assistant": "AI助手",
+  "Knowledge Assistant": "知识助手 (测试版)",
   "original_path": "Original path",
   "original_path": "Original path",
   "new_path": "New path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
   "duplicated_path": "Duplicated path",
@@ -189,10 +191,12 @@
   "custom_navigation": {
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
     "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": {
   "installer": {
     "tab": "创建账户",
     "tab": "创建账户",
     "title": "安装",
     "title": "安装",
@@ -449,6 +453,7 @@
       "Create Page": "创建页面",
       "Create Page": "创建页面",
       "Search": "搜索",
       "Search": "搜索",
       "Show Contributors": "显示参与者",
       "Show Contributors": "显示参与者",
+      "MirrorMode": "镜像模式",
       "Konami Code": "Konami Code",
       "Konami Code": "Konami Code",
       "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
       "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
     },
     },
@@ -457,11 +462,14 @@
       "Indent": "缩进",
       "Indent": "缩进",
       "Outdent": "回退缩进",
       "Outdent": "回退缩进",
       "Save Page": "保存页面",
       "Save Page": "保存页面",
-      "Delete Line": "删除行"
-    },
-    "commentform": {
-      "title": "注释窗体快捷方式",
-      "Post": "提交"
+      "Only Editor": "(仅编辑器)",
+      "Delete Line": "删除行",
+      "Search in Editor": "编辑器内搜索",
+      "Move Line": "移动行",
+      "Copy Line": "复制行",
+      "Toggle Line": "注释/取消注释行",
+      "Insert Line": "插入行",
+      "Post Comment": "(发表评论)"
     }
     }
   },
   },
   "modal_resolve_conflict": {
   "modal_resolve_conflict": {
@@ -475,9 +483,9 @@
     "latest_revision": "最新页面正文",
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
     "selected_editable_revision": "选定的可编辑页面正文"
   },
   },
-  "modal_aichat": {
-    "title": "知识助手",
-    "title_beta_label": "(测试版)",
+  "sidebar_aichat": {
+    "instruction_label": "助手指令",
+    "reference_pages_label": "参考页面",
     "placeholder": "问我任何问题。",
     "placeholder": "问我任何问题。",
     "summary_mode_label": "摘要模式",
     "summary_mode_label": "摘要模式",
     "summary_mode_help": "简洁回答在2-3句话内",
     "summary_mode_help": "简洁回答在2-3句话内",
@@ -489,6 +497,94 @@
     "error_message": "错误",
     "error_message": "错误",
     "show_error_detail": "显示详情"
     "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": {
   "link_edit": {
     "edit_link": "Edit Link",
     "edit_link": "Edit Link",
     "set_link_and_label": "Set link and label",
     "set_link_and_label": "Set link and label",
@@ -745,7 +841,8 @@
   "pagetree": {
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
     "you_cannot_move_this_page_now": "你现在不能移动这个页面",
     "you_cannot_move_this_page_now": "你现在不能移动这个页面",
-    "something_went_wrong_with_moving_page": "移动页面时出了问题"
+    "something_went_wrong_with_moving_page": "移动页面时出了问题",
+    "error_retrieving_the_pagetree": "检索页面树时发生错误"
   },
   },
   "duplicated_page_alert": {
   "duplicated_page_alert": {
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
@@ -871,8 +968,7 @@
   },
   },
   "sidebar_header": {
   "sidebar_header": {
     "show_wip_page": "显示 WIP",
     "show_wip_page": "显示 WIP",
-    "size_s": "尺寸: S",
-    "size_l": "尺寸: L"
+    "compact_view": "紧凑视图"
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"

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

@@ -133,6 +133,20 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             </div>
             </div>
           </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} />
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
         </div>
         </div>

+ 1 - 1
apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx

@@ -16,7 +16,7 @@ export const CustomizeTitle: FC = () => {
 
 
   const { data: customizeTitle } = useCustomizeTitle();
   const { data: customizeTitle } = useCustomizeTitle();
 
 
-  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle);
+  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle ?? '');
 
 
   const onClickSubmit = async() => {
   const onClickSubmit = async() => {
     try {
     try {

+ 0 - 36
apps/app/src/client/components/Admin/Security/FacebookSecuritySetting.jsx

@@ -1,36 +0,0 @@
-import React from 'react';
-
-import { withTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
-class FacebookSecurityManagement extends React.Component {
-
-  render() {
-    const { t } = this.props;
-    return (
-      <>
-        <h2 className="alert-anchor border-bottom">
-          Facebook OAuth { t('admin:security_settings.configuration') }
-        </h2>
-
-        <p className="card custom-card">(TBD)</p>
-      </>
-    );
-  }
-
-}
-
-
-FacebookSecurityManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-};
-
-const FacebookSecurityManagementWrapper = withUnstatedContainers(FacebookSecurityManagement, [AdminGeneralSecurityContainer]);
-
-export default withTranslation()(FacebookSecurityManagementWrapper);

+ 0 - 8
apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx

@@ -6,7 +6,6 @@ import { TabContent, TabPane } from 'reactstrap';
 
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 import CustomNav from '../../CustomNavigation/CustomNav';
 
 
-// import FacebookSecuritySetting from './FacebookSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import GoogleSecuritySetting from './GoogleSecuritySetting';
 import GoogleSecuritySetting from './GoogleSecuritySetting';
 import LdapSecuritySetting from './LdapSecuritySetting';
 import LdapSecuritySetting from './LdapSecuritySetting';
@@ -53,10 +52,6 @@ const SecurityManagementContents = () => {
         Icon: () => <span className="growi-custom-icons align-bottom">github</span>,
         Icon: () => <span className="growi-custom-icons align-bottom">github</span>,
         i18n: 'GitHub',
         i18n: 'GitHub',
       },
       },
-      // passport_facebook: {
-      //   Icon: () => <span className="growi-custom-icons align-bottom">facebook</span>,
-      //   i18n: '(TBD) Facebook',
-      // },
     };
     };
   }, []);
   }, []);
 
 
@@ -114,9 +109,6 @@ const SecurityManagementContents = () => {
           <TabPane tabId="passport_github">
           <TabPane tabId="passport_github">
             {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
             {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
           </TabPane>
           </TabPane>
-          {/* <TabPane tabId="passport_facebook">
-            {activeComponents.has('passport_facebook') && <FacebookSecuritySetting />}
-          </TabPane> */}
         </TabContent>
         </TabContent>
       </div>
       </div>
     </div>
     </div>

+ 2 - 1
apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx

@@ -6,6 +6,7 @@ import { useTranslation } from 'next-i18next';
 
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
@@ -76,7 +77,7 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount) => {
+          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount<IExternalAuthProviderType>) => {
             return (
             return (
               <tr key={ea._id}>
               <tr key={ea._id}>
                 <td><span>{ea.providerType}</span></td>
                 <td><span>{ea.providerType}</span></td>

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

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

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

@@ -28,20 +28,20 @@ type AuthorInfoProps = {
   date: Date,
   date: Date,
   user?: IUserHasId | Ref<IUser>,
   user?: IUserHasId | Ref<IUser>,
   mode: 'create' | 'update',
   mode: 'create' | 'update',
-  locate: 'subnav' | 'footer',
+  locate: 'pageSide' | 'footer',
 }
 }
 
 
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
-    date, user, mode = 'create', locate = 'subnav',
+    date, user, mode = 'create', locate = 'pageSide',
   } = props;
   } = props;
 
 
   const formatType = 'yyyy/MM/dd HH:mm';
   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'
   const nullinfoLabelForFooter = mode === 'create'
     ? 'Created by'
     ? 'Created by'
     : 'Updated by';
     : 'Updated by';
@@ -76,13 +76,13 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   };
   };
 
 
   return (
   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" />
         <UserPicture user={user} size="sm" />
       </div>
       </div>
       <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()}
           {renderParsedDate()}
         </div>
         </div>
       </div>
       </div>

+ 10 - 90
apps/app/src/client/components/ItemsTree/ItemsTree.tsx

@@ -1,17 +1,16 @@
 import React, {
 import React, {
-  useEffect, useMemo, useCallback,
+  useEffect, useCallback,
 } from 'react';
 } from 'react';
 
 
 import path from 'path';
 import path from 'path';
 
 
-import type { Nullable, IPageHasId, IPageToDeleteWithMeta } from '@growi/core';
+import type { IPageToDeleteWithMeta } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForItem } from '~/interfaces/page';
-import type { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
 import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
 import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -19,7 +18,7 @@ import type { IPageForPageDuplicateModal } from '~/stores/modal';
 import { usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
 import { usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
 import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
 import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
 import {
 import {
-  useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList,
+  useSWRxRootPage, mutatePageTree, mutatePageList,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 import { mutateSearching } from '~/stores/search';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
@@ -35,66 +34,12 @@ const moduleClass = styles['items-tree'] ?? '';
 
 
 const logger = loggerFactory('growi:cli:ItemsTree');
 const logger = loggerFactory('growi:cli:ItemsTree');
 
 
-/*
- * Utility to generate initial node
- */
-const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPageHasId>[]): ItemNode => {
-  const nodes = targetAndAncestors.map((page): ItemNode => {
-    return new ItemNode(page, []);
-  });
-
-  // update children for each node
-  const rootNode = nodes.reduce((child, parent) => {
-    parent.children = [child];
-    return parent;
-  });
-
-  return rootNode;
-};
-
-const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPageHasId>[]>, rootNode: ItemNode): ItemNode => {
-  const paths = Object.keys(ancestorsChildren);
-
-  let currentNode = rootNode;
-  paths.every((path) => {
-    // stop rendering when non-migrated pages found
-    if (currentNode == null) {
-      return false;
-    }
-
-    const childPages = ancestorsChildren[path];
-    currentNode.children = ItemNode.generateNodesFromPages(childPages);
-    const nextNode = currentNode.children.filter((node) => {
-      return paths.includes(node.page.path as string);
-    })[0];
-    currentNode = nextNode;
-    return true;
-  });
-
-  return rootNode;
-};
-
-// user defined typeguard to assert the arg is not null
-type RenderingCondition = {
-  ancestorsChildrenResult: AncestorsChildrenResult | undefined,
-  rootPageResult: RootPageResult | undefined,
-}
-type SecondStageRenderingCondition = {
-  ancestorsChildrenResult: AncestorsChildrenResult,
-  rootPageResult: RootPageResult,
-}
-const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondStageRenderingCondition): condition is SecondStageRenderingCondition => {
-  return condition.ancestorsChildrenResult != null && condition.rootPageResult != null;
-};
-
-
 type ItemsTreeProps = {
 type ItemsTreeProps = {
   isEnableActions: boolean
   isEnableActions: boolean
   isReadOnlyUser: boolean
   isReadOnlyUser: boolean
   isWipPageShown?: boolean
   isWipPageShown?: boolean
   targetPath: string
   targetPath: string
-  targetPathOrId?: Nullable<string>
-  targetAndAncestorsData?: TargetAndAncestors
+  targetPathOrId?: string,
   CustomTreeItem: React.FunctionComponent<TreeItemProps>
   CustomTreeItem: React.FunctionComponent<TreeItemProps>
   onClickTreeItem?: (page: IPageForItem) => void;
   onClickTreeItem?: (page: IPageForItem) => void;
 }
 }
@@ -104,14 +49,13 @@ type ItemsTreeProps = {
  */
  */
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
+    targetPath, targetPathOrId, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
   } = props;
   } = props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
   const router = useRouter();
   const router = useRouter();
 
 
-  const { data: ancestorsChildrenResult, error: error1 } = useSWRxPageAncestorsChildren(targetPath, { suspense: true });
-  const { data: rootPageResult, error: error2 } = useSWRxRootPage({ suspense: true });
+  const { data: rootPageResult, error } = useSWRxRootPage({ suspense: true });
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -122,14 +66,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   // for mutation
   // for mutation
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
 
-
-  const renderingCondition = useMemo(() => {
-    return {
-      ancestorsChildrenResult,
-      rootPageResult,
-    };
-  }, [ancestorsChildrenResult, rootPageResult]);
-
   useEffect(() => {
   useEffect(() => {
     if (socket == null) {
     if (socket == null) {
       return;
       return;
@@ -197,34 +133,18 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
   }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
 
 
 
 
-  if (error1 != null || error2 != null) {
-    // TODO: improve message
-    toastError('Error occurred while fetching pages to render PageTree');
+  if (error != null) {
+    toastError(t('pagetree.error_retrieving_the_pagetree'));
     return <></>;
     return <></>;
   }
   }
 
 
-  let initialItemNode;
-  /*
-   * Render second stage
-   */
-  if (isSecondStageRenderingCondition(renderingCondition)) {
-    initialItemNode = generateInitialNodeAfterResponse(
-      renderingCondition.ancestorsChildrenResult.ancestorsChildren,
-      new ItemNode(renderingCondition.rootPageResult.rootPage),
-    );
-  }
-  /*
-   * Before swr response comes back
-   */
-  else if (targetAndAncestorsData != null) {
-    initialItemNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-  }
-
+  const initialItemNode = rootPageResult ? new ItemNode(rootPageResult.rootPage) : null;
   if (initialItemNode != null) {
   if (initialItemNode != null) {
     return (
     return (
       <ul className={`${moduleClass} list-group`}>
       <ul className={`${moduleClass} list-group`}>
         <CustomTreeItem
         <CustomTreeItem
           key={initialItemNode.page.path}
           key={initialItemNode.page.path}
+          targetPath={targetPath}
           targetPathOrId={targetPathOrId}
           targetPathOrId={targetPathOrId}
           itemNode={initialItemNode}
           itemNode={initialItemNode}
           isOpen
           isOpen

+ 2 - 3
apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx

@@ -1,12 +1,12 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
-import { IExternalAuthProviderType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
 const authIcon = {
 const authIcon = {
   [IExternalAuthProviderType.google]: <span className="growi-custom-icons align-bottom">google</span>,
   [IExternalAuthProviderType.google]: <span className="growi-custom-icons align-bottom">google</span>,
   [IExternalAuthProviderType.github]: <span className="growi-custom-icons align-bottom">github</span>,
   [IExternalAuthProviderType.github]: <span className="growi-custom-icons align-bottom">github</span>,
-  [IExternalAuthProviderType.facebook]: <span className="growi-custom-icons align-bottom">facebook</span>,
   [IExternalAuthProviderType.oidc]: <span className="growi-custom-icons align-bottom">openid</span>,
   [IExternalAuthProviderType.oidc]: <span className="growi-custom-icons align-bottom">openid</span>,
   [IExternalAuthProviderType.saml]: <span className="material-symbols-outlined align-bottom">key</span>,
   [IExternalAuthProviderType.saml]: <span className="material-symbols-outlined align-bottom">key</span>,
 };
 };
@@ -14,7 +14,6 @@ const authIcon = {
 const authLabel = {
 const authLabel = {
   [IExternalAuthProviderType.google]: 'Google',
   [IExternalAuthProviderType.google]: 'Google',
   [IExternalAuthProviderType.github]: 'GitHub',
   [IExternalAuthProviderType.github]: 'GitHub',
-  [IExternalAuthProviderType.facebook]: 'Facebook',
   [IExternalAuthProviderType.oidc]: 'OIDC',
   [IExternalAuthProviderType.oidc]: 'OIDC',
   [IExternalAuthProviderType.saml]: 'SAML',
   [IExternalAuthProviderType.saml]: 'SAML',
 };
 };

+ 0 - 6
apps/app/src/client/components/LoginForm/LoginForm.module.scss

@@ -82,12 +82,6 @@
       --bs-btn-active-bg: #{rgba(#403D3E, 0.7)};
       --bs-btn-active-bg: #{rgba(#403D3E, 0.7)};
     }
     }
 
 
-    .btn-auth-facebook {
-      --bs-btn-bg: #{rgba(#29487d, 0.4)};
-      --bs-btn-hover-bg: #{rgba(#29487d, 0.9)};
-      --bs-btn-active-bg: #{rgba(#29487d, 0.9)};
-    }
-
     .btn-auth-oidc {
     .btn-auth-oidc {
       --bs-btn-bg: #{rgba(#835B1A, 0.4)};
       --bs-btn-bg: #{rgba(#835B1A, 0.4)};
       --bs-btn-hover-bg: #{rgba(#835B1A, 0.8)};
       --bs-btn-hover-bg: #{rgba(#835B1A, 0.8)};

+ 1 - 1
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -2,7 +2,6 @@ import React, {
   useState, useEffect, useCallback,
   useState, useEffect, useCallback,
 } from 'react';
 } from 'react';
 
 
-import type { IExternalAuthProviderType } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -13,6 +12,7 @@ import { useTWithOpt } from '~/client/util/t-with-opt';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 

+ 0 - 6
apps/app/src/client/components/Me/AssociateModal.tsx

@@ -80,12 +80,6 @@ const AssociateModal = (props: Props): JSX.Element => {
             >
             >
               <span className="growi-custom-icons">google</span> (TBD) Google OAuth
               <span className="growi-custom-icons">google</span> (TBD) Google OAuth
             </NavLink>
             </NavLink>
-            {/* <NavLink
-              className={`${activeTab === 4 ? 'active' : ''} d-flex gap-1 align-items-center`}
-              onClick={() => setActiveTab(4)}
-            >
-              <span className="growi-custom-icons">facebook</span> (TBD) Facebook
-            </NavLink> */}
           </Nav>
           </Nav>
           <TabContent activeTab={activeTab}>
           <TabContent activeTab={activeTab}>
             <TabPane tabId={1}>
             <TabPane tabId={1}>

+ 3 - 2
apps/app/src/client/components/Me/DisassociateModal.tsx

@@ -1,6 +1,6 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
-import type { IExternalAccountHasId } from '@growi/core';
+import type { HasObjectId, IExternalAccount } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal,
   Modal,
@@ -10,12 +10,13 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 
 type Props = {
 type Props = {
   isOpen: boolean,
   isOpen: boolean,
   onClose: () => void,
   onClose: () => void,
-  accountForDisassociate: IExternalAccountHasId,
+  accountForDisassociate: IExternalAccount<IExternalAuthProviderType> & HasObjectId,
 }
 }
 
 
 
 

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

+ 9 - 5
apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx

@@ -8,16 +8,20 @@ import styles from './EditorNavbar.module.scss';
 
 
 const moduleClass = styles['editor-navbar'] ?? '';
 const moduleClass = styles['editor-navbar'] ?? '';
 
 
-export const EditorNavbar = (): JSX.Element => {
+const EditingUsers = (): JSX.Element => {
   const { data: editingUsers } = useEditingUsers();
   const { data: editingUsers } = useEditingUsers();
+  return (
+    <EditingUserList
+      userList={editingUsers?.userList ?? []}
+    />
+  );
+};
 
 
+export const EditorNavbar = (): JSX.Element => {
   return (
   return (
     <div className={`${moduleClass} d-flex flex-column flex-sm-row justify-content-between ps-3 ps-md-5 ps-xl-4 pe-4 py-1 align-items-sm-end`}>
     <div className={`${moduleClass} d-flex flex-column flex-sm-row justify-content-between ps-3 ps-md-5 ps-xl-4 pe-4 py-1 align-items-sm-end`}>
       <div className="order-2 order-sm-1"><PageHeader /></div>
       <div className="order-2 order-sm-1"><PageHeader /></div>
-      <div className="order-1 order-sm-2"><EditingUserList
-        userList={editingUsers?.userList ?? []}
-      />
-      </div>
+      <div className="order-1 order-sm-2"><EditingUsers /></div>
     </div>
     </div>
   );
   );
 };
 };

+ 38 - 32
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -81,7 +81,7 @@ type Props = {
   visibility?: boolean,
   visibility?: boolean,
 }
 }
 
 
-export const PageEditor = React.memo((props: Props): JSX.Element => {
+export const PageEditorSubstance = (props: Props): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -361,42 +361,48 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     return <></>;
     return <></>;
   }
   }
 
 
+  return (
+    <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
+      <div className="page-editor-editor-container flex-expand-vert border-end">
+        <CodeMirrorEditorMain
+          isEditorMode={editorMode === EditorMode.Editor}
+          onSave={saveWithShortcut}
+          onUpload={uploadHandler}
+          acceptedUploadFileType={acceptedUploadFileType}
+          onScroll={scrollEditorHandlerThrottle}
+          indentSize={currentIndentSize ?? defaultIndentSize}
+          user={user ?? undefined}
+          pageId={pageId ?? undefined}
+          initialValue={initialValue}
+          editorSettings={editorSettings}
+          onEditorsUpdated={onEditorsUpdated}
+          cmProps={cmProps}
+        />
+      </div>
+      <div
+        ref={previewRef}
+        onScroll={scrollPreviewHandlerThrottle}
+        className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
+      >
+        <Preview
+          rendererOptions={rendererOptions}
+          markdown={markdownToPreview}
+          pagePath={currentPagePath}
+          expandContentWidth={shouldExpandContent}
+          style={pastEndStyle}
+        />
+      </div>
+    </div>
+  );
+};
+
+export const PageEditor = React.memo((props: Props): JSX.Element => {
   return (
   return (
     <div data-testid="page-editor" id="page-editor" className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}>
     <div data-testid="page-editor" id="page-editor" className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}>
 
 
       <EditorNavbar />
       <EditorNavbar />
 
 
-      <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
-        <div className="page-editor-editor-container flex-expand-vert border-end">
-          <CodeMirrorEditorMain
-            isEditorMode={editorMode === EditorMode.Editor}
-            onSave={saveWithShortcut}
-            onUpload={uploadHandler}
-            acceptedUploadFileType={acceptedUploadFileType}
-            onScroll={scrollEditorHandlerThrottle}
-            indentSize={currentIndentSize ?? defaultIndentSize}
-            user={user ?? undefined}
-            pageId={pageId ?? undefined}
-            initialValue={initialValue}
-            editorSettings={editorSettings}
-            onEditorsUpdated={onEditorsUpdated}
-            cmProps={cmProps}
-          />
-        </div>
-        <div
-          ref={previewRef}
-          onScroll={scrollPreviewHandlerThrottle}
-          className="page-editor-preview-container flex-expand-vert overflow-y-auto d-none d-lg-flex"
-        >
-          <Preview
-            rendererOptions={rendererOptions}
-            markdown={markdownToPreview}
-            pagePath={currentPagePath}
-            expandContentWidth={shouldExpandContent}
-            style={pastEndStyle}
-          />
-        </div>
-      </div>
+      <PageEditorSubstance visibility={props.visibility} />
 
 
       <EditorNavbarBottom />
       <EditorNavbarBottom />
 
 

+ 19 - 6
apps/app/src/client/components/PageHeader/PagePathHeader.tsx

@@ -3,6 +3,8 @@ import {
   useState, useCallback, memo,
   useState, useCallback, memo,
 } from 'react';
 } from 'react';
 
 
+import nodePath from 'path';
+
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 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 type { InputValidationResult } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } 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 LinkedPagePath from '~/models/linked-page-path';
 import { usePageSelectModal } from '~/stores/modal';
 import { usePageSelectModal } from '~/stores/modal';
 
 
 import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 import { PagePathHierarchicalLink } from '../../../components/Common/PagePathHierarchicalLink';
 import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
 import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
-import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 
 
 import styles from './PagePathHeader.module.scss';
 import styles from './PagePathHeader.module.scss';
 
 
@@ -45,8 +47,7 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isHover, setHover] = 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>();
   const [validationResult, setValidationResult] = useState<InputValidationResult>();
 
 
@@ -61,6 +62,20 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
 
 
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
   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 rename = useCallback((inputText) => {
     const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`);
     const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`);
@@ -144,13 +159,11 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
         <button
         <button
           type="button"
           type="button"
           className="btn btn-outline-neutral-secondary d-flex align-items-center justify-content-center"
           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>
           <span className="material-symbols-outlined fs-6">account_tree</span>
         </button>
         </button>
       </div>
       </div>
-
-      {isOpened && <PageSelectModal />}
     </div>
     </div>
   );
   );
 });
 });

+ 53 - 29
apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx

@@ -13,78 +13,68 @@ import {
 import SimpleBar from 'simplebar-react';
 import SimpleBar from 'simplebar-react';
 
 
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForItem } from '~/interfaces/page';
-import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
 import { usePageSelectModal } from '~/stores/modal';
 import { usePageSelectModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
 
 
 import { ItemsTree } from '../ItemsTree';
 import { ItemsTree } from '../ItemsTree';
 import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
 import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
-import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 
 import { TreeItemForModal } from './TreeItemForModal';
 import { TreeItemForModal } from './TreeItemForModal';
 
 
-
-export const PageSelectModal: FC = () => {
+const PageSelectModalSubstance: FC = () => {
   const {
   const {
     data: PageSelectModalData,
     data: PageSelectModalData,
     close: closeModal,
     close: closeModal,
   } = usePageSelectModal();
   } = 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 { t } = useTranslation();
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
-  const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
+  const { data: pageSelectModalData } = usePageSelectModal();
 
 
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+  const isHierarchicalSelectionMode = pageSelectModalData?.opts?.isHierarchicalSelectionMode ?? false;
 
 
   const onClickTreeItem = useCallback((page: IPageForItem) => {
   const onClickTreeItem = useCallback((page: IPageForItem) => {
     const parentPagePath = page.path;
     const parentPagePath = page.path;
 
 
     if (parentPagePath == null) {
     if (parentPagePath == null) {
-      return <></>;
+      return;
     }
     }
 
 
-    setClickedParentPagePath(parentPagePath);
+    setClickedParentPage(page);
   }, []);
   }, []);
 
 
   const onClickCancel = useCallback(() => {
   const onClickCancel = useCallback(() => {
-    setClickedParentPagePath(null);
+    setClickedParentPage(null);
     closeModal();
     closeModal();
   }, [closeModal]);
   }, [closeModal]);
 
 
   const onClickDone = useCallback(() => {
   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();
     closeModal();
-  }, [clickedParentPagePath, closeModal, currentPage?.path, pagePathRenameHandler]);
+  }, [PageSelectModalData?.opts, clickedParentPage, closeModal, isIncludeSubPage]);
 
 
   const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage?.path ?? ''));
   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) {
   if (isGuestUser == null) {
     return <></>;
     return <></>;
   }
   }
 
 
   return (
   return (
-    <Modal
-      isOpen={isOpened}
-      toggle={closeModal}
-      centered
-    >
+    <>
       <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalBody className="p-0">
       <ModalBody className="p-0">
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
@@ -96,17 +86,51 @@ export const PageSelectModal: FC = () => {
                 isReadOnlyUser={!!isReadOnlyUser}
                 isReadOnlyUser={!!isReadOnlyUser}
                 targetPath={targetPath}
                 targetPath={targetPath}
                 targetPathOrId={targetPathOrId}
                 targetPathOrId={targetPathOrId}
-                targetAndAncestorsData={targetAndAncestorsData}
                 onClickTreeItem={onClickTreeItem}
                 onClickTreeItem={onClickTreeItem}
               />
               />
             </div>
             </div>
           </SimpleBar>
           </SimpleBar>
         </Suspense>
         </Suspense>
       </ModalBody>
       </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>
       </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>
     </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 dynamic from 'next/dynamic';
 import { scroller } from 'react-scroll';
 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 { useDescendantsPageListModal, useTagEditModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
 import { useIsAbleToShowTagLabel } from '~/stores/ui';
 import { useIsAbleToShowTagLabel } from '~/stores/ui';
@@ -28,6 +28,7 @@ const PageTags = dynamic(() => import('../PageTags').then(mod => mod.PageTags),
   loading: PageTagsSkeleton,
   loading: PageTagsSkeleton,
 });
 });
 
 
+const AuthorInfo = dynamic(() => import('~/client/components/AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
 
 
 type TagsProps = {
 type TagsProps = {
   pageId: string,
   pageId: string,
@@ -84,6 +85,11 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   const tagsRef = useRef<HTMLDivElement>(null);
   const tagsRef = useRef<HTMLDivElement>(null);
 
 
   const { data: pageInfo } = useSWRxPageInfo(page._id);
   const { data: pageInfo } = useSWRxPageInfo(page._id);
+  const { data: showPageSideAuthors } = useShowPageSideAuthors();
+
+  const {
+    creator, lastUpdateUser, createdAt, updatedAt,
+  } = page;
 
 
   const pagePath = page.path;
   const pagePath = page.path;
   const isTopPagePath = isTopPage(pagePath);
   const isTopPagePath = isTopPage(pagePath);
@@ -92,6 +98,14 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
 
   return (
   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 */}
       {/* Tags */}
       { page.revision != null && (
       { page.revision != null && (
         <div ref={tagsRef}>
         <div ref={tagsRef}>

+ 6 - 66
apps/app/src/client/components/ShortcutsModal.module.scss

@@ -1,77 +1,17 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use '@growi/core-styles/scss/helpers/modifier-keys';
 
 
 .shortcuts-modal :global {
 .shortcuts-modal :global {
-  h3 {
-    margin-bottom: 1em;
-  }
-
-  table {
-    th {
-      vertical-align: middle;
-    }
-    td {
-      min-width: 170px;
-    }
-  }
-
-  @include bs.media-breakpoint-up(sm) {
-    table {
-      table-layout: fixed;
-      th {
-        width: 170px;
-      }
-    }
-  }
 
 
-  // see http://coliss.com/articles/build-websites/operation/css/css-apple-keyboard-style-by-nrjmadan.html
   .key {
   .key {
     /* Box Properties */
     /* Box Properties */
-    display: inline-block;
-    width: 36px;
-    height: 36px;
-    margin: 0 4px;
+    padding: 0 4px;
 
 
     /* Text Properties */
     /* Text Properties */
-    font: 18px/36px Helvetica, serif;
-    color: bs.$secondary;
-    text-align: center;
-    text-transform: uppercase;
-    background: white;
-    border-radius: 4px;
-    box-shadow: 0 1px 3px 1px rgba(0, 0, 0, 50%);
-
-    /* SVG Properties */
-    polygon {
-      fill: bs.$secondary;
-    }
-
-    &.key-longer {
-      width: 64px;
-    }
-    &.key-long {
-      width: 72px;
-    }
-    &.key-small {
-      width: 24px;
-      height: 24px;
-      margin: 4px 2px;
-      font-size: 18px;
-      line-height: 22px;
-    }
+    background: var(--bs-tertiary-bg);
+    border: var(--bs-border-width) solid var(--bs-border-color);
+    border-radius: var(--bs-border-radius-sm);
   }
   }
 
 
-  .dl-horizontal {
-    dt {
-      display: flex;
-      align-items: center;
-      justify-content: flex-end;
-
-      // width: 180px;
-      height: 41px;
-    }
-
-    // dd {
-    //   margin-left: 190px;
-    // }
-  }
+  @include modifier-keys.modifier-key;
 }
 }

+ 213 - 121
apps/app/src/client/components/ShortcutsModal.tsx

@@ -21,139 +21,231 @@ const ShortcutsModal = (): JSX.Element => {
     // add classes to cmd-key by OS
     // add classes to cmd-key by OS
     const platform = window.navigator.platform.toLowerCase();
     const platform = window.navigator.platform.toLowerCase();
     const isMac = (platform.indexOf('mac') > -1);
     const isMac = (platform.indexOf('mac') > -1);
-    const additionalClassByOs = isMac ? 'mac' : 'key-longer win';
+    const additionalClassByOs = isMac ? 'mac' : 'win';
 
 
     return (
     return (
       <div className="container">
       <div className="container">
         <div className="row">
         <div className="row">
           <div className="col-lg-6">
           <div className="col-lg-6">
-            <h3>
+            <h6>
               <strong>{t('modal_shortcuts.global.title')}</strong>
               <strong>{t('modal_shortcuts.global.title')}</strong>
-            </h3>
+            </h6>
 
 
-            <table className="table">
-              <tbody>
-                <tr>
-                  <th>
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('modal_shortcuts.global.Open/Close shortcut help') }} />:
-                  </th>
-                  <td>
-                    <span className={`key cmd-key ${additionalClassByOs}`}></span> + <span className="key">/</span>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('modal_shortcuts.global.Create Page')}:</th>
-                  <td>
-                    <span className="key">C</span>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('modal_shortcuts.global.Edit Page')}:</th>
-                  <td>
-                    <span className="key">E</span>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('modal_shortcuts.global.Search')}:</th>
-                  <td><span className="key">/</span></td>
-                </tr>
-                <tr>
-                  <th>
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('modal_shortcuts.global.Show Contributors') }} />:
-                  </th>
-                  <td className="text-nowrap">
-                    <a href="{ t('modal_shortcuts.global.konami_code_url') }" target="_blank">
+            <ul className="list-unstyled m-0">
+              {/* Open/Close shortcut help */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">
+                  <span
+                    className="text-nowrap"
+                    // eslint-disable-next-line react/no-danger
+                    dangerouslySetInnerHTML={{ __html: t('modal_shortcuts.global.Open/Close shortcut help') }}
+                  />
+                </div>
+                <div className="d-flex align-items-center">
+                  <span className={`key cmd-key ${additionalClassByOs}`}></span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key">/</span>
+                </div>
+              </li>
+              {/* Create Page */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.global.Create Page')}</div>
+                <div>
+                  <span className="key">C</span>
+                </div>
+              </li>
+              {/* Edit Page */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.global.Edit Page')}</div>
+                <div>
+                  <span className="key">E</span>
+                </div>
+              </li>
+              {/* Search */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.global.Search')}</div>
+                <div>
+                  <span className="key">/</span>
+                </div>
+              </li>
+              {/* Show Contributors */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">
+                  <span
+                    className="text-nowrap"
+                    // eslint-disable-next-line react/no-danger
+                    dangerouslySetInnerHTML={{ __html: t('modal_shortcuts.global.Show Contributors') }}
+                  />
+                </div>
+                <div className="text-start">
+                  <a href={t('modal_shortcuts.global.konami_code_url')} target="_blank" rel="noreferrer">
+                    <span className="text-secondary small">
                       {t('modal_shortcuts.global.Konami Code')}
                       {t('modal_shortcuts.global.Konami Code')}
-                    </a>
-                    <br />
-                    <span className="key key-small">&uarr;</span>&nbsp;<span className="key key-small">&uarr;</span>
-                    <span className="key key-small">&darr;</span>&nbsp;<span className="key key-small">&darr;</span>
-                    <br />
-                    <span className="key key-small">&larr;</span>&nbsp;<span className="key key-small">&rarr;</span>
-                    <span className="key key-small">&larr;</span>&nbsp;<span className="key key-small">&rarr;</span>
-                    <br />
-                    <span className="key key-small">B</span>&nbsp;<span className="key key-small">A</span>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('modal_shortcuts.global.MirrorMode')}:</th>
-                  <td className="text-nowrap">
-                    <a href="{ t('modal_shortcuts.global.konami_code_url') }" target="_blank">
+                    </span>
+                  </a>
+                  <div className="d-flex gap-2 flex-column align-items-start mt-1">
+                    <div className="d-flex gap-1">
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_upward</span>
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_upward</span>
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_downward</span>
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_downward</span>
+                    </div>
+                    <div className="d-flex gap-1">
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_back</span>
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_forward</span>
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_back</span>
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_forward</span>
+                    </div>
+                    <div className="d-flex gap-1">
+                      <span className="key">B</span>
+                      <span className="key">A</span>
+                    </div>
+                  </div>
+                </div>
+              </li>
+              {/* Mirror Mode */}
+              <li className="d-flex align-items-center p-3">
+                <div className="flex-grow-1">{t('modal_shortcuts.global.MirrorMode')}</div>
+                <div className="text-start">
+                  <a href={t('modal_shortcuts.global.konami_code_url')} target="_blank" rel="noreferrer">
+                    <span className="text-secondary small">
                       {t('modal_shortcuts.global.Konami Code')}
                       {t('modal_shortcuts.global.Konami Code')}
-                    </a>
-                    <br />
-                    <span className="key key-small">X</span>&nbsp;<span className="key key-small">X</span>
-                    <span className="key key-small">B</span>&nbsp;<span className="key key-small">B</span>
-                    <br />
-                    <span className="key key-small">A</span>&nbsp;<span className="key key-small">Y</span>
-                    <span className="key key-small">A</span>&nbsp;<span className="key key-small">Y</span>
-                    <br />
-                    <span className="key key-small">&darr;</span>&nbsp;<span className="key key-small">&larr;</span>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
+                    </span>
+                  </a>
+                  <div className="d-flex gap-2 flex-column align-items-start mt-1">
+                    <div className="d-flex gap-1">
+                      <span className="key">X</span>
+                      <span className="key">X</span>
+                      <span className="key">B</span>
+                      <span className="key">B</span>
+                    </div>
+                    <div className="d-flex gap-1">
+                      <span className="key">A</span>
+                      <span className="key">Y</span>
+                      <span className="key">A</span>
+                      <span className="key">Y</span>
+                    </div>
+                    <div className="d-flex gap-1">
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_downward</span>
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_back</span>
+                    </div>
+                  </div>
+                </div>
+              </li>
+            </ul>
           </div>
           </div>
 
 
           <div className="col-lg-6">
           <div className="col-lg-6">
-            <h3>
+            <h6>
               <strong>{t('modal_shortcuts.editor.title')}</strong>
               <strong>{t('modal_shortcuts.editor.title')}</strong>
-            </h3>
-            <table className="table">
-              <tbody>
-                <tr>
-                  <th>{t('modal_shortcuts.editor.Indent')}:</th>
-                  <td>
-                    <span className="key key-longer">Tab</span>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('modal_shortcuts.editor.Outdent')}:</th>
-                  <td className="text-nowrap">
-                    <span className="key key-long">Shift</span> + <span className="key key-longer">Tab</span>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('modal_shortcuts.editor.Save Page')}:</th>
-                  <td>
-                    <span className={`key cmd-key ${additionalClassByOs}`}></span> + <span className="key">S</span>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('modal_shortcuts.editor.Delete Line')}:</th>
-                  <td>
-                    <span className={`key cmd-key ${additionalClassByOs}`}></span> + <span className="key">D</span>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-
-            <h3>
-              <strong>{t('modal_shortcuts.commentform.title')}</strong>
-            </h3>
-
-            <table className="table">
-              <tbody>
-                <tr>
-                  <th>{t('modal_shortcuts.commentform.Post')}:</th>
-                  <td className="text-nowrap">
-                    <span className={`key cmd-key ${additionalClassByOs}`}></span> +
-                    <span className="key key-longer">
-                      <span className="material-symbols-outlined">keyboard_return</span>
-                    </span>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('modal_shortcuts.editor.Delete Line')}:</th>
-                  <td>
-                    <span className={`key cmd-key ${additionalClassByOs}`}></span> + <span className="key">D</span>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
+            </h6>
+            <ul className="list-unstyled m-0">
+              {/* Search in Editor */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.editor.Search in Editor')}</div>
+                <div className="text-nowrap">
+                  <span className={`key cmd-key ${additionalClassByOs}`}></span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key">F</span>
+                </div>
+              </li>
+              {/* Save Page */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">
+                  {t('modal_shortcuts.editor.Save Page')}
+                  <span className="small text-secondary ms-1">{t('modal_shortcuts.editor.Only Editor')}</span>
+                </div>
+                <div className="text-nowrap">
+                  <span className={`key cmd-key ${additionalClassByOs}`}></span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key">S</span>
+                </div>
+              </li>
+              {/* Indent */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.editor.Indent')}</div>
+                <div>
+                  <span className="key">Tab</span>
+                </div>
+              </li>
+              {/* Outdent */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.editor.Outdent')}</div>
+                <div className="text-nowrap gap-1">
+                  <span className="key">Shift</span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key">Tab</span>
+                </div>
+              </li>
+              {/* Delete Line */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.editor.Delete Line')}</div>
+                <div className="text-nowrap">
+                  <span className={`key cmd-key ${additionalClassByOs}`}></span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key">Shift</span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key">K</span>
+                </div>
+              </li>
+              {/* Insert Line */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">
+                  <span
+                  // eslint-disable-next-line react/no-danger
+                    dangerouslySetInnerHTML={{ __html: t('modal_shortcuts.editor.Insert Line') }}
+                  />
+                  <br />
+                  <span className="small text-secondary ms-1">{t('modal_shortcuts.editor.Post Comment')}</span>
+                </div>
+                <div className="text-nowrap">
+                  <span className={`key cmd-key ${additionalClassByOs}`}></span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key">Enter</span>
+                </div>
+              </li>
+              {/* Move Line */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.editor.Move Line')}</div>
+                <div className="text-nowrap">
+                  <span className={`key alt-key ${additionalClassByOs}`}></span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key material-symbols-outlined fs-5 px-0">arrow_downward</span>
+                  <span className="text-secondary mx-2">or</span>
+                  <span className="key material-symbols-outlined fs-5 px-0">arrow_upward</span>
+                </div>
+              </li>
+              {/* Copy Line */}
+              <li className="d-flex align-items-center p-3 border-bottom">
+                <div className="flex-grow-1">{t('modal_shortcuts.editor.Copy Line')}</div>
+                <div className="text-nowrap">
+                  <div className="text-start">
+                    <div>
+                      <span className={`key alt-key ${additionalClassByOs}`}></span>
+                      <span className="text-secondary mx-2">+</span>
+                      <span className="key">Shift</span>
+                      <span className="text-secondary ms-2">+</span>
+                    </div>
+                    <div className="mt-1">
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_downward</span>
+                      <span className="text-secondary mx-2">or</span>
+                      <span className="key material-symbols-outlined fs-5 px-0">arrow_upward</span>
+                    </div>
+                  </div>
+                </div>
+              </li>
+              {/* Toggle Line */}
+              <li className="d-flex align-items-center p-3">
+                <div className="flex-grow-1">{t('modal_shortcuts.editor.Toggle Line')}</div>
+                <div className="text-nowrap">
+                  <span className={`key cmd-key ${additionalClassByOs}`}></span>
+                  <span className="text-secondary mx-2">+</span>
+                  <span className="key">/</span>
+                </div>
+              </li>
+            </ul>
           </div>
           </div>
+          {/* TODO: Add docs link button https://redmine.weseek.co.jp/issues/161862 */}
         </div>
         </div>
       </div>
       </div>
     );
     );
@@ -163,10 +255,10 @@ const ShortcutsModal = (): JSX.Element => {
     <>
     <>
       { status != null && (
       { status != null && (
         <Modal id="shortcuts-modal" size="lg" isOpen={status.isOpened} toggle={close} className={`shortcuts-modal ${styles['shortcuts-modal']}`}>
         <Modal id="shortcuts-modal" size="lg" isOpen={status.isOpened} toggle={close} className={`shortcuts-modal ${styles['shortcuts-modal']}`}>
-          <ModalHeader tag="h4" toggle={close}>
+          <ModalHeader tag="h4" toggle={close} className="px-4">
             {t('Shortcuts')}
             {t('Shortcuts')}
           </ModalHeader>
           </ModalHeader>
-          <ModalBody>
+          <ModalBody className="p-md-4">
             {bodyContent()}
             {bodyContent()}
           </ModalBody>
           </ModalBody>
         </Modal>
         </Modal>

+ 41 - 0
apps/app/src/client/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -2,6 +2,7 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '~/styles/variables' as var;
 @use '~/styles/variables' as var;
 @use '../button-styles';
 @use '../button-styles';
+@use '~/styles/mixins';
 
 
 // GROWI Logo
 // GROWI Logo
 .grw-app-title :global {
 .grw-app-title :global {
@@ -25,6 +26,22 @@
   }
   }
 }
 }
 
 
+// == GROWI Logo when Editor mode
+@include mixins.at-editing() {
+  @include bs.media-breakpoint-up(xl) {
+    .grw-app-title :global {
+      .grw-logo {
+          opacity: 0.5;
+          transition: opacity 0.8s ease;
+
+          &:hover {
+            opacity: 1;
+          }
+      }
+    }
+  }
+}
+
 
 
 // == Location
 // == Location
 .on-subnavigation {
 .on-subnavigation {
@@ -64,6 +81,30 @@
   width: calc(100% - $toggle-collapse-button-width);
   width: calc(100% - $toggle-collapse-button-width);
 }
 }
 
 
+// ==Sidebar Head when Editor mode
+@include bs.color-mode(light) {
+  .on-editor-sidebar-head {
+    background-color: var(
+      --on-editor-sidebar-head-bg,
+      var(
+        --grw-sidebar-nav-bg,
+        var(--grw-highlight-100)
+      )
+    );
+  }
+}
+
+@include bs.color-mode(dark) {
+  .on-editor-sidebar-head {
+    background-color: var(
+      --on-editor-sidebar-head-bg,
+      var(
+        --grw-sidebar-nav-bg,
+        var(--grw-highlight-800)
+      )
+    );
+  }
+}
 
 
 // == Interaction
 // == Interaction
 @keyframes bounce-to-right {
 @keyframes bounce-to-right {

+ 27 - 11
apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx

@@ -12,28 +12,29 @@ import styles from './AppTitle.module.scss';
 
 
 type Props = {
 type Props = {
   className?: string,
   className?: string,
+  hideAppTitle?: boolean;
 }
 }
 
 
-const AppTitleSubstance = memo((props: Props): JSX.Element => {
-
-  const { className } = props;
+const AppTitleSubstance = memo(({ className = '', hideAppTitle = false }: Props): JSX.Element => {
 
 
   const { data: isDefaultLogo } = useIsDefaultLogo();
   const { data: isDefaultLogo } = useIsDefaultLogo();
   const { data: appTitle } = useAppTitle();
   const { data: appTitle } = useAppTitle();
   const { data: confidential } = useConfidential();
   const { data: confidential } = useConfidential();
 
 
   return (
   return (
-    <div className={`${styles['grw-app-title']} ${className} d-flex d-edit-none`}>
+    <div className={`${styles['grw-app-title']} ${className} d-flex`}>
       {/* Brand Logo  */}
       {/* Brand Logo  */}
       <Link href="/" className="grw-logo d-block">
       <Link href="/" className="grw-logo d-block">
         <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
         <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
       </Link>
       </Link>
       <div className="flex-grow-1 d-flex align-items-center justify-content-between gap-3 overflow-hidden">
       <div className="flex-grow-1 d-flex align-items-center justify-content-between gap-3 overflow-hidden">
-        <div id="grw-site-name" className="grw-site-name text-truncate">
-          <Link href="/" className="fs-4">
-            {appTitle}
-          </Link>
-        </div>
+        {!hideAppTitle && (
+          <div id="grw-site-name" className="grw-site-name text-truncate">
+            <Link href="/" className="fs-4">
+              {appTitle}
+            </Link>
+          </div>
+        )}
       </div>
       </div>
       {!(confidential == null || confidential === '')
       {!(confidential == null || confidential === '')
       && (
       && (
@@ -56,6 +57,21 @@ export const AppTitleOnSubnavigation = memo((): JSX.Element => {
   return <AppTitleSubstance className={`position-absolute ${styles['on-subnavigation']}`} />;
   return <AppTitleSubstance className={`position-absolute ${styles['on-subnavigation']}`} />;
 });
 });
 
 
-export const AppTitleOnSidebarHead = memo((): JSX.Element => {
-  return <AppTitleSubstance className={`position-absolute z-1 ${styles['on-sidebar-head']}`} />;
+export const AppTitleOnSidebarHead = memo(({ hideAppTitle }: Props): JSX.Element => {
+  return (
+    <AppTitleSubstance
+      className={`position-absolute z-1 ${styles['on-sidebar-head']}`}
+      hideAppTitle={hideAppTitle}
+    />
+  );
+});
+
+export const AppTitleOnEditorSidebarHead = memo((): JSX.Element => {
+  return (
+    <div className={`${styles['on-editor-sidebar-head']}`}>
+      <AppTitleSubstance
+        className={`${styles['on-sidebar-head']}`}
+      />
+    </div>
+  );
 });
 });

+ 4 - 7
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -5,10 +5,10 @@ import React, {
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import {
 import {
-  mutatePageTree, mutateRecentlyUpdated, useSWRxPageAncestorsChildren, useSWRxRootPage, useSWRxV5MigrationStatus,
+  mutatePageTree, mutateRecentlyUpdated, useSWRxRootPage, useSWRxV5MigrationStatus,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
 import { useSidebarScrollerRef } from '~/stores/ui';
 import { useSidebarScrollerRef } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -99,14 +99,12 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetId } = useCurrentPageId();
-  const { data: targetAndAncestorsData } = useTargetAndAncestors();
 
 
   const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
   const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
 
 
   const targetPathOrId = targetId || currentPath;
   const targetPathOrId = targetId || currentPath;
   const path = currentPath || '/';
   const path = currentPath || '/';
 
 
-  const { data: ancestorsChildrenResult } = useSWRxPageAncestorsChildren(path, { suspense: true });
   const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
   const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
   const { data: sidebarScrollerRef } = useSidebarScrollerRef();
   const { data: sidebarScrollerRef } = useSidebarScrollerRef();
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
@@ -144,7 +142,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
   const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (isInitialScrollCompleted || ancestorsChildrenResult == null || rootPageResult == null) {
+    if (isInitialScrollCompleted || rootPageResult == null) {
       return;
       return;
     }
     }
 
 
@@ -166,7 +164,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
     return () => {
     return () => {
       observer.disconnect();
       observer.disconnect();
     };
     };
-  }, [isInitialScrollCompleted, scrollOnInitDebounced, ancestorsChildrenResult, rootPageResult]);
+  }, [isInitialScrollCompleted, scrollOnInitDebounced, rootPageResult]);
   // *******************************  end  *******************************
   // *******************************  end  *******************************
 
 
 
 
@@ -189,7 +187,6 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
         isWipPageShown={isWipPageShown}
         isWipPageShown={isWipPageShown}
         targetPath={path}
         targetPath={path}
         targetPathOrId={targetPathOrId}
         targetPathOrId={targetPathOrId}
-        targetAndAncestorsData={targetAndAncestorsData}
         CustomTreeItem={PageTreeItem}
         CustomTreeItem={PageTreeItem}
       />
       />
 
 

+ 2 - 1
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -34,7 +34,7 @@ const moduleClass = styles['page-tree-item'] ?? '';
 
 
 const logger = loggerFactory('growi:cli:Item');
 const logger = loggerFactory('growi:cli:Item');
 
 
-export const PageTreeItem: FC<TreeItemProps> = (props) => {
+export const PageTreeItem = (props:TreeItemProps): JSX.Element => {
   const router = useRouter();
   const router = useRouter();
 
 
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
@@ -186,6 +186,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
   return (
   return (
     <TreeItemLayout
     <TreeItemLayout
       className={moduleClass}
       className={moduleClass}
+      targetPath={props.targetPath}
       targetPathOrId={props.targetPathOrId}
       targetPathOrId={props.targetPathOrId}
       itemLevel={props.itemLevel}
       itemLevel={props.itemLevel}
       itemNode={props.itemNode}
       itemNode={props.itemNode}

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

@@ -201,7 +201,7 @@ export const RecentChangesHeader = ({
                 onChange={() => {}}
                 onChange={() => {}}
               />
               />
               <label className="form-check-label pe-none" aria-disabled="true">
               <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>
               </label>
             </div>
             </div>
           </li>
           </li>

+ 20 - 5
apps/app/src/client/components/Sidebar/Sidebar.tsx

@@ -11,6 +11,7 @@ import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 
 import { SidebarMode } from '~/interfaces/ui';
 import { SidebarMode } from '~/interfaces/ui';
 import { useIsSearchPage } from '~/stores-universal/context';
 import { useIsSearchPage } from '~/stores-universal/context';
+import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import {
 import {
   useDrawerOpened,
   useDrawerOpened,
   useCollapsedContentsOpened,
   useCollapsedContentsOpened,
@@ -18,11 +19,13 @@ import {
   usePreferCollapsedMode,
   usePreferCollapsedMode,
   useSidebarMode,
   useSidebarMode,
   useSidebarScrollerRef,
   useSidebarScrollerRef,
+  useIsDeviceLargerThanMd,
+  useIsDeviceLargerThanXl,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
 import { DrawerToggler } from '../Common/DrawerToggler';
 import { DrawerToggler } from '../Common/DrawerToggler';
 
 
-import { AppTitleOnSidebarHead, AppTitleOnSubnavigation } from './AppTitle/AppTitle';
+import { AppTitleOnSidebarHead, AppTitleOnEditorSidebarHead, AppTitleOnSubnavigation } from './AppTitle/AppTitle';
 import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
 import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
 import type { ResizableAreaProps } from './ResizableArea/props';
 import type { ResizableAreaProps } from './ResizableArea/props';
 import { SidebarHead } from './SidebarHead';
 import { SidebarHead } from './SidebarHead';
@@ -230,6 +233,14 @@ export const Sidebar = (): JSX.Element => {
   } = useSidebarMode();
   } = useSidebarMode();
 
 
   const { data: isSearchPage } = useIsSearchPage();
   const { data: isSearchPage } = useIsSearchPage();
+  const { data: editorMode } = useEditorMode();
+  const { data: isMdSize } = useIsDeviceLargerThanMd();
+  const { data: isXlSize } = useIsDeviceLargerThanXl();
+
+  const isEditorMode = editorMode === EditorMode.Editor;
+  const shouldHideSiteName = isEditorMode && isXlSize;
+  const shouldHideSubnavAppTitle = isEditorMode && isMdSize && (isDrawerMode() || isCollapsedMode());
+  const shouldShowEditorSidebarHead = isEditorMode && isXlSize;
 
 
   // css styles
   // css styles
   const grwSidebarClass = styles['grw-sidebar'];
   const grwSidebarClass = styles['grw-sidebar'];
@@ -253,12 +264,16 @@ export const Sidebar = (): JSX.Element => {
         <DrawerToggler className="position-fixed d-none d-md-block">
         <DrawerToggler className="position-fixed d-none d-md-block">
           <span className="material-symbols-outlined">reorder</span>
           <span className="material-symbols-outlined">reorder</span>
         </DrawerToggler>
         </DrawerToggler>
-      ) }
-      { sidebarMode != null && !isDockMode() && !isSearchPage && <AppTitleOnSubnavigation /> }
+      )}
+      { sidebarMode != null && !isDockMode() && !isSearchPage && !shouldHideSubnavAppTitle && (
+        <AppTitleOnSubnavigation />
+      )}
       <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
       <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
         <ResizableContainer>
         <ResizableContainer>
-          { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }
-          <SidebarHead />
+          { sidebarMode != null && !isCollapsedMode() && (
+            <AppTitleOnSidebarHead hideAppTitle={shouldHideSiteName} />
+          )}
+          {shouldShowEditorSidebarHead ? <AppTitleOnEditorSidebarHead /> : <SidebarHead />}
           <CollapsibleContainer Nav={SidebarNav} className="border-top">
           <CollapsibleContainer Nav={SidebarNav} className="border-top">
             <SidebarContents />
             <SidebarContents />
           </CollapsibleContainer>
           </CollapsibleContainer>

+ 3 - 0
apps/app/src/client/components/Sidebar/SidebarContents.tsx

@@ -1,5 +1,6 @@
 import React, { memo, useMemo } from 'react';
 import React, { memo, useMemo } from 'react';
 
 
+import { AiAssistant } from '~/features/openai/client/components/AiAssistant/Sidebar/AiAssistant';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useCollapsedContentsOpened, useCurrentSidebarContents, useSidebarMode } from '~/stores/ui';
 import { useCollapsedContentsOpened, useCurrentSidebarContents, useSidebarMode } from '~/stores/ui';
 
 
@@ -32,6 +33,8 @@ export const SidebarContents = memo(() => {
         return Bookmarks;
         return Bookmarks;
       case SidebarContentsType.NOTIFICATION:
       case SidebarContentsType.NOTIFICATION:
         return InAppNotification;
         return InAppNotification;
+      case SidebarContentsType.AI_ASSISTANT:
+        return AiAssistant;
       default:
       default:
         return PageTree;
         return PageTree;
     }
     }

+ 6 - 2
apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx

@@ -22,6 +22,7 @@ export type PrimaryItemProps = {
   label: string,
   label: string,
   iconName: string,
   iconName: string,
   sidebarMode: SidebarMode,
   sidebarMode: SidebarMode,
+  isCustomIcon?: boolean,
   badgeContents?: number,
   badgeContents?: number,
   onHover?: (contents: SidebarContentsType) => void,
   onHover?: (contents: SidebarContentsType) => void,
   onClick?: () => void,
   onClick?: () => void,
@@ -29,7 +30,7 @@ export type PrimaryItemProps = {
 
 
 export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
 export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
   const {
   const {
-    contents, label, iconName, sidebarMode, badgeContents,
+    contents, label, iconName, sidebarMode, badgeContents, isCustomIcon,
     onClick, onHover,
     onClick, onHover,
   } = props;
   } = props;
 
 
@@ -80,7 +81,10 @@ export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
           { badgeContents != null && (
           { badgeContents != null && (
             <span className="position-absolute badge rounded-pill bg-primary">{badgeContents}</span>
             <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>
         </div>
       </button>
       </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 dynamic from 'next/dynamic';
 
 
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
+import { useIsAiEnabled } from '~/stores-universal/context';
 import { useSidebarMode } from '~/stores/ui';
 import { useSidebarMode } from '~/stores/ui';
 
 
 import { PrimaryItem } from './PrimaryItem';
 import { PrimaryItem } from './PrimaryItem';
@@ -22,6 +23,7 @@ export const PrimaryItems = memo((props: Props) => {
   const { onItemHover } = props;
   const { onItemHover } = props;
 
 
   const { data: sidebarMode } = useSidebarMode();
   const { data: sidebarMode } = useSidebarMode();
+  const { data: isAiEnabled } = useIsAiEnabled();
 
 
   if (sidebarMode == null) {
   if (sidebarMode == null) {
     return <></>;
     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.BOOKMARKS} label="Bookmarks" iconName="bookmarks" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onHover={onItemHover} />
       <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onHover={onItemHover} />
       <PrimaryItemForNotification sidebarMode={sidebarMode} 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>
     </div>
   );
   );
 });
 });

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

@@ -1,6 +1,10 @@
-.system-version {
+@use '@growi/core-styles/scss/helpers/modifier-keys';
+
+.system-version :global {
   position: fixed;
   position: fixed;
   right: 0.5em;
   right: 0.5em;
   bottom: 0;
   bottom: 0;
   opacity: 0.6;
   opacity: 0.6;
+
+  @include modifier-keys.modifier-key;
 }
 }

+ 8 - 6
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -22,21 +22,21 @@ type TreeItemLayoutProps = TreeItemProps & {
   indentSize?: number,
   indentSize?: number,
 }
 }
 
 
-export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
+export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
   const {
   const {
     className, itemClassName,
     className, itemClassName,
     indentSize = 10,
     indentSize = 10,
     itemLevel: baseItemLevel = 1,
     itemLevel: baseItemLevel = 1,
-    itemNode, targetPathOrId, isOpen: _isOpen = false,
+    itemNode, targetPath, targetPathOrId, isOpen: _isOpen = false,
     onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
     onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
     isEnableActions, isReadOnlyUser, isWipPageShown = true,
     isEnableActions, isReadOnlyUser, isWipPageShown = true,
     itemRef, itemClass,
     itemRef, itemClass,
     showAlternativeContent,
     showAlternativeContent,
   } = props;
   } = props;
 
 
-  const { page, children } = itemNode;
+  const { page } = itemNode;
 
 
-  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>(children);
+  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>([]);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isOpen, setIsOpen] = useState(_isOpen);
 
 
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
@@ -84,8 +84,9 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
 
 
   // didMount
   // didMount
   useEffect(() => {
   useEffect(() => {
-    if (hasChildren()) setIsOpen(true);
-  }, [hasChildren]);
+    const isPathToTarget = page.path != null && targetPath.startsWith(page.path) && targetPath !== page.path; // Target Page does not need to be opened
+    if (isPathToTarget) setIsOpen(true);
+  }, [targetPath, page.path]);
 
 
   /*
   /*
    * When swr fetch succeeded
    * When swr fetch succeeded
@@ -108,6 +109,7 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
     isReadOnlyUser,
     isReadOnlyUser,
     isOpen: false,
     isOpen: false,
     isWipPageShown,
     isWipPageShown,
+    targetPath,
     targetPathOrId,
     targetPathOrId,
     onRenamed,
     onRenamed,
     onClickDuplicateMenuItem,
     onClickDuplicateMenuItem,

+ 2 - 2
apps/app/src/client/components/TreeItem/interfaces/index.ts

@@ -1,5 +1,4 @@
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageToDeleteWithMeta } from '@growi/core';
-import type { Nullable } from 'vitest';
 
 
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForPageDuplicateModal } from '~/stores/modal';
 import type { IPageForPageDuplicateModal } from '~/stores/modal';
@@ -23,7 +22,8 @@ export type TreeItemToolProps = TreeItemBaseProps & {
 };
 };
 
 
 export type TreeItemProps = TreeItemBaseProps & {
 export type TreeItemProps = TreeItemBaseProps & {
-  targetPathOrId?: Nullable<string>,
+  targetPath: string,
+  targetPathOrId?:string,
   isOpen?: boolean,
   isOpen?: boolean,
   isWipPageShown?: boolean,
   isWipPageShown?: boolean,
   itemClass?: React.FunctionComponent<TreeItemProps>,
   itemClass?: React.FunctionComponent<TreeItemProps>,

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

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

+ 1 - 1
apps/app/src/client/util/bookmark-utils.ts

@@ -16,7 +16,7 @@ export const addNewFolder = async(name: string, parent: string | null): Promise<
 
 
 // Put bookmark to a folder
 // Put bookmark to a folder
 export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
 export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
-  await apiv3Post('/bookmark-folder/add-boookmark-to-folder', { pageId, folderId });
+  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', { pageId, folderId });
 };
 };
 
 
 // Delete bookmark folder
 // Delete bookmark folder

+ 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 '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 '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 '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 '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')} </>;
     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') }</>;
     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="user-groups" isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/user-groups', 'user-group-detail'])} />
         <MenuLink menu="audit-log" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="audit-log" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="plugins" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/plugins')} />
         <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')} />
         <MenuLink menu="search" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
           && (
@@ -173,7 +175,8 @@ export const AdminNavigation = (): JSX.Element => {
             {isActiveMenu('/audit-log')             && <MenuLabel menu="audit-log" />}
             {isActiveMenu('/audit-log')             && <MenuLabel menu="audit-log" />}
             {isActiveMenu('/plugins')               && <MenuLabel menu="plugins" />}
             {isActiveMenu('/plugins')               && <MenuLabel menu="plugins" />}
             {isActiveMenu('/data-transfer')         && <MenuLabel menu="data-transfer" />}
             {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 */}
             {/* eslint-enable no-multi-spaces */}
           </span>
           </span>
         </button>
         </button>

+ 15 - 2
apps/app/src/components/Layout/BasicLayout.tsx

@@ -8,6 +8,12 @@ import { RawLayout } from './RawLayout';
 
 
 import styles from './BasicLayout.module.scss';
 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'] ?? '';
 const moduleClass = styles['grw-basic-layout'] ?? '';
 
 
 
 
@@ -34,7 +40,11 @@ const DeleteBookmarkFolderModal = dynamic(
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
 );
 );
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
-const AiChatModal = dynamic(() => import('~/features/openai/chat/components/AiChatModal').then(mod => mod.AiChatModal), { ssr: false });
+const AiAssistantManagementModal = dynamic(
+  () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal')
+    .then(mod => mod.AiAssistantManagementModal), { ssr: false },
+);
+const PageSelectModal = dynamic(() => import('~/client/components/PageSelectModal/PageSelectModal').then(mod => mod.PageSelectModal), { ssr: false });
 
 
 type Props = {
 type Props = {
   children?: ReactNode
   children?: ReactNode
@@ -54,6 +64,8 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
           <AlertSiteUrlUndefined />
           <AlertSiteUrlUndefined />
           {children}
           {children}
         </div>
         </div>
+
+        <AiAssistantChatSidebar />
       </div>
       </div>
 
 
       <GrowiNavbarBottom />
       <GrowiNavbarBottom />
@@ -66,8 +78,9 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <DeleteAttachmentModal />
       <DeleteAttachmentModal />
       <DeleteBookmarkFolderModal />
       <DeleteBookmarkFolderModal />
       <PutbackPageModal />
       <PutbackPageModal />
+      <PageSelectModal />
       <SearchModal />
       <SearchModal />
-      <AiChatModal />
+      <AiAssistantManagementModal />
 
 
       <PagePresentationModal />
       <PagePresentationModal />
       <HotkeysManager />
       <HotkeysManager />

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

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

+ 1 - 1
apps/app/src/components/ShareLinkPageView/ShareLinkAlert.tsx

@@ -42,7 +42,7 @@ const ShareLinkAlert: FC<Props> = (props: Props) => {
   return (
   return (
     <p className={`alert alert-${alertColor} px-4 d-edit-none`}>
     <p className={`alert alert-${alertColor} px-4 d-edit-none`}>
       <span className="material-symbols-outlined me-1">link</span>
       <span className="material-symbols-outlined me-1">link</span>
-      {(expiredAt === null ? <span>{t('page_page.notice.no_deadline')}</span>
+      {(expiredAt == null ? <span>{t('page_page.notice.no_deadline')}</span>
       // eslint-disable-next-line react/no-danger
       // eslint-disable-next-line react/no-danger
         : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
         : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
       )}
       )}

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

@@ -30,7 +30,7 @@ interface AuthorizedRequest extends Request {
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
-  const addActivity = generateAddActivityMiddleware(crowi);
+  const addActivity = generateAddActivityMiddleware();
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
@@ -216,14 +216,14 @@ module.exports = (crowi: Crowi): Router => {
 
 
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
     const settings = {
-      ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
-      ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
-      ldapGroupMembershipAttributeType: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'),
-      ldapGroupChildGroupAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'),
-      autoGenerateUserOnLdapGroupSync: configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'),
-      preserveDeletedLdapGroups: configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'),
-      ldapGroupNameAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'),
-      ldapGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'),
+      ldapGroupSearchBase: configManager.getConfig('external-user-group:ldap:groupSearchBase'),
+      ldapGroupMembershipAttribute: configManager.getConfig('external-user-group:ldap:groupMembershipAttribute'),
+      ldapGroupMembershipAttributeType: configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType'),
+      ldapGroupChildGroupAttribute: configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute'),
+      autoGenerateUserOnLdapGroupSync: configManager.getConfig('external-user-group:ldap:autoGenerateUserOnGroupSync'),
+      preserveDeletedLdapGroups: configManager.getConfig('external-user-group:ldap:preserveDeletedGroups'),
+      ldapGroupNameAttribute: configManager.getConfig('external-user-group:ldap:groupNameAttribute'),
+      ldapGroupDescriptionAttribute: configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute'),
     };
     };
 
 
     return res.apiv3(settings);
     return res.apiv3(settings);
@@ -231,14 +231,14 @@ module.exports = (crowi: Crowi): Router => {
 
 
   router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
     const settings = {
-      keycloakHost: configManager?.getConfig('crowi', 'external-user-group:keycloak:host'),
-      keycloakGroupRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm'),
-      keycloakGroupSyncClientRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm'),
-      keycloakGroupSyncClientID: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID'),
-      keycloakGroupSyncClientSecret: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret'),
-      autoGenerateUserOnKeycloakGroupSync: configManager?.getConfig('crowi', 'external-user-group:keycloak:autoGenerateUserOnGroupSync'),
-      preserveDeletedKeycloakGroups: configManager?.getConfig('crowi', 'external-user-group:keycloak:preserveDeletedGroups'),
-      keycloakGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute'),
+      keycloakHost: configManager.getConfig('external-user-group:keycloak:host'),
+      keycloakGroupRealm: configManager.getConfig('external-user-group:keycloak:groupRealm'),
+      keycloakGroupSyncClientRealm: configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm'),
+      keycloakGroupSyncClientID: configManager.getConfig('external-user-group:keycloak:groupSyncClientID'),
+      keycloakGroupSyncClientSecret: configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret'),
+      autoGenerateUserOnKeycloakGroupSync: configManager.getConfig('external-user-group:keycloak:autoGenerateUserOnGroupSync'),
+      preserveDeletedKeycloakGroups: configManager.getConfig('external-user-group:keycloak:preserveDeletedGroups'),
+      keycloakGroupDescriptionAttribute: configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute'),
     };
     };
 
 
     return res.apiv3(settings);
     return res.apiv3(settings);
@@ -269,7 +269,7 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
 
 
     try {
     try {
-      await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+      await configManager.updateConfigs(params, { skipPubsub: true });
       return res.apiv3({}, 204);
       return res.apiv3({}, 204);
     }
     }
     catch (err) {
     catch (err) {
@@ -301,7 +301,7 @@ module.exports = (crowi: Crowi): Router => {
       };
       };
 
 
       try {
       try {
-        await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+        await configManager.updateConfigs(params, { skipPubsub: true });
         return res.apiv3({}, 204);
         return res.apiv3({}, 204);
       }
       }
       catch (err) {
       catch (err) {
@@ -319,7 +319,7 @@ module.exports = (crowi: Crowi): Router => {
       );
       );
     }
     }
 
 
-    const isLdapEnabled = await configManager.getConfig('crowi', 'security:passport-ldap:isEnabled');
+    const isLdapEnabled = await configManager.getConfig('security:passport-ldap:isEnabled');
     if (!isLdapEnabled) {
     if (!isLdapEnabled) {
       return res.apiv3Err(
       return res.apiv3Err(
         new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
         new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
@@ -349,25 +349,25 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
 
 
     const getAuthProviderType = () => {
     const getAuthProviderType = () => {
-      let kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      let kcHost = configManager.getConfig('external-user-group:keycloak:host');
       if (kcHost?.endsWith('/')) {
       if (kcHost?.endsWith('/')) {
         kcHost = kcHost.slice(0, -1);
         kcHost = kcHost.slice(0, -1);
       }
       }
-      const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
+      const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
 
 
       // starts with kcHost, contains kcGroupRealm in path
       // starts with kcHost, contains kcGroupRealm in path
       // see: https://regex101.com/r/3ihDmf/1
       // see: https://regex101.com/r/3ihDmf/1
       const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
       const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
 
 
-      const isOidcEnabled = configManager.getConfig('crowi', 'security:passport-oidc:isEnabled');
-      const oidcIssuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
+      const isOidcEnabled = configManager.getConfig('security:passport-oidc:isEnabled');
+      const oidcIssuerHost = configManager.getConfig('security:passport-oidc:issuerHost');
 
 
-      if (isOidcEnabled && regex.test(oidcIssuerHost)) return 'oidc';
+      if (isOidcEnabled && oidcIssuerHost != null && regex.test(oidcIssuerHost)) return 'oidc';
 
 
-      const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
-      const samlEntryPoint = configManager.getConfig('crowi', 'security:passport-saml:entryPoint');
+      const isSamlEnabled = configManager.getConfig('security:passport-saml:isEnabled');
+      const samlEntryPoint = configManager.getConfig('security:passport-saml:entryPoint');
 
 
-      if (isSamlEnabled && regex.test(samlEntryPoint)) return 'saml';
+      if (isSamlEnabled && samlEntryPoint != null && regex.test(samlEntryPoint)) return 'saml';
 
 
       return null;
       return null;
     };
     };

+ 7 - 6
apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts

@@ -3,19 +3,20 @@ import type { IUserHasId } from '@growi/core';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import ExternalAccount from '~/server/models/external-account';
 import ExternalAccount from '~/server/models/external-account';
 import S2sMessage from '~/server/models/vo/s2s-message';
 import S2sMessage from '~/server/models/vo/s2s-message';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
-import { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 
 import { configManager } from '../../../../server/service/config-manager';
 import { configManager } from '../../../../server/service/config-manager';
 import { externalAccountService } from '../../../../server/service/external-account';
 import { externalAccountService } from '../../../../server/service/external-account';
-import {
+import type {
   ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
   ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
 } from '../../interfaces/external-user-group';
 } from '../../interfaces/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 
 const logger = loggerFactory('growi:service:external-user-group-sync-service');
 const logger = loggerFactory('growi:service:external-user-group-sync-service');
 
 
@@ -37,7 +38,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
 
 
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
 
 
-  authProviderType: string | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
+  authProviderType: IExternalAuthProviderType | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
 
 
   socketIoService: any;
   socketIoService: any;
 
 
@@ -93,7 +94,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
     if (this.authProviderType == null) throw new Error('auth provider type is not set');
     if (this.authProviderType == null) throw new Error('auth provider type is not set');
     if (this.syncStatus.isExecutingSync) throw new Error('External user group sync is already being executed');
     if (this.syncStatus.isExecutingSync) throw new Error('External user group sync is already being executed');
 
 
-    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
+    const preserveDeletedLdapGroups = configManager.getConfig(`external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
     const existingExternalUserGroupIds: string[] = [];
     const existingExternalUserGroupIds: string[] = [];
 
 
     const socket = this.socketIoService?.getAdminSocket();
     const socket = this.socketIoService?.getAdminSocket();
@@ -183,7 +184,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
     const authProviderType = this.authProviderType;
     const authProviderType = this.authProviderType;
     if (authProviderType == null) throw new Error('auth provider type is not set');
     if (authProviderType == null) throw new Error('auth provider type is not set');
 
 
-    const autoGenerateUserOnGroupSync = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
+    const autoGenerateUserOnGroupSync = configManager.getConfig(`external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
 
 
     const getExternalAccount = async() => {
     const getExternalAccount = async() => {
       if (autoGenerateUserOnGroupSync && externalAccountService != null) {
       if (autoGenerateUserOnGroupSync && externalAccountService != null) {

+ 2 - 1
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts

@@ -146,7 +146,8 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
   };
   };
 
 
   beforeAll(async() => {
   beforeAll(async() => {
-    await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+    await configManager.loadConfigs();
+    await configManager.updateConfigs(configParams, { skipPubsub: true });
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService.init('oidc');
     keycloakUserGroupSyncService.init('oidc');
   });
   });

+ 14 - 13
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -1,13 +1,14 @@
 import KeycloakAdminClient from '@keycloak/keycloak-admin-client';
 import KeycloakAdminClient from '@keycloak/keycloak-admin-client';
-import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
-import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
+import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
+import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
 
 
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 
-import { ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import { ExternalGroupProviderType } from '../../interfaces/external-user-group';
 
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 import ExternalUserGroupSyncService from './external-user-group-sync';
 
 
@@ -22,9 +23,9 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
 
   kcAdminClient: KeycloakAdminClient;
   kcAdminClient: KeycloakAdminClient;
 
 
-  realm: string; // realm that contains the groups
+  realm: string | undefined; // realm that contains the groups
 
 
-  groupDescriptionAttribute: string; // attribute to map to group description
+  groupDescriptionAttribute: string | undefined; // attribute to map to group description
 
 
   isInitialized = false;
   isInitialized = false;
 
 
@@ -34,10 +35,10 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   }
   }
 
 
   init(authProviderType: 'oidc' | 'saml'): void {
   init(authProviderType: 'oidc' | 'saml'): void {
-    const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
-    const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
-    const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
-    const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
+    const kcHost = configManager.getConfig('external-user-group:keycloak:host');
+    const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
+    const kcGroupSyncClientRealm = configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm');
+    const kcGroupDescriptionAttribute = configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute');
 
 
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.realm = kcGroupRealm;
     this.realm = kcGroupRealm;
@@ -70,12 +71,12 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
    * Authenticate to group sync client using client credentials grant type
    * Authenticate to group sync client using client credentials grant type
    */
    */
   private async auth(): Promise<void> {
   private async auth(): Promise<void> {
-    const kcGroupSyncClientID: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID');
-    const kcGroupSyncClientSecret: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret');
+    const kcGroupSyncClientID = configManager.getConfig('external-user-group:keycloak:groupSyncClientID');
+    const kcGroupSyncClientSecret = configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret');
 
 
     await this.kcAdminClient.auth({
     await this.kcAdminClient.auth({
       grantType: 'client_credentials',
       grantType: 'client_credentials',
-      clientId: kcGroupSyncClientID,
+      clientId: kcGroupSyncClientID ?? '',
       clientSecret: kcGroupSyncClientSecret,
       clientSecret: kcGroupSyncClientSecret,
     });
     });
   }
   }

+ 12 - 10
apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts

@@ -1,12 +1,14 @@
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
-import { ldapService, SearchResultEntry } from '~/server/service/ldap';
-import PassportService from '~/server/service/passport';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { SearchResultEntry } from '~/server/service/ldap';
+import { ldapService } from '~/server/service/ldap';
+import type PassportService from '~/server/service/passport';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 
+import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
 import {
 import {
-  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, LdapGroupMembershipAttributeType,
+  ExternalGroupProviderType, LdapGroupMembershipAttributeType,
 } from '../../interfaces/external-user-group';
 } from '../../interfaces/external-user-group';
 
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 import ExternalUserGroupSyncService from './external-user-group-sync';
@@ -47,11 +49,11 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
   }
   }
 
 
   override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
   override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
-    const groupChildGroupAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute');
-    const groupMembershipAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute');
-    const groupNameAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute');
-    const groupDescriptionAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute');
-    const groupBase: string = ldapService.getGroupSearchBase();
+    const groupChildGroupAttribute = configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute');
+    const groupMembershipAttribute = configManager.getConfig('external-user-group:ldap:groupMembershipAttribute');
+    const groupNameAttribute = configManager.getConfig('external-user-group:ldap:groupNameAttribute');
+    const groupDescriptionAttribute = configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute');
+    const groupBase = ldapService.getGroupSearchBase();
 
 
     const groupEntries = await ldapService.searchGroupDir();
     const groupEntries = await ldapService.searchGroupDir();
 
 
@@ -117,7 +119,7 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
   }
   }
 
 
   private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
   private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
-    const groupMembershipAttributeType = configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType');
+    const groupMembershipAttributeType = configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType');
     const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
     const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
     const attrMapName = this.passportService.getLdapAttrNameMappedToName();
     const attrMapName = this.passportService.getLdapAttrNameMappedToName();
     const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();
     const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();

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

+ 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/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 @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 {
   .textarea-ask {
     max-height: 30vh;
     max-height: 30vh;
@@ -13,9 +19,8 @@
   }
   }
 }
 }
 
 
-
 // == Colors
 // == Colors
-.grw-aichat-modal :global {
+.grw-ai-assistant-chat-sidebar :global {
   .growi-ai-chat-icon {
   .growi-ai-chat-icon {
     color: growi-official-colors.$growi-ai-purple;
     color: growi-official-colors.$growi-ai-purple;
   }
   }
@@ -24,4 +29,3 @@
     @include btn-muted.colorize(bs.$purple, bs.$purple);
     @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 { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 
 
-import { useRagSearchModal } from '../../../client/stores/rag-search';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 
 
 import styles from './MessageCard.module.scss';
 import styles from './MessageCard.module.scss';
 
 
@@ -27,11 +27,11 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
 
 const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
 const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  const { close: closeRagSearchModal } = useRagSearchModal();
+  const { close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
 
 
   const onClick = useCallback(() => {
   const onClick = useCallback(() => {
-    closeRagSearchModal();
-  }, [closeRagSearchModal]);
+    closeAiAssistantChatSidebar();
+  }, [closeAiAssistantChatSidebar]);
 
 
   return (
   return (
     <NextLink href={props.href} onClick={onClick} className="link-primary">
     <NextLink href={props.href} onClick={onClick} className="link-primary">
@@ -55,7 +55,7 @@ const AssistantMessageCard = ({ children }: { children: string }): JSX.Element =
             )
             )
             : (
             : (
               <span className="text-thinking">
               <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>
               </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 { 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 = {
+        name,
+        description,
+        additionalInstruction: instruction,
+        pagePathPatterns,
+        shareScope: selectedShareScope,
+        accessScope: selectedAccessScope,
+        grantedGroupsForShareScope,
+        grantedGroupsForAccessScope,
+        isDefault: shouldEdit ? aiAssistant.isDefault : false,
+      };
+
+      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
+  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, aiAssistant?.isDefault, 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 '@growi/ui/scss/atoms/btn-muted';
 @use '~/client/components/PageControls/button-styles';
 @use '~/client/components/PageControls/button-styles';
 
 
-.btn-rag-search :global {
+.btn-open-default-ai-assistant :global {
   @extend %btn-basis;
   @extend %btn-basis;
 }
 }
 
 
 // == Colors
 // == Colors
-.btn-rag-search {
+.btn-open-default-ai-assistant {
   @include btn-muted.colorize(bs.$purple);
   @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 { useTranslation } from 'react-i18next';
 
 
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-
 
 
 export const AiIntegration = (): JSX.Element => {
 export const AiIntegration = (): JSX.Element => {
   const { t } = useTranslation('admin');
   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 (
   return (
     <div data-testid="admin-ai-integration">
     <div data-testid="admin-ai-integration">
       <h2 className="admin-setting-header">{ t('ai_integration.ai_search_management') }</h2>
       <h2 className="admin-setting-header">{ t('ai_integration.ai_search_management') }</h2>
 
 
       <div className="row">
       <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>
     </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]),
+  };
+};

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio