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

Merge branch 'master' into feat/enhanced-access-token

Shun Miyazawa 10 месяцев назад
Родитель
Сommit
eb4b90adfa
100 измененных файлов с 2893 добавлено и 841 удалено
  1. 1 0
      .devcontainer/app/devcontainer.json
  2. 3 0
      .devcontainer/app/postCreateCommand.sh
  3. 1 0
      .devcontainer/pdf-converter/devcontainer.json
  4. 2 2
      .github/workflows/ci-app.yml
  5. 1 1
      .github/workflows/ci-slackbot-proxy.yml
  6. 1 1
      .github/workflows/reusable-app-create-manifests.yml
  7. 10 3
      .github/workflows/reusable-app-prod.yml
  8. 1 1
      .github/workflows/reusable-app-reg-suit.yml
  9. 9 0
      .roo/mcp.json
  10. 13 0
      .vscode/settings.json
  11. 109 1
      CHANGELOG.md
  12. 9 7
      README.md
  13. 16 13
      README_JP.md
  14. 9 2
      apps/app/bin/swagger-jsdoc/definition-apiv1.js
  15. 9 2
      apps/app/bin/swagger-jsdoc/definition-apiv3.js
  16. 28 18
      apps/app/docker/Dockerfile
  17. 11 8
      apps/app/package.json
  18. 2 0
      apps/app/playwright.config.ts
  19. 0 15
      apps/app/playwright/20-basic-features/presentation.spec.ts
  20. 19 13
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  21. 25 5
      apps/app/public/static/locales/en_US/translation.json
  22. 25 6
      apps/app/public/static/locales/fr_FR/translation.json
  23. 26 7
      apps/app/public/static/locales/ja_JP/translation.json
  24. 25 6
      apps/app/public/static/locales/zh_CN/translation.json
  25. 29 1
      apps/app/resource/Contributor.js
  26. 1 1
      apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx
  27. 2 2
      apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss
  28. 2 2
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss
  29. 10 4
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  30. 26 10
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  31. 1 1
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  32. 1 1
      apps/app/src/client/components/PageComment.tsx
  33. 2 2
      apps/app/src/client/components/PageComment/Comment.module.scss
  34. 1 1
      apps/app/src/client/components/PageComment/Comment.tsx
  35. 15 2
      apps/app/src/client/components/PageComment/CommentEditor.module.scss
  36. 7 2
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  37. 1 1
      apps/app/src/client/components/PageComment/_comment-inheritance.scss
  38. 10 10
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx
  39. 3 3
      apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  40. 33 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx
  41. 0 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.module.scss
  42. 11 7
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx
  43. 0 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx
  44. 0 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx
  45. 4 3
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx
  46. 1 0
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/index.ts
  47. 8 7
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  48. 1 1
      apps/app/src/client/components/PageEditor/_page-editor-inheritance.scss
  49. 4 2
      apps/app/src/client/components/PageHeader/PageHeader.tsx
  50. 4 0
      apps/app/src/client/components/PageHeader/PagePathHeader.tsx
  51. 28 8
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  52. 1 1
      apps/app/src/client/components/PageHistory/Revision.module.scss
  53. 19 11
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx
  54. 1 0
      apps/app/src/client/components/PageTags/TagsInput.tsx
  55. 0 1
      apps/app/src/client/components/SavePageControls/GrantSelector/index.ts
  56. 1 1
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  57. 5 0
      apps/app/src/client/components/SearchTypeahead.tsx
  58. 3 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  59. 1 1
      apps/app/src/client/components/Sidebar/SidebarBrandLogo.tsx
  60. 2 2
      apps/app/src/client/components/UsersHomepageFooter.module.scss
  61. 12 8
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  62. 4 4
      apps/app/src/components/Layout/BasicLayout.tsx
  63. 1 1
      apps/app/src/components/PageView/PageView.tsx
  64. 1 1
      apps/app/src/components/ShareLinkPageView/ShareLinkPageView.tsx
  65. 1 1
      apps/app/src/components/User/UserInfo.module.scss
  66. 1 1
      apps/app/src/components/User/UserInfo.tsx
  67. 47 1
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  68. 3 3
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  69. 4 2
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  70. 0 455
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx
  71. 0 79
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx
  72. 35 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView.tsx
  73. 74 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown.tsx
  74. 2 2
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.module.scss
  75. 545 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  76. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.module.scss
  77. 126 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx
  78. 40 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/QuickMenuList.tsx
  79. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/ResizableTextArea.tsx
  80. 4 4
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx
  81. 4 18
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  82. 389 0
      apps/app/src/features/openai/client/services/editor-assistant.tsx
  83. 332 0
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  84. 35 11
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  85. 2 2
      apps/app/src/features/openai/client/stores/message.tsx
  86. 4 3
      apps/app/src/features/openai/client/stores/thread.tsx
  87. 17 0
      apps/app/src/features/openai/client/utils/get-share-scope-Icon.ts
  88. 32 0
      apps/app/src/features/openai/interfaces/editor-assistant/llm-response-schemas.ts
  89. 47 0
      apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.ts
  90. 16 0
      apps/app/src/features/openai/interfaces/knowledge-assistant/sse-schemas.ts
  91. 6 0
      apps/app/src/features/openai/interfaces/message.ts
  92. 9 0
      apps/app/src/features/openai/interfaces/thread-relation.ts
  93. 6 2
      apps/app/src/features/openai/server/models/thread-relation.ts
  94. 55 17
      apps/app/src/features/openai/server/models/vector-store-file-relation.ts
  95. 146 0
      apps/app/src/features/openai/server/routes/edit/README.ja.md
  96. 272 0
      apps/app/src/features/openai/server/routes/edit/index.ts
  97. 4 3
      apps/app/src/features/openai/server/routes/index.ts
  98. 2 3
      apps/app/src/features/openai/server/routes/message/get-messages.ts
  99. 2 0
      apps/app/src/features/openai/server/routes/message/index.ts
  100. 20 20
      apps/app/src/features/openai/server/routes/message/post-message.ts

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

@@ -24,6 +24,7 @@
     "vscode": {
       "extensions": [
         "dbaeumer.vscode-eslint",
+        "biomejs.biome",
         "mhutchie.git-graph",
         "eamodio.gitlens",
         "github.vscode-pull-request-github",

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

@@ -11,6 +11,9 @@ mkdir -p /tmp/page-bulk-export
 sudo chown -R vscode:vscode /tmp/page-bulk-export
 sudo chmod 700 /tmp/page-bulk-export
 
+# Install uv
+curl -LsSf https://astral.sh/uv/install.sh | sh
+
 # Setup pnpm
 SHELL=bash pnpm setup
 eval "$(cat /home/vscode/.bashrc)"

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

@@ -16,6 +16,7 @@
     "vscode": {
       "extensions": [
         "dbaeumer.vscode-eslint",
+        "biomejs.biome",
         "mhutchie.git-graph",
         "eamodio.gitlens"
       ],

+ 2 - 2
.github/workflows/ci-app.yml

@@ -74,7 +74,7 @@ jobs:
 
       - name: Lint
         run: |
-          turbo run lint --filter=!@growi/slackbot-proxy
+          turbo run lint --filter=@growi/app --filter=./packages/*
 
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
@@ -128,7 +128,7 @@ jobs:
 
       - name: Test
         run: |
-          turbo run test --filter=!@growi/slackbot-proxy --env-mode=loose
+          turbo run test --filter=@growi/app --filter=./packages/* --env-mode=loose
         env:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 

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

@@ -59,7 +59,7 @@ jobs:
 
     - name: Lint
       run: |
-        turbo run lint --filter=@growi/slackbot-proxy
+        turbo run lint --filter=@growi/slackbot-proxy --filter=@growi/slack
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master

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

@@ -45,7 +45,7 @@ jobs:
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
 
     - name: Create and push manifest images
-      uses: Noelware/docker-manifest-action@master
+      uses: Noelware/docker-manifest-action@0.4.3
       with:
         base-image: ${{ inputs.tags }}
         extra-images: ${{ steps.meta-extra-images.outputs.tags }}

+ 10 - 3
.github/workflows/reusable-app-prod.yml

@@ -262,12 +262,19 @@ jobs:
         MONGO_URI: mongodb://mongodb:27017/growi-playwright-guest-mode
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
-    - name: Upload test results
+    - name: Generate shard ID
+      id: shard-id
       if: always()
+      run: |
+        SHARD_ID=$(echo "${{ matrix.shard }}" | tr '/' '-')
+        echo "shard_id=${SHARD_ID}" >> $GITHUB_OUTPUT
+
+    - name: Upload test results
       uses: actions/upload-artifact@v4
+      if: always()
       with:
-        name: blob-report-${{ matrix.browser }}-${{ matrix.shard }}
-        path: blob-report
+        name: blob-report-${{ matrix.browser }}-${{ steps.shard-id.outputs.shard_id }}
+        path: ./apps/app/blob-report
         retention-days: 30
 
     - name: Slack Notification

+ 1 - 1
.github/workflows/reusable-app-reg-suit.yml

@@ -1,4 +1,4 @@
-name: Reusable build app workflow for production
+name: Reusable VRT reporting workflow for production
 
 on:
   workflow_call:

+ 9 - 0
.roo/mcp.json

@@ -0,0 +1,9 @@
+{
+  "mcpServers": {
+    "fetch": {
+      "command": "uvx",
+      "args": ["mcp-server-fetch"],
+      "alwaysAllow": ["fetch"]
+    }
+  }
+}

+ 13 - 0
.vscode/settings.json

@@ -3,6 +3,14 @@
 
   "eslint.workingDirectories": [{ "mode": "auto" }],
 
+  "[typescript]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
+  "[javascript]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],
@@ -13,10 +21,14 @@
 
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": "explicit",
+    "source.fixAll.biome": "explicit",
+    "source.organizeImports.biome": "explicit",
     "source.fixAll.markdownlint": "explicit",
     "source.fixAll.stylelint": "explicit"
   },
 
+  "editor.formatOnSave": true,
+
   "githubPullRequests.ignoredPullRequestBranches": [
     "master"
   ],
@@ -28,4 +40,5 @@
   "typescript.surveys.enabled": false,
 
   "vitest.filesWatcherInclude": "**/*"
+
 }

+ 109 - 1
CHANGELOG.md

@@ -1,9 +1,117 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.2.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.2.5...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.2.5](https://github.com/weseek/growi/compare/v7.2.4...v7.2.5) - 2025-05-28
+
+### 💎 Features
+
+* feat: Save attachment to VectorStore (#9906) @miya
+
+### 🚀 Improvement
+
+* imprv: Headers and CopyDropdown UX (#9973) @yuki-takei
+* imprv(api): Support Bearer token (#9971) @yuki-takei
+* imprv: Add orgId and appId to bulk export path (#9946) @arafubeatbox
+
+### 🐛 Bug Fixes
+
+* fix: Prevent submission during text composition in SearchTypeahead (#9977) @miya
+* fix(ai): First user message is not displayed (#9976) @miya
+* fix: Vectorstore file not rebuilt after page update (#9951) @miya
+
+### 🧰 Maintenance
+
+* ci(deps-dev): bump vite from 5.4.17 to 5.4.19 (#9911) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Prevent SSRF for slack integration (#9952) @yuki-takei
+* support: Remark lsx package biome migration (#9948) @arafubeatbox
+* support: UI package biome migration (#9945) @arafubeatbox
+* support: Migrate linter/formatter to biome for @growi/slack package (#9917) @arafubeatbox
+
+## [v7.2.4](https://github.com/weseek/growi/compare/v7.2.3...v7.2.4) - 2025-05-15
+
+### 🐛 Bug Fixes
+
+* fix: Picture size (#9938) @yuki-takei
+
+## [v7.2.3](https://github.com/weseek/growi/compare/v7.2.2...v7.2.3) - 2025-05-14
+
+### 💎 Features
+
+* feat(ai): Unified merge view (#9643) @yuki-takei
+
+### 🚀 Improvement
+
+* imprv(ai): AI models and instructions (#9913) @yuki-takei
+* imprv(ai): Evaluate article headers (#9921) @yuki-takei
+* imprv(ai): Tidy up instructions (#9918) @yuki-takei
+* imprv: Disable page bulk export when file upload settings are not configured (#9900) @arafubeatbox
+* imprv: add contributors that has not been added to konami command (#9901) @Ryosei-Fukushima
+* imprv(ai): AI models and instructions (#9913) @yuki-takei
+* imprv: Hide summary mode switch in editor assistant mode (#9897) @miya
+* imprv: User picture tooltip (#9892) @yuki-takei
+* imprv: User picture tooltip (2) (#9898) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: PagePathHeader maxWidth for editor (#9930) @yuki-takei
+* fix: Pages list API (#9928) @yuki-takei
+* fix: Set OpenTelemetry resource attribute `service.instance.id` (#9902) @yuki-takei
+* fix: User picture tooltip (2) (#9898) @yuki-takei
+* fix: ConfigLoader.loadFromDB for JSON parsing error handling (#9890) @yuki-takei
+* fix: Profile image upload functionality and accepted file types (#9886) @yuki-takei
+* fix: Tooltip for UserPicture doesn't work (#9884) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Improve the official docker image size (#9874) @yuki-takei
+* support: Upgrade openai package (#9909) @yuki-takei
+* support(pdf-converter): Improve the official docker image size for pdf-converter (#9880) @yuki-takei
+* support: Improve the official docker image size (#9874) @yuki-takei
+
+## [v7.2.2](https://github.com/weseek/growi/compare/v7.2.1...v7.2.2) - 2025-04-17
+
+### 🐛 Bug Fixes
+
+* fix: Export page API is broken (#9870) @yuki-takei
+
+## [v7.2.1](https://github.com/weseek/growi/compare/v7.2.0...v7.2.1) - 2025-04-16
+
+### 💎 Features
+
+* feat: Page bulk export (pdf export included) (#9678) @arafubeatbox
+
+### 🚀 Improvement
+
+* imprv: Add util method to get react instance of growi via growifacade (#9775) @NaokiHigashi28
+* imprv: Adjust comment editor clickable area (#9840) @yuki-takei
+* imprv: Make user activation middleware securely (#9818) @yuki-takei
+* imprv: Prepare to upgrade React and Next.js (#9816) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: The order of multer middleware (#9772) @NaokiHigashi28
+* fix: growi pluginkit growifacade typo (#9812) @NaokiHigashi28
+* fix: Migration script to remove index for 'ns' from config collection (#9770) @yuki-takei
+* fix: Assistant remains default when made unpublished (#9763) @miya
+* fix: Fix layout when safari browse (#9744) @satof3
+* fix:  Prevent GrowiPlugin from being downloaded outside the plugin directory (#9712) @NaokiHigashi28
+* fix: The link for Telemetry of GROWI Docs (#9737) @yuki-takei
+
+### 🧰 Maintenance
+
+* ci(deps): bump next from 14.2.22 to 14.2.25 (#9761) @[dependabot[bot]](https://github.com/apps/dependabot)
+* ci(deps-dev): bump vite from 5.4.16 to 5.4.17 (#9837) @[dependabot[bot]](https://github.com/apps/dependabot)
+* ci(deps-dev): bump vite from 5.4.15 to 5.4.16 (#9830) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Upgrade Next.js (#9825) @yuki-takei
+* support: Upgrade SWR (#9814) @yuki-takei
+* ci(deps-dev): bump vite from 5.4.14 to 5.4.15 (#9781) @[dependabot[bot]](https://github.com/apps/dependabot)
+* ci(deps): bump dependabot/fetch-metadata from 1 to 2 (#9636) @[dependabot[bot]](https://github.com/apps/dependabot)
+* ci(deps): bump docker/build-push-action from 4 to 6 (#9638) @[dependabot[bot]](https://github.com/apps/dependabot)
+* ci(deps): bump release-drafter/release-drafter from 5 to 6 (#9637) @[dependabot[bot]](https://github.com/apps/dependabot)
+
 ## [v7.2.0](https://github.com/weseek/growi/compare/v7.1.9...v7.2.0) - 2025-03-11
 
 ### 💎 Features

+ 9 - 7
README.md

@@ -2,12 +2,12 @@
 
 <p align="center">
   <a href="https://growi.org">
-    <img src="https://user-images.githubusercontent.com/1638767/38254268-d4476bbe-3793-11e8-964c-8865d690baff.png" width="240px">
+    <img src="https://github.com/user-attachments/assets/0acf1409-cea7-4f0e-841c-af5bd8be6711" width="360px">
   </a>
 </p>
 <p align="center">
-  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
-  <a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
+  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg" alt="Latest version"></a>
+  <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
 </p>
 
 <p align="center">
@@ -16,12 +16,14 @@
 
 # GROWI
 
-[![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
+[![CodeQL](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml)
+[![Node CI for app development](https://github.com/weseek/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app.yml)
+[![Node CI for app production](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml)
+
+## Demonstration
+<video src="https://private-user-images.githubusercontent.com/34241526/333079483-fee540d7-2fa6-46d7-833e-74014c5340e3.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk2OTEsIm5iZiI6MTcxNjQ0OTM5MSwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzk0ODMtZmVlNTQwZDctMmZhNi00NmQ3LTgzM2UtNzQwMTRjNTM0MGUzLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3Mjk1MVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTBkYWFkMmYyYmIwMTI2YWE3ZmQzZTFiNWU3ZThkMDc5NDA5N2Q3YWE5ZGM1NDgwNjk0OGNjYjZmOTJkM2IzZGQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.FAvLseWBzE62yFA7wt26uERamvEVQdIGRVdBwk0uLhE"></video>
 
-|                                                     demonstration                                                     |
-| :-------------------------------------------------------------------------------------------------------------------: |
-| ![sample image](https://user-images.githubusercontent.com/42988650/70600974-6b29cc80-1c34-11ea-94ef-33c39c6a00dc.gif) |
 
 ## Table Of Contents
 

+ 16 - 13
README_JP.md

@@ -1,13 +1,14 @@
 - [English 🇺🇸](./README.md)
-  <p align="center">
-    <a href="https://growi.org">
-      <img src="https://user-images.githubusercontent.com/1638767/38254268-d4476bbe-3793-11e8-964c-8865d690baff.png" width="240px">
-    </a>
-  </p>
-  <p align="center">
-    <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
-    <a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
-  </p>
+
+<p align="center">
+  <a href="https://growi.org">
+    <img src="https://github.com/user-attachments/assets/0acf1409-cea7-4f0e-841c-af5bd8be6711" width="360px">
+  </a>
+</p>
+<p align="center">
+  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg" alt="Latest version"></a>
+  <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
+</p>
 
 <p align="center">
   <a href="https://docs.growi.org">ドキュメント</a> / <a href="https://demo.growi.org">デモ</a>
@@ -15,12 +16,14 @@
 
 # GROWI
 
-[![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
+[![CodeQL](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml)
+[![Node CI for app development](https://github.com/weseek/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app.yml)
+[![Node CI for app production](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml)
+
+## デモ
+<video src="https://private-user-images.githubusercontent.com/34241526/333079216-cec7f7d8-c3cc-4ee7-bc94-167b056d4ce2.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk0MDQsIm5iZiI6MTcxNjQ0OTEwNCwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzkyMTYtY2VjN2Y3ZDgtYzNjYy00ZWU3LWJjOTQtMTY3YjA1NmQ0Y2UyLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3MjUwNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWQ2M2IwZjc0ZGNhOWQxNWE4MGIyZTZlMzQ0ZDQ4MGZlNjEzMWE3MTQ1YmMwYzg3MmY1NWMyZWI2NzQ3NGIwMTkmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.qLfu5120JrkdfpghXlLG8wCn0p4JNZ7W8AD3zUJTIYY"></video>
 
-|                                                 デモンストレーション                                                 |
-| :-------------------------------------------------------------------------------------------------------------------: |
-| ![sample image](https://user-images.githubusercontent.com/42988650/70600974-6b29cc80-1c34-11ea-94ef-33c39c6a00dc.gif) |
 
 ## 目次
 

+ 9 - 2
apps/app/bin/swagger-jsdoc/definition-apiv1.js

@@ -13,15 +13,22 @@ module.exports = {
   ],
   security: [
     {
-      api_key: [],
+      bearer: [],
+      accessTokenInQuery: [],
     },
   ],
   components: {
     securitySchemes: {
-      api_key: {
+      bearer: {
+        type: 'http',
+        scheme: 'bearer',
+        description: 'Access token generated by each GROWI users',
+      },
+      accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',
         in: 'query',
+        description: 'Access token generated by each GROWI users',
       },
     },
   },

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

@@ -13,15 +13,22 @@ module.exports = {
   ],
   security: [
     {
-      api_key: [],
+      bearer: [],
+      accessTokenInQuery: [],
     },
   ],
   components: {
     securitySchemes: {
-      api_key: {
+      bearer: {
+        type: 'http',
+        scheme: 'bearer',
+        description: 'Access token generated by each GROWI users',
+      },
+      accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',
         in: 'query',
+        description: 'Access token generated by each GROWI users',
       },
       cookieAuth: {
         type: 'apiKey',

+ 28 - 18
apps/app/docker/Dockerfile

@@ -1,25 +1,31 @@
-# syntax = docker/dockerfile:1
+# syntax = docker/dockerfile:1.4
 
+ARG OPT_DIR="/opt"
+ARG PNPM_HOME="/root/.local/share/pnpm"
 
 ##
 ## base
 ##
 FROM node:20-slim AS base
 
-ENV optDir=/opt
+ARG OPT_DIR
+ARG PNPM_HOME
 
-WORKDIR ${optDir}
+WORKDIR $OPT_DIR
 
 # install tools
-RUN apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
+RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    --mount=type=cache,target=/var/cache/apt,sharing=locked \
+  apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
 
 # install pnpm
 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=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 
 # install turbo
-RUN pnpm add turbo --global
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm add turbo --global
 
 
 
@@ -28,14 +34,17 @@ RUN pnpm add turbo --global
 ##
 FROM base AS builder
 
-ENV optDir=/opt
+ENV PNPM_HOME=$PNPM_HOME
+ENV PATH="$PNPM_HOME:$PATH"
 
-WORKDIR ${optDir}
+WORKDIR $OPT_DIR
 
 COPY . .
 
-RUN pnpm add node-gyp --global
-RUN pnpm install ---frozen-lockfile
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm add node-gyp --global
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm install ---frozen-lockfile
 
 # build
 RUN turbo run clean
@@ -45,7 +54,7 @@ RUN turbo run build --filter @growi/app
 RUN pnpm deploy out --prod --filter @growi/app
 RUN rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
 RUN rm -rf apps/app/.next/cache
-RUN tar -zcf packages.tar.gz \
+RUN tar -zcf /tmp/packages.tar.gz \
   package.json \
   apps/app/.next \
   apps/app/config \
@@ -66,27 +75,28 @@ RUN tar -zcf packages.tar.gz \
 FROM node:20-slim
 LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
 
+ARG OPT_DIR
+
 ENV NODE_ENV="production"
 
-ENV optDir=/opt
-ENV appDir=${optDir}/growi
+ENV appDir="$OPT_DIR/growi"
 
 # Add gosu
 # see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
-RUN set -eux; \
+RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    --mount=type=cache,target=/var/cache/apt,sharing=locked \
+  set -eux; \
 	apt-get update; \
 	apt-get install -y gosu; \
 	rm -rf /var/lib/apt/lists/*; \
 # verify that the binary works
 	gosu nobody true
 
-COPY --from=builder --chown=node:node \
-  ${optDir}/packages.tar.gz ${appDir}/
-
 # extract artifacts as 'node' user
 USER node
 WORKDIR ${appDir}
-RUN tar -zxf packages.tar.gz && rm packages.tar.gz
+RUN --mount=type=bind,from=builder,source=/tmp/packages.tar.gz,target=/tmp/packages.tar.gz \
+  tar -zxf /tmp/packages.tar.gz -C ${appDir}/
 
 COPY --chown=node:node --chmod=700 apps/app/docker/docker-entrypoint.sh /
 

+ 11 - 8
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.2.1-RC.0",
+  "version": "7.2.6-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -31,7 +31,7 @@
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:swagger2openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:swagger2openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
-    "lint": "run-p lint:*",
+    "lint": "run-p lint:**",
     "prelint:swagger2openapi:apiv3": "pnpm run swagger2openapi:apiv3",
     "prelint:swagger2openapi:apiv1": "pnpm run swagger2openapi:apiv1",
     "test": "run-p test:*",
@@ -64,7 +64,7 @@
     "@aws-sdk/client-s3": "3.454.0",
     "@aws-sdk/s3-request-presigner": "3.454.0",
     "@azure/identity": "^4.4.1",
-    "@azure/openai": "^2.0.0-beta.2",
+    "@azure/openai": "^2.0.0",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@cspell/dynamic-import": "^8.15.4",
@@ -73,6 +73,7 @@
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "workspace:^",
+    "@growi/pdf-converter-client": "workspace:^",
     "@growi/pluginkit": "workspace:^",
     "@growi/presentation": "workspace:^",
     "@growi/preset-templates": "workspace:^",
@@ -82,20 +83,20 @@
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
-    "@growi/pdf-converter-client": "workspace:^",
     "@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",
+    "@opentelemetry/semantic-conventions": "^1.28.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@types/async": "^3.2.24",
+    "@types/multer": "^1.4.12",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
@@ -145,6 +146,7 @@
     "is-iso-date": "^0.0.1",
     "js-tiktoken": "^1.0.15",
     "js-yaml": "^4.1.0",
+    "jsonrepair": "^3.12.0",
     "katex": "^0.16.21",
     "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
@@ -167,7 +169,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.25",
+    "next": "^14.2.26",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^0.0.4",
@@ -176,7 +178,7 @@
     "node-cron": "^3.0.2",
     "nodemailer": "^6.9.15",
     "nodemailer-ses-transport": "~1.5.0",
-    "openai": "^4.56.0",
+    "openai": "^4.96.2",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
     "passport": "^0.6.0",
@@ -246,7 +248,8 @@
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
     "y-socket.io": "^1.1.3",
-    "yjs": "^13.6.18"
+    "yjs": "^13.6.18",
+    "zod": "^3.24.2"
   },
   "// comments for defDependencies": {
     "bootstrap": "v5.3.3 has a bug. refs: https://github.com/twbs/bootstrap/issues/39798",

+ 2 - 0
apps/app/playwright.config.ts

@@ -72,6 +72,8 @@ export default defineConfig({
     trace: 'on-first-retry',
 
     viewport: { width: 1400, height: 1024 },
+
+    screenshot: 'only-on-failure',
   },
 
   /* Configure projects for major browsers */

+ 0 - 15
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -10,19 +10,4 @@ test('Presentation', async({ page }) => {
   // check the content of the h1
   await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
     .toHaveText(/Welcome to GROWI/);
-
-  // forward the slide with keyboard
-  await page.keyboard.press('ArrowRight');
-
-  // check the content of the h1
-  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
-    .toHaveText(/What can you do with GROWI?/);
-
-  // forward the slide with button
-  await page.getByRole('application').getByLabel('next slide').click();
-
-  // check the content of the h2
-  await expect(page.getByRole('application').getByRole('heading', { level: 2 }))
-    .toHaveText(/1. Knowledge Management: Create pages to store information and knowledge/);
-
 });

+ 19 - 13
apps/app/playwright/20-basic-features/use-tools.spec.ts

@@ -1,25 +1,31 @@
 import { test, expect, type Page } from '@playwright/test';
 
 const openPageItemControl = async(page: Page): Promise<void> => {
-  await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
-  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  const nav = page.getByTestId('grw-contextual-sub-nav');
+  const button = nav.getByTestId('open-page-item-control-btn');
+
+  // Wait for navigation element to be visible and attached
+  await expect(nav).toBeVisible();
+  await nav.waitFor({ state: 'visible' });
+
+  // Wait for button to be visible, enabled and attached
+  await expect(button).toBeVisible();
+  await expect(button).toBeEnabled();
+  await button.waitFor({ state: 'visible' });
+
+  // Add a small delay to ensure the button is fully interactive
+  await page.waitForTimeout(100);
+
+  await button.click();
 };
 
-test('Page Deletion and PutBack is executed successfully', async({ page }) => {
-  await page.goto('/Sandbox/Bootstrap5');
+test('PageDeleteModal is shown successfully', async({ page }) => {
+  await page.goto('/Sandbox');
 
-  // Delete
   await openPageItemControl(page);
   await page.getByTestId('open-page-delete-modal-btn').click();
+
   await expect(page.getByTestId('page-delete-modal')).toBeVisible();
-  await page.getByTestId('delete-page-button').click();
-
-  // PutBack
-  await expect(page.getByTestId('trash-page-alert')).toBeVisible();
-  await page.getByTestId('put-back-button').click();
-  await expect(page.getByTestId('put-back-page-modal')).toBeVisible();
-  await page.getByTestId('put-back-execution-button').click();
-  await expect(page.getByTestId('trash-page-alert')).not.toBeVisible();
 });
 
 test('PageDuplicateModal is shown successfully', async({ page }) => {

+ 25 - 5
apps/app/public/static/locales/en_US/translation.json

@@ -154,6 +154,7 @@
   "In-App Notification": "Notifications",
   "AI Assistant": "AI Assistant",
   "Knowledge Assistant": "Knowledge Assistant (Beta)",
+  "Editor Assistant": "Editor Assistant (Beta)",
   "original_path": "Original path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
@@ -373,6 +374,7 @@
       "file": "File only"
     },
     "editor_config": "Editor Config",
+    "editor_assistant": "Editor Assistant",
     "Show active line": "Show active line",
     "auto_format_table": "Auto format table",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
@@ -522,19 +524,36 @@
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
   },
-  "sidebar_aichat": {
-    "instruction_label": "Assistant instructions",
+  "sidebar_ai_assistant": {
     "reference_pages_label": "Reference pages",
     "placeholder": "Ask me anything.",
+    "knowledge_assistant_placeholder": "Ask me anything.",
+    "editor_assistant_placeholder": "Can I help you with anything?",
     "summary_mode_label": "Summary mode",
     "summary_mode_help": "Concise answer within 2-3 sentences",
+    "extended_thinking_mode_label": "Extended Thinking Mode",
+    "extended_thinking_mode_help": "When enabled, the AI will take more time to think and provide a more comprehensive answer.",
     "caution_against_hallucination": "Please verify the information and check the sources.",
     "progress_label": "Generating answers",
     "failed_to_create_or_retrieve_thread": "Failed to create or retrieve thread",
     "budget_exceeded": "You have reached your usage limit for OpenAI's API. To use the Knowledge Assistant again, please add credits from the OpenAI billing page.",
     "budget_exceeded_for_growi_cloud": "You have reached your OpenAI API usage limit. To use the Knowledge Assistant again, please add credits from the GROWI.cloud admin page for Hosted users or from the OpenAI billing page for Owned users.",
     "error_message": "An error has occurred",
-    "show_error_detail": "Show error details"
+    "show_error_detail": "Show error details",
+    "discard": "Discard",
+    "accept": "Accept",
+    "use_assistant": "Use Assistant",
+    "remove_assistant": "Deselect the selected assistant",
+    "preset_menu": {
+      "summarize": {
+        "title": "Summarize this article",
+        "prompt": "Please summarize the markdown content"
+      },
+      "correct": {
+        "title": "Correct errors in the text",
+        "prompt": "Please correct the errors in the markdown text"
+      }
+    }
   },
   "modal_ai_assistant": {
     "header": {
@@ -560,7 +579,7 @@
       "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.",
+    "default_instruction": "You are the knowledge assistant for this Wiki.\n\n## Multilingual Support:\nRespond in the same language the user uses in their input.\n",
     "add_page_button": "Add page",
     "page_mode_title": {
       "share": "Assistant Sharing",
@@ -796,7 +815,8 @@
     "export_cancel_warning": "The following export in progress will be canceled",
     "restart": "Restart",
     "format": "Format",
-    "started_on": "Started on"
+    "started_on": "Started on",
+    "file_upload_not_configured": "File upload settings are not configured"
   },
   "message": {
     "successfully_connected": "Successfully Connected!",

+ 25 - 6
apps/app/public/static/locales/fr_FR/translation.json

@@ -155,6 +155,7 @@
   "In-App Notification": "Notifications",
   "AI Assistant": "Assistant IA",
   "Knowledge Assistant": "Assistant de Connaissances (Bêta)",
+  "Editor Assistant": "Assistante de rédaction (Bêta)",
   "original_path": "Chemin originel",
   "new_path": "Nouveau chemin",
   "duplicated_path": "Chemin dupliqué",
@@ -374,6 +375,7 @@
       "file": "Fichier seulement"
     },
     "editor_config": "Préférences de l'éditeur",
+    "editor_assistant": "Assistant d'édition",
     "Show active line": "Surligner la ligne active",
     "auto_format_table": "Formatter les tableaux",
     "overwrite_scopes": "{{operation}} et écraser les scopes des pages enfants",
@@ -517,19 +519,35 @@
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
   },
-  "sidebar_aichat": {
-    "instruction_label": "Instructions pour l'assistant",
+  "sidebar_ai_assistant": {
     "reference_pages_label": "Pages de référence",
-    "placeholder": "Demandez-moi n'importe quoi.",
+    "knowledge_assistant_placeholder": "Demandez-moi n'importe quoi.",
+    "editor_assistant_placeholder": "Puis-je vous aider ?",
     "summary_mode_label": "Mode résumé",
     "summary_mode_help": "Réponse concise en 2-3 phrases",
+    "extended_thinking_mode_label": "Mode réflexion approfondie",
+    "extended_thinking_mode_help": "Lorsqu'activé, l'IA prendra plus de temps pour réfléchir et fournir une réponse plus complète.",
     "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources.",
     "progress_label": "Génération des réponses",
     "failed_to_create_or_retrieve_thread": "Échec de la création ou de la récupération du fil de discussion",
     "budget_exceeded": "Vous avez atteint votre limite d'utilisation de l'API de l'OpenAI. Pour utiliser à nouveau l'assistant de connaissance, veuillez ajouter des crédits à partir de la page de facturation d'OpenAI.",
     "budget_exceeded_for_growi_cloud": "Vous avez atteint votre limite d'utilisation de l'API de l'OpenAI. Pour utiliser à nouveau l'assistant de connaissance, veuillez ajouter des crédits à partir de la page d'administration de GROWI.cloud pour les utilisateurs hébergés ou à partir de la page de facturation de l'OpenAI pour les utilisateurs propriétaires.",
     "error_message": "Erreur",
-    "show_error_detail": "Détails de l'exposition"
+    "show_error_detail": "Détails de l'exposition",
+    "discard": "Annuler",
+    "accept": "Accepter",
+    "use_assistant": "Utiliser l'assistant",
+    "remove_assistant": "Désélectionner l'assistant sélectionné",
+    "preset_menu": {
+      "summarize": {
+        "title": "Résumer cet article'",
+        "prompt": "Veuillez résumer le contenu markdown"
+      },
+      "correct": {
+        "title": "Corriger les erreurs du texte",
+        "prompt": "Veuillez corriger les erreurs dans le texte markdown"
+      }
+    }
   },
   "modal_ai_assistant": {
     "header": {
@@ -555,7 +573,7 @@
       "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.",
+    "default_instruction": "Vous êtes l'assistant de connaissances pour ce Wiki.\n\n## Support multilingue :\nRépondez dans la même langue que celle utilisée par l'utilisateur dans sa requête.\n",
     "add_page_button": "Ajouter une page",
     "page_mode_title": {
       "share": "Partage de l'assistant",
@@ -791,7 +809,8 @@
     "export_cancel_warning": "Les exportations suivantes en cours seront annulées",
     "restart": "Redémarrage",
     "format": "Format",
-    "started_on": "Commencé le"
+    "started_on": "Commencé le",
+    "file_upload_not_configured": "Les paramètres de téléchargement de fichiers ne sont pas configurés"
   },
   "message": {
     "successfully_connected": "Connecté!",

+ 26 - 7
apps/app/public/static/locales/ja_JP/translation.json

@@ -155,6 +155,7 @@
   "In-App Notification": "通知",
   "AI Assistant": "AI アシスタント",
   "Knowledge Assistant": "ナレッジアシスタント (ベータ版)",
+  "Editor Assistant": "エディターアシスタント (ベータ版)",
   "original_path": "元のパス",
   "new_path": "新しいパス",
   "duplicated_path": "重複したパス",
@@ -405,7 +406,8 @@
       "text": "テキストのみ",
       "file": "ファイルのみ"
     },
-    "editor_config": "エディタ設定",
+    "editor_config": "エディター設定",
+    "editor_assistant": "エディターアシスタント",
     "Show active line": "アクティブ行をハイライト",
     "auto_format_table": "表の自動整形",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
@@ -555,19 +557,35 @@
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
-  "sidebar_aichat": {
-    "instruction_label": "アシスタントへの指示",
+  "sidebar_ai_assistant": {
     "reference_pages_label": "参照するページ",
-    "placeholder": "ききたいことを入力してください",
+    "knowledge_assistant_placeholder": "ききたいことを入力してください",
+    "editor_assistant_placeholder": "お手伝いできることはありますか?",
     "summary_mode_label": "要約モード",
     "summary_mode_help": "2~3文以内の簡潔な回答",
+    "extended_thinking_mode_label": "拡張思考モード",
+    "extended_thinking_mode_help": "有効にすると、AIはより時間をかけて考え、より包括的な回答を提供します。",
     "caution_against_hallucination": "情報が正しいか出典を確認しましょう",
     "progress_label": "回答を生成しています",
     "failed_to_create_or_retrieve_thread": "スレッドの作成または取得に失敗しました",
     "budget_exceeded": "OpenAI の API の利用上限に達しました。ナレッジアシスタントを再度利用するには OpenAI の請求ページからクレジットを追加してください。",
     "budget_exceeded_for_growi_cloud": "OpenAI の API の利用上限に達しました。ナレッジアシスタントを再度利用するには Hosted の場合は GROWI.cloud の管理画面から Owned の場合は OpenAI の請求ページからクレジットを追加してください。",
     "error_message": "エラーが発生しました",
-    "show_error_detail": "詳細を表示"
+    "show_error_detail": "詳細を表示",
+    "discard": "破棄",
+    "accept": "採用",
+    "use_assistant": "アシスタントを使用する",
+    "remove_assistant": "選択されているアシスタントの解除",
+    "preset_menu": {
+      "summarize": {
+        "title": "この記事の要約をつくる",
+        "prompt": "マークダウンの内容を要約してください"
+      },
+      "correct": {
+        "title": "文章の誤りを修正する",
+        "prompt": "マークダウンの内の文章の誤りを修正してください"
+      }
+    }
   },
   "modal_ai_assistant": {
     "header": {
@@ -592,8 +610,8 @@
       "create_failed": "アシスタントの作成に失敗しました",
       "update_failed": "アシスタントの更新に失敗しました"
     },
-    "default_instruction": "あなたはこのWikiの知識アシスタントです。以下の方針で支援を行ってください:\n\n- 文書の関連性分析と情報の関連付け\n- 新しい視点の提案\n- 質問の意図を理解した的確な情報提供 必要に応じて構造化された形式で情報を提供します。",
     "edit_page_description": " アシスタントが参照するページを編集します。<br> 参照できるページは配下ページも含めて {{limitLearnablePageCountPerAssistant}} ページまでです。",
+    "default_instruction": "あなたはこのWikiの知識アシスタントです。\n\n## 多言語サポート:\nユーザーが入力で使用した言語と同じ言語で応答してください。\n",
     "add_page_button": "ページを追加する",
     "page_mode_title": {
       "share": "アシスタントの共有",
@@ -829,7 +847,8 @@
     "export_cancel_warning": "進行中の以下のエクスポートはキャンセルされます",
     "restart": "やり直す",
     "format": "形式",
-    "started_on": "開始日時"
+    "started_on": "開始日時",
+    "file_upload_not_configured": "ファイルアップロード設定が完了していません"
   },
   "message": {
     "successfully_connected": "接続に成功しました!",

+ 25 - 6
apps/app/public/static/locales/zh_CN/translation.json

@@ -160,6 +160,7 @@
   "In-App Notification": "通知",
   "AI Assistant": "AI助手",
   "Knowledge Assistant": "知识助手 (测试版)",
+  "Editor Assistant": "编辑助理 (测试版)",
   "original_path": "Original path",
   "new_path": "New path",
   "duplicated_path": "Duplicated path",
@@ -364,6 +365,7 @@
       "file": "仅文件"
     },
     "editor_config": "编辑器配置",
+    "editor_assistant": "编辑助手",
 		"Show active line": "显示活动行",
 		"auto_format_table": "自动格式化表格",
 		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
@@ -513,19 +515,35 @@
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
   },
-  "sidebar_aichat": {
-    "instruction_label": "助手指令",
+  "sidebar_ai_assistant": {
     "reference_pages_label": "参考页面",
-    "placeholder": "问我任何问题。",
+    "knowledge_assistant_placeholder": "问我任何问题。",
+    "editor_assistant_placeholder": "有什么需要帮忙的吗?",
     "summary_mode_label": "摘要模式",
     "summary_mode_help": "简洁回答在2-3句话内",
+    "extended_thinking_mode_label": "延伸思考模式",
+    "extended_thinking_mode_help": "启用后,AI 将花更多时间思考并提供更全面的回答。",
     "caution_against_hallucination": "请核实信息并检查来源。",
     "progress_label": "生成答案中",
     "failed_to_create_or_retrieve_thread": "创建或获取线程失败",
     "budget_exceeded": "您已达到 OpenAI API 的使用上限。要再次使用知识助手,请从 OpenAI 账单页面添加点数。",
     "budget_exceeded_for_growi_cloud": "您已达到 OpenAI API 使用上限。如需再次使用知识助手,请从GROWI.cloud管理页面为托管用户添加点数,或从OpenAI计费页面为自有用户添加点数。",
     "error_message": "错误",
-    "show_error_detail": "显示详情"
+    "show_error_detail": "显示详情",
+    "discard": "丢弃",
+    "accept": "接受",
+    "use_assistant": "使用助手",
+    "remove_assistant": "取消选定的助手",
+    "preset_menu": {
+      "summarize": {
+        "title": "为此文章创建摘要",
+        "prompt": "请总结这个 markdown 内容"
+      },
+      "correct": {
+        "title": "修正文本中的错误",
+        "prompt": "请修正 markdown 中的文本错误"
+      }
+    }
   },
   "modal_ai_assistant": {
     "header": {
@@ -551,7 +569,7 @@
       "update_failed": "更新助手失败"
     },
     "edit_page_description": "编辑助手可以参考的页面。<br> 助手可以参考最多 {{limitLearnablePageCountPerAssistant}} 个页面,包括子页面。",
-    "default_instruction": "您是这个Wiki的知识助手。请按照以下方针提供支持:\n\n- 分析文档相关性并连接信息\n- 提出新的观点\n- 理解问题意图并提供准确信息\n必要时我会以结构化的形式提供信息。",
+    "default_instruction": "您是这个Wiki的知识助手。\n\n## 多语言支持:\n请使用用户输入中使用的相同语言进行回复。\n",
     "add_page_button": "添加页面",
     "page_mode_title": {
       "share": "助理共享",
@@ -801,7 +819,8 @@
     "export_cancel_warning": "以下正在进行的导出将被取消",
     "restart": "重新开始",
     "format": "格式",
-    "started_on": "开始于"
+    "started_on": "开始于",
+    "file_upload_not_configured": "未配置文件上传设置"
   },
   "message": {
     "successfully_connected": "连接成功!",

+ 29 - 1
apps/app/resource/Contributor.js

@@ -17,6 +17,7 @@ const contributors = [
           { position: 'Titan', name: 'ryoh15' },
           { position: 'Haberion', name: 'hakumizuki' },
           { position: 'Undefined', name: 'miya' },
+          { position: 'Hoimi Slime', name: 'satof3' },
         ],
       },
       {
@@ -58,13 +59,32 @@ const contributors = [
           { name: 'yoshiro-s' },
           { name: 'kuimac' },
           { name: 'akira-sugiyama' },
+          { name: 'Ryosei-Fukushima' },
+          { name: 'kazutoweseek' },
+          { name: 'reiji-h' },
+          { name: 'atsuki-t' },
+          { name: 'moekumasaka' },
+          { name: 'WNomunomu' },
+          { name: 'abichan99911111' },
+          { name: 'naoki-higashi-28' },
+          { name: 'meiri-k' },
+          { name: 'soumaeda' },
+          { name: 'akin0ri' },
+          { name: 'ffujisawa' },
+          { name: 'maeshinshin' },
+          { name: 'arafubeatbox' },
+          { name: 'Shunm634-source' },
+          { name: 'kamij-i' },
+          { name: 'shironegi39' },
+          { name: 'ryo-h15' },
+          { name: 'jam411' },
         ],
       },
     ],
   },
   {
     order: 10,
-    sectionName: 'CONTRIBUTER',
+    sectionName: 'CONTRIBUTOR',
     additionalClass: '',
     memberGroups: [
       {
@@ -104,6 +124,13 @@ const contributors = [
           { name: 'tats-u' },
           { name: 'yamatomo717' },
           { name: 'tohutohu' },
+          { name: 'Lanhild' },
+          { name: 'urzk' },
+          { name: 'Mxchaeltrxn' },
+          { name: 'nakashimaki' },
+          { name: 'ToshihitoKon' },
+          { name: 'sakazuki' },
+          { name: 'Takahirostride' },
         ],
       },
     ],
@@ -140,6 +167,7 @@ const contributors = [
           { name: 'Crowi Team' },
           { position: 'Ambassador', name: 'Tsuyoshi Suzuki' },
           { name: 'JPCERT/CC' },
+          { name: 'goofmint' },
         ],
       },
       {

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

@@ -125,7 +125,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
                     {isCustomizedLogoUploaded && (
                       <>
                         <p>
-                          <img src={CUSTOMIZED_LOGO} className="picture picture-lg " id="settingBrandLogo" width="64" />
+                          <img src={CUSTOMIZED_LOGO} id="settingBrandLogo" width="64" />
                         </p>
                         <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
                           { t('admin:customize_settings.delete_logo') }

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

@@ -10,12 +10,12 @@ $date-font-size: 12px;
     font-size: $date-font-size;
   }
 
-  .picture {
+  .user-picture {
     width: 22px;
     height: 22px;
     border: 1px solid bs.$gray-300;
 
-    &.picture-xs {
+    &.user-picture-xs {
       width: 14px;
       height: 14px;
     }

+ 2 - 2
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -65,12 +65,12 @@ $grw-bookmark-item-padding-left: 35px;
       min-width: 30px;
       height: 50px;
 
-      .picture {
+      .user-picture {
         width: 16px;
         height: 16px;
         vertical-align: text-bottom;
 
-        &.picture-md {
+        &.user-picture-md {
           width: 20px;
           height: 20px;
         }

+ 10 - 4
apps/app/src/client/components/Me/ProfileImageSettings.tsx

@@ -11,7 +11,6 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/stores-universal/context';
 import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
 
-
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
 
@@ -113,7 +112,7 @@ const ProfileImageSettings = (): JSX.Element => {
               </a>
             </div>
           </h5>
-          <img src={generateGravatarSrc(currentUser.email)} className="rounded-pill" width="64" data-vrt-blackout-profile />
+          <img src={generateGravatarSrc(currentUser.email)} className="rounded-pill" width="64" height="64" data-vrt-blackout-profile />
         </div>
 
         <div className="col-md-7 mt-5 mt-md-0">
@@ -138,7 +137,9 @@ const ProfileImageSettings = (): JSX.Element => {
               { t('Current Image') }
             </label>
             <div className="col-md-6 col-lg-8">
-              <p className="mb-0"><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
+              <p className="mb-0">
+                <img src={uploadedPictureSrc ?? DEFAULT_IMAGE} width="64" height="64" className="rounded-circle" id="settingUserPicture" />
+              </p>
               {uploadedPictureSrc && <button type="button" className="btn btn-danger mt-2" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
             </div>
           </div>
@@ -147,7 +148,12 @@ const ProfileImageSettings = (): JSX.Element => {
               {t('Upload new image')}
             </label>
             <div className="col-md-6 col-lg-8">
-              <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
+              <input
+                type="file"
+                onChange={selectFileHandler}
+                name="profileImage"
+                accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,image/avif,image/heic,image/heif,image/tiff,image/svg+xml"
+              />
             </div>
           </div>
         </div>

+ 26 - 10
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -16,7 +16,7 @@ import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
-import { DropdownItem, UncontrolledTooltip } from 'reactstrap';
+import { DropdownItem, UncontrolledTooltip, Tooltip } from 'reactstrap';
 
 import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
@@ -26,7 +26,8 @@ import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import {
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsBulkExportPagesEnabled, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsBulkExportPagesEnabled,
+  useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId, useIsUploadEnabled,
 } from '~/stores-universal/context';
 import { useEditorMode } from '~/stores-universal/ui';
 import {
@@ -79,6 +80,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isBulkExportPagesEnabled } = useIsBulkExportPagesEnabled();
+  const { data: isUploadEnabled } = useIsUploadEnabled();
 
   const { open: openPresentationModal } = usePagePresentationModal();
   const { open: openAccessoriesModal } = usePageAccessoriesModal();
@@ -86,6 +88,8 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
+  const [isBulkExportTooltipOpen, setIsBulkExportTooltipOpen] = useState(false);
+
   const syncLatestRevisionBodyHandler = useCallback(async() => {
     // eslint-disable-next-line no-alert
     const answer = window.confirm(t('sync-latest-revision-body.confirm'));
@@ -144,15 +148,27 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
 
       {/* Bulk export */}
       {isBulkExportPagesEnabled && (
-        <span id="bulkExportDropdownItem">
-          <DropdownItem
-            onClick={openPageBulkExportSelectModal}
-            className="grw-page-control-dropdown-item"
+        <>
+          <span id="bulkExportDropdownItem">
+            <DropdownItem
+              onClick={openPageBulkExportSelectModal}
+              className="grw-page-control-dropdown-item"
+              disabled={!isUploadEnabled ?? true}
+            >
+              <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
+              {t('page_export.bulk_export')}
+            </DropdownItem>
+          </span>
+          <Tooltip
+            placement={window.innerWidth < 800 ? 'bottom' : 'left'}
+            isOpen={!isUploadEnabled && isBulkExportTooltipOpen}
+            // Tooltip cannot be activated when target is disabled so set the target to wrapper span
+            target="bulkExportDropdownItem"
+            toggle={() => setIsBulkExportTooltipOpen(!isBulkExportTooltipOpen)}
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
-            {t('page_export.bulk_export')}
-          </DropdownItem>
-        </span>
+            {t('page_export.file_upload_not_configured')}
+          </Tooltip>
+        </>
       )}
 
       <DropdownItem divider />

+ 1 - 1
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -30,7 +30,7 @@ const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
         { isRelatedPageExists && (
           <CopyDropdown
             pagePath={relatedPage.path}
-            dropdownToggleId={`copydropdown-${shareLinkId}`}
+            dropdownToggleId={`copydropdown-for-share-link-list-${shareLinkId}`}
             pageId={shareLinkId}
             isShareLinkMode
           >

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

@@ -190,7 +190,7 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
                           className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
                           onClick={() => onReplyButtonClickHandler(comment._id)}
                         >
-                          <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-2" />
+                          <UserPicture user={currentUser} noLink noTooltip className="me-2" />
                           <span className="material-symbols-outlined me-1 fs-5 pb-1">reply</span><small>{t('page_comment.reply')}...</small>
                         </button>
                       </NotAvailableIfReadOnlyUserNotAllowedToComment>

+ 2 - 2
apps/app/src/client/components/PageComment/Comment.module.scss

@@ -15,8 +15,8 @@
     }
 
     // user icon
-    .picture {
-      @extend %picture;
+    .user-picture {
+      @extend %user-picture;
     }
 
     // comment section

+ 1 - 1
apps/app/src/client/components/PageComment/Comment.tsx

@@ -151,7 +151,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
         <div id={commentId} className={rootClassName}>
           <div className="page-comment-main bg-comment rounded mb-2">
             <div className="d-flex align-items-center">
-              <UserPicture user={creator} additionalClassName="me-2" />
+              <UserPicture user={creator} className="me-2" />
               <div className="small fw-bold me-3">
                 <Username user={creator} />
               </div>

+ 15 - 2
apps/app/src/client/components/PageComment/CommentEditor.module.scss

@@ -13,8 +13,8 @@
     }
 
     // user icon
-    .picture {
-      @extend %picture;
+    .user-picture {
+      @extend %user-picture;
     }
 
   }
@@ -23,6 +23,12 @@
 
 // adjust height
 .comment-editor-styles :global {
+  // Set `display: flex` instead of `display: block` to make it work with `flex: 1` of the children
+  // This helps users focus on the editor by clicking on the broader area
+  .tab-pane.active {
+    display: flex;
+  }
+
   .cm-editor {
     min-height: comment-inheritance.$codemirror-default-height !important;
     max-height: #{2 * comment-inheritance.$codemirror-default-height};
@@ -42,3 +48,10 @@
     border-radius: var(--bs-border-radius);
   }
 }
+
+// remove outline
+.comment-editor-styles :global {
+  .cm-editor {
+    outline: none;
+  }
+}

+ 7 - 2
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -1,6 +1,6 @@
 import type { ReactNode, JSX } from 'react';
 import React, {
-  useCallback, useState, useEffect,
+  useCallback, useState, useEffect, useLayoutEffect,
   useMemo,
 } from 'react';
 
@@ -224,6 +224,11 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     codeMirrorEditor?.initDoc(commentBody);
   }, [codeMirrorEditor, commentBody]);
 
+  // set handler to focus
+  useLayoutEffect(() => {
+    if (showPreview) return;
+    codeMirrorEditor?.focus();
+  }, [codeMirrorEditor, showPreview]);
 
   const errorMessage = useMemo(() => <span className="text-danger text-end me-2">{error}</span>, [error]);
   const cancelButton = useMemo(() => (
@@ -336,7 +341,7 @@ export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
               onClick={() => setIsReadyToUse(true)}
               data-testid="open-comment-editor-button"
             >
-              <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-3" />
+              <UserPicture user={currentUser} noLink noTooltip className="me-3" />
               <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
               <small>{t('page_comment.add_a_comment')}...</small>
             </button>

+ 1 - 1
apps/app/src/client/components/PageComment/_comment-inheritance.scss

@@ -21,7 +21,7 @@
   }
 }
 
-%picture {
+%user-picture {
   width: 1.2em;
   height: 1.2em;
 }

+ 10 - 10
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -1,6 +1,6 @@
 import { type FC, useState } from 'react';
 
-import type { IUserHasId } from '@growi/core';
+import type { EditingClient } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { Popover, PopoverBody } from 'reactstrap';
 
@@ -11,30 +11,30 @@ import styles from './EditingUserList.module.scss';
 const userListPopoverClass = styles['user-list-popover'] ?? '';
 
 type Props = {
-  userList: IUserHasId[]
+  clientList: EditingClient[]
 }
 
-export const EditingUserList: FC<Props> = ({ userList }) => {
+export const EditingUserList: FC<Props> = ({ clientList }) => {
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
 
-  const firstFourUsers = userList.slice(0, 4);
-  const remainingUsers = userList.slice(4);
+  const firstFourUsers = clientList.slice(0, 4);
+  const remainingUsers = clientList.slice(4);
 
-  if (userList.length === 0) {
+  if (clientList.length === 0) {
     return <></>;
   }
 
   return (
     <div className="d-flex flex-column justify-content-start justify-content-sm-end">
       <div className="d-flex justify-content-start justify-content-sm-end">
-        {firstFourUsers.map(user => (
-          <div key={user._id} className="ms-1">
+        {firstFourUsers.map(editingClient => (
+          <div key={editingClient.clientId} className="ms-1">
             <UserPicture
-              user={user}
+              user={editingClient}
               noLink
-              additionalClassName="border border-info"
+              className="border border-info"
             />
           </div>
         ))}

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

@@ -1,7 +1,7 @@
 import type { JSX } from 'react';
 
 import { PageHeader } from '~/client/components/PageHeader';
-import { useEditingUsers } from '~/stores/use-editing-users';
+import { useEditingClients } from '~/stores/use-editing-clients';
 
 import { EditingUserList } from './EditingUserList';
 
@@ -10,10 +10,10 @@ import styles from './EditorNavbar.module.scss';
 const moduleClass = styles['editor-navbar'] ?? '';
 
 const EditingUsers = (): JSX.Element => {
-  const { data: editingUsers } = useEditingUsers();
+  const { data: editingClients } = useEditingClients();
   return (
     <EditingUserList
-      userList={editingUsers?.userList ?? []}
+      clientList={editingClients ?? []}
     />
   );
 };

+ 33 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorAssistantToggleButton.tsx

@@ -0,0 +1,33 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useAiAssistantSidebar } from '~/features/openai/client/stores/ai-assistant';
+
+export const EditorAssistantToggleButton = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data, close, openEditor } = useAiAssistantSidebar();
+  const { isOpened } = data ?? {};
+
+  const toggle = useCallback(() => {
+    if (isOpened) {
+      close();
+      return;
+    }
+
+    openEditor();
+  }, [isOpened, openEditor, close]);
+
+  return (
+    <button
+      type="button"
+      className={`btn btn-sm btn-outline-neutral-secondary py-0 ${data?.isOpened ? 'active' : ''}`}
+      onClick={toggle}
+    >
+      <span className="d-flex align-items-center">
+        <span className="material-symbols-outlined">support_agent</span>
+        <span className="ms-1 me-1">{t('page_edit.editor_assistant')}</span>
+      </span>
+    </button>
+  );
+};

+ 0 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom.module.scss → apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.module.scss


+ 11 - 7
apps/app/src/client/components/PageEditor/EditorNavbarBottom.tsx → apps/app/src/client/components/PageEditor/EditorNavbarBottom/EditorNavbarBottom.tsx

@@ -2,18 +2,21 @@ import type { JSX } from 'react';
 
 import dynamic from 'next/dynamic';
 
+import { useIsAiEnabled } from '~/stores-universal/context';
 import { useDrawerOpened } from '~/stores/ui';
 
+import { EditorAssistantToggleButton } from './EditorAssistantToggleButton';
+
 import styles from './EditorNavbarBottom.module.scss';
 
 
 const moduleClass = styles['grw-editor-navbar-bottom'];
 
-const SavePageControls = dynamic(() => import('~/client/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
-const OptionsSelector = dynamic(() => import('~/client/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
-
-const EditorNavbarBottom = (): JSX.Element => {
+const SavePageControls = dynamic(() => import('./SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
+const OptionsSelector = dynamic(() => import('./OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 
+export const EditorNavbarBottom = (): JSX.Element => {
+  const { data: isAiEnabled } = useIsAiEnabled();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
 
   return (
@@ -26,8 +29,11 @@ const EditorNavbarBottom = (): JSX.Element => {
         >
           <span className="material-symbols-outlined fs-2">reorder</span>
         </a>
-        <form className="me-auto">
+        <form className="me-auto d-flex gap-2">
           <OptionsSelector />
+          {isAiEnabled && (
+            <EditorAssistantToggleButton />
+          )}
         </form>
         <form>
           <SavePageControls />
@@ -36,5 +42,3 @@ const EditorNavbarBottom = (): JSX.Element => {
     </div>
   );
 };
-
-export default EditorNavbarBottom;

+ 0 - 0
apps/app/src/client/components/SavePageControls/GrantSelector/GrantSelector.tsx → apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx


+ 0 - 0
apps/app/src/client/components/PageEditor/OptionsSelector.tsx → apps/app/src/client/components/PageEditor/EditorNavbarBottom/OptionsSelector.tsx


+ 4 - 3
apps/app/src/client/components/SavePageControls.tsx → apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx

@@ -23,9 +23,10 @@ import { useSWRxCurrentPage, useCurrentPagePath } from '~/stores/page';
 import { useIsDeviceLargerThanMd, useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
-import { NotAvailable } from './NotAvailable';
-import { GrantSelector } from './SavePageControls/GrantSelector';
-import { SlackNotification } from './SlackNotification';
+import { NotAvailable } from '../../NotAvailable';
+import { SlackNotification } from '../../SlackNotification';
+
+import { GrantSelector } from './GrantSelector';
 
 
 declare global {

+ 1 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom/index.ts

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

+ 8 - 7
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -27,7 +27,7 @@ import {
   useDefaultIndentSize, useCurrentUser,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
   useIsEditable, useIsIndentSizeForced,
-  useAcceptedUploadFileType,
+  useAcceptedUploadFileType, useIsEnableUnifiedMergeView,
 } from '~/stores-universal/context';
 import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { useNextThemes } from '~/stores-universal/use-next-themes';
@@ -44,11 +44,11 @@ import {
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
 import { useIsUntitledPage, useSelectedGrant } from '~/stores/ui';
-import { useEditingUsers } from '~/stores/use-editing-users';
+import { useEditingClients } from '~/stores/use-editing-clients';
 import loggerFactory from '~/utils/logger';
 
 import { EditorNavbar } from './EditorNavbar';
-import EditorNavbarBottom from './EditorNavbarBottom';
+import { EditorNavbarBottom } from './EditorNavbarBottom';
 import Preview from './Preview';
 import { useScrollSync } from './ScrollSyncHelper';
 import { useConflictResolver, useConflictEffect, type ConflictHandler } from './conflict';
@@ -108,9 +108,10 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   const { data: editorSettings } = useEditorSettings();
   const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(currentPage?._id);
   const { data: user } = useCurrentUser();
-  const { onEditorsUpdated } = useEditingUsers();
+  const { mutate: mutateEditingUsers } = useEditingClients();
   const onConflict = useConflictResolver();
   const { data: reservedNextCaretLine, mutate: mutateReservedNextCaretLine } = useReservedNextCaretLine();
+  const { data: isEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
 
   const { data: rendererOptions } = usePreviewOptions();
 
@@ -365,7 +366,8 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
     <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}
+          enableUnifiedMergeView={isEnableUnifiedMergeView}
+          enableCollaboration={editorMode === EditorMode.Editor}
           onSave={saveWithShortcut}
           onUpload={uploadHandler}
           acceptedUploadFileType={acceptedUploadFileType}
@@ -373,9 +375,8 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
           indentSize={currentIndentSize ?? defaultIndentSize}
           user={user ?? undefined}
           pageId={pageId ?? undefined}
-          initialValue={initialValue}
           editorSettings={editorSettings}
-          onEditorsUpdated={onEditorsUpdated}
+          onEditorsUpdated={mutateEditingUsers}
           cmProps={cmProps}
         />
       </div>

+ 1 - 1
apps/app/src/client/components/PageEditor/_page-editor-inheritance.scss

@@ -1 +1 @@
-$navbar-editor-height: 32.8px;
+$navbar-editor-height: 37.8px;

+ 4 - 2
apps/app/src/client/components/PageHeader/PageHeader.tsx

@@ -26,8 +26,10 @@ export const PageHeader = (): JSX.Element => {
       setMaxWidth(300);
       return;
     }
-    // At least 10px space between PageHeader and PageControls
-    const maxWidth = pageControlsX - pageHeaderRef.current.getBoundingClientRect().x - 10;
+
+    // PageControls.x - PageHeader.x
+    const maxWidth = pageControlsX - pageHeaderRef.current.getBoundingClientRect().x;
+
     setMaxWidth(maxWidth);
   }, [pageControlsX]);
 

+ 4 - 0
apps/app/src/client/components/PageHeader/PagePathHeader.tsx

@@ -108,6 +108,9 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
 
   const isInvalid = validationResult != null;
 
+  const fixedMaxWidth = maxWidth != null
+    ? maxWidth - 60 // 60px is the width of the buttons
+    : undefined;
   const inputMaxWidth = maxWidth != null
     ? getAdjustedMaxWidthForAutosizeInput(maxWidth, 'sm', validationResult != null ? false : undefined) - 16
     : undefined;
@@ -121,6 +124,7 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
     >
       <div
         className="page-path-header-input d-inline-block"
+        style={{ maxWidth: fixedMaxWidth }}
       >
         { isRenameInputShown && (
           <div className="position-relative">

+ 28 - 8
apps/app/src/client/components/PageHeader/PageTitleHeader.tsx

@@ -1,5 +1,7 @@
 import type { ChangeEvent, JSX } from 'react';
-import { useState, useCallback, useEffect } from 'react';
+import {
+  useState, useCallback, useEffect, useMemo,
+} from 'react';
 
 import nodePath from 'path';
 
@@ -101,9 +103,27 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   const isInvalid = validationResult != null;
 
-  const inputMaxWidth = maxWidth != null
-    ? getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - 16
-    : undefined;
+  // calculate inputMaxWidth as the maximum width of AutoSizeInput minus the width of WIP badge and CopyDropdown
+  const inputMaxWidth = useMemo(() => {
+    if (maxWidth == null) {
+      return undefined;
+    }
+
+    const wipBadgeAndCopyDropdownWidth = 4 // me-1
+      + (currentPage.wip ? 49 : 0) // WIP badge + gap
+      + 24; // CopyDropdown
+
+    return getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - wipBadgeAndCopyDropdownWidth;
+  }, [currentPage.wip, maxWidth, validationResult]);
+
+  // calculate h1MaxWidth as the inputMaxWidth plus padding
+  const h1MaxWidth = useMemo(() => {
+    if (inputMaxWidth == null) {
+      return undefined;
+    }
+
+    return inputMaxWidth + 16; // plus the padding of px-2 because AutosizeInput has "box-sizing: content-box;"
+  }, [inputMaxWidth]);
 
   return (
     <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
@@ -129,22 +149,22 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
             ${isRenameInputShown ? 'invisible' : ''} text-truncate
             ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
           `}
-          style={{ maxWidth: inputMaxWidth }}
+          style={{ maxWidth: h1MaxWidth }}
           onClick={onClickPageTitle}
         >
           {pageTitle}
         </h1>
       </div>
 
-      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center`}>
+      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center gap-2`}>
         { currentPage.wip && (
-          <span className="badge rounded-pill text-bg-secondary ms-2">WIP</span>
+          <span className="badge rounded-pill text-bg-secondary">WIP</span>
         )}
 
         <CopyDropdown
           pageId={currentPage._id}
           pagePath={currentPage.path}
-          dropdownToggleId={`copydropdown-${currentPage._id}`}
+          dropdownToggleId={`copydropdown-in-pagetitleheader-${currentPage._id}`}
           dropdownToggleClassName="p-1"
         >
           <span className="material-symbols-outlined fs-6">content_paste</span>

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

@@ -1,5 +1,5 @@
 .revision-history-main :global {
-  img.picture-lg {
+  img.user-picture-lg {
     width: 32px;
     height: 32px;
   }

+ 19 - 11
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx

@@ -84,22 +84,30 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
     <div ref={pagePathNavRef}>
       <Sticky className={`${moduleClass} mb-4`} innerClass="pe-none" innerActiveClass="active mt-1">
         {({ status }) => {
-          const isCollapseParents = status === Sticky.STATUS_FIXED;
-          return (
-            // Controlling pointer-events
-            //  2. enable pointer-events with 'pe-auto' only against the children
-            //      which width is minimized by 'd-inline-block'
-            //
-            <div className="d-inline-block pe-auto">
-              { !isCollapseParents && <PagePathNav {...props} /> }
-              { isCollapseParents && (
+          const isParentsCollapsed = status === Sticky.STATUS_FIXED;
+
+          // Controlling pointer-events
+          //  2. enable pointer-events with 'pe-auto' only against the children
+          //      which width is minimized by 'd-inline-block'
+          //
+          if (isParentsCollapsed) {
+            return (
+              <div className="d-inline-block pe-auto">
                 <PagePathNavLayout
                   {...props}
                   latterLink={latterLink}
                   latterLinkClassName="fs-3 text-truncate"
-                  maxWidth={isCollapseParents ? navMaxWidth : undefined}
+                  maxWidth={navMaxWidth}
                 />
-              ) }
+              </div>
+            );
+          }
+
+          return (
+            // Use 'd-block' to make the children take the full width
+            // This is to improve UX when opening/closing CopyDropdown
+            <div className="d-block pe-auto">
+              <PagePathNav {...props} inline />
             </div>
           );
         }}

+ 1 - 0
apps/app/src/client/components/PageTags/TagsInput.tsx

@@ -43,6 +43,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
       event.preventDefault();
 
       // fix: https://redmine.weseek.co.jp/issues/140689
+      // "event.isComposing" is not supported
       const isComposing = event.nativeEvent.isComposing;
       if (isComposing) {
         return;

+ 0 - 1
apps/app/src/client/components/SavePageControls/GrantSelector/index.ts

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

+ 1 - 1
apps/app/src/client/components/SearchPage/SearchResultContent.tsx

@@ -216,7 +216,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       <RightComponent />
 
       <div className="container-lg grw-container-convertible pt-2 pb-2">
-        <PagePathNav pageId={page._id} pagePath={page.path} formerLinkClassName="small" latterLinkClassName="fs-3 text-truncate" />
+        <PagePathNav pageId={page._id} pagePath={page.path} isWipPage={page.wip} formerLinkClassName="small" latterLinkClassName="fs-3 text-truncate" />
       </div>
 
       <div

+ 5 - 0
apps/app/src/client/components/SearchTypeahead.tsx

@@ -148,6 +148,11 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
 
   const keyDownHandler = useCallback((event: KeyboardEvent) => {
     if (event.key === 'Enter') {
+      // do nothing while composing
+      // "event.isComposing" is not supported
+      if (event.nativeEvent.isComposing) {
+        return;
+      }
       if (onSubmit != null && input != null && input.length > 0) {
         // schedule to submit with 100ms delay
         timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);

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

@@ -108,7 +108,9 @@ const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Elemen
     <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
       <div className="d-flex w-100">
 
-        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
+        <div>
+          <UserPicture user={page.lastUpdateUser} size="md" className="d-inline-block" />
+        </div>
 
         <div className="flex-grow-1 ms-2">
           <div className={`row ${isSmall ? 'gy-0' : 'gy-1'}`}>

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

@@ -12,7 +12,7 @@ export const SidebarBrandLogo = memo((props: SidebarBrandLogoProps) => {
   return isDefaultLogo
     ? <GrowiLogo />
     // eslint-disable-next-line @next/next/no-img-element
-    : (<div><img src="/attachment/brand-logo" alt="custom logo" className="picture picture-lg p-2" id="settingBrandLogo" /></div>);
+    : (<div><img src="/attachment/brand-logo" alt="custom logo" width="48" className="p-1" id="settingBrandLogo" /></div>);
 });
 
 SidebarBrandLogo.displayName = 'SidebarBrandLogo';

+ 2 - 2
apps/app/src/client/components/UsersHomepageFooter.module.scss

@@ -24,12 +24,12 @@ $grw-sidebar-content-footer-height: 50px;
         border-radius: 0;
 
         &.grw-bookmark-item-list {
-          .picture {
+          .user-picture {
             width: 16px;
             height: 16px;
             vertical-align: text-bottom;
 
-            &.picture-md {
+            &.user-picture-md {
               width: 20px;
               height: 20px;
             }

+ 12 - 8
apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx

@@ -9,8 +9,9 @@ import styles from './PagePathNav.module.scss';
 const moduleClass = styles['grw-page-path-nav-layout'] ?? '';
 
 export type PagePathNavLayoutProps = {
-  className?: string,
   pagePath: string,
+  inline?: boolean,
+  className?: string,
   pageId?: string | null,
   isWipPage?: boolean,
   maxWidth?: number,
@@ -28,6 +29,7 @@ const CopyDropdown = dynamic(() => import('~/client/components/Common/CopyDropdo
 export const PagePathNavLayout = (props: Props): JSX.Element => {
   const {
     className = '',
+    inline = false,
     pageId, pagePath, isWipPage,
     formerLink,
     formerLinkClassName = '',
@@ -38,7 +40,9 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
 
   const { data: isNotFound } = useIsNotFound();
 
-  const copyDropdownId = `copydropdown-${pageId}`;
+  const copyDropdownId = `copydropdown-in-pagepathnavlayout-${pageId}`;
+
+  const containerLayoutClass = inline ? '' : 'd-flex align-items-center';
 
   return (
     <div
@@ -46,21 +50,21 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
       style={{ maxWidth }}
     >
       <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
-      <div className="d-flex align-items-center">
-        <h1 className={`m-0 ${latterLinkClassName}`}>
+      <div className={containerLayoutClass}>
+        <h1 className={`m-0 d-inline align-bottom ${latterLinkClassName}`}>
           {latterLink}
         </h1>
         { pageId != null && !isNotFound && (
-          <div className="d-flex align-items-center ms-2">
+          <span className="d-inline-flex align-items-center align-bottom ms-2 gap-2">
             { isWipPage && (
-              <span className="badge text-bg-secondary ms-1 me-1">WIP</span>
+              <span className="badge text-bg-secondary">WIP</span>
             )}
-            <span className=" grw-page-path-nav-copydropdown">
+            <span className="grw-page-path-nav-copydropdown">
               <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
                 <span className="material-symbols-outlined">content_paste</span>
               </CopyDropdown>
             </span>
-          </div>
+          </span>
         ) }
       </div>
     </div>

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

@@ -8,9 +8,9 @@ import { RawLayout } from './RawLayout';
 
 import styles from './BasicLayout.module.scss';
 
-const AiAssistantChatSidebar = dynamic(
-  () => import('~/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar')
-    .then(mod => mod.AiAssistantChatSidebar), { ssr: false },
+const AiAssistantSidebar = dynamic(
+  () => import('~/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar')
+    .then(mod => mod.AiAssistantSidebar), { ssr: false },
 );
 
 
@@ -67,7 +67,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
           {children}
         </div>
 
-        <AiAssistantChatSidebar />
+        <AiAssistantSidebar />
       </div>
 
       <GrowiNavbarBottom />

+ 1 - 1
apps/app/src/components/PageView/PageView.tsx

@@ -101,7 +101,7 @@ export const PageView = (props: Props): JSX.Element => {
     }
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
 
-  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} />;
+  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />;
 
   const sideContents = !isNotFound && !isNotCreatable
     ? (

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

@@ -61,7 +61,7 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
     }
   }, [disableLinkSharing, props.disableLinkSharing]);
 
-  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} />;
+  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />;
 
   const sideContents = !isNotFound
     ? (

+ 1 - 1
apps/app/src/components/User/UserInfo.module.scss

@@ -11,7 +11,7 @@
     color: bs.$secondary;
   }
 
-  .picture {
+  .user-picture {
     width: 120px;
     height: 120px;
   }

+ 1 - 1
apps/app/src/components/User/UserInfo.tsx

@@ -20,7 +20,7 @@ export const UserInfo = (props: UserInfoProps): JSX.Element => {
 
   return (
     <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`} data-testid="grw-users-info">
-      <UserPicture user={author} />
+      <UserPicture user={author} noTooltip noLink />
       <div className="users-meta">
         <h1 className="user-page-name">
           {author.name}

+ 47 - 1
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

@@ -30,7 +30,53 @@ module.exports = (crowi: Crowi): Router => {
     ],
   };
 
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.list,
+  /**
+   * @swagger
+   * /external-user-group-relations:
+   *   get:
+   *     summary: /external-user-group-relations
+   *     description: Get user group relations
+   *     tags: [ExternalUserGroups]
+   *     security:
+   *       - cookieAuth: []
+   *     parameters:
+   *       - name: groupIds
+   *         in: query
+   *         description: The group IDs to get relations for
+   *         schema:
+   *           type: array
+   *           items:
+   *             type: string
+   *       - name: childGroupIds
+   *         in: query
+   *         description: The child group IDs to get relations for
+   *         required: false
+   *         schema:
+   *           type: array
+   *           items:
+   *             type: string
+   *     responses:
+   *       200:
+   *         description: The user group relations
+   *         content:
+   *           application/json:
+   *             schema:
+   *               type: object
+   *               properties:
+   *                 userGroupRelations:
+   *                   type: array
+   *                   items:
+   *                     type: object
+   *                 relationsOfChildGroups:
+   *                   type: array
+   *                   items:
+   *                     type: object
+   */
+  router.get('/',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validators.list,
     async(req: Request, res: ApiV3Response) => {
       const { query } = req;
 

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

@@ -454,13 +454,13 @@ module.exports = (crowi: Crowi): Router => {
   /**
    * @swagger
    *   paths:
-   *     /external-user-groups/:id/external-user-group-relations:
+   *     /external-user-groups/{id}/external-user-group-relations:
    *       get:
    *         tags: [ExternalUserGroups]
    *         security:
    *           - cookieAuth: []
    *         operationId: getExternalUserGroupRelations
-   *         summary: /external-user-groups/:id/external-user-group-relations
+   *         summary: /external-user-groups/{id}/external-user-group-relations
    *         parameters:
    *           - name: id
    *             in: path
@@ -819,7 +819,7 @@ module.exports = (crowi: Crowi): Router => {
    *             content:
    *               application/json:
    *                 schema:
-   *                 type: object
+   *                   type: object
    */
   router.put('/keycloak/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
     async(req: AuthorizedRequest, res: ApiV3Response) => {

+ 4 - 2
apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

@@ -109,7 +109,8 @@ module.exports = (crowi: Crowi): Router => {
    *       - name: id
    *         in: path
    *         required: true
-   *         type: string
+   *         schema:
+   *           type: string
    *     responses:
    *       200:
    *         description: OK
@@ -164,7 +165,8 @@ module.exports = (crowi: Crowi): Router => {
    *       - name: id
    *         in: path
    *         required: true
-   *         type: string
+   *         schema:
+   *           type: string
    *     responses:
    *       200:
    *         description: OK

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

@@ -1,455 +0,0 @@
-import type { KeyboardEvent, JSX } 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 - 79
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx

@@ -1,79 +0,0 @@
-import { useCallback, type JSX } from 'react';
-
-import type { LinkProps } from 'next/link';
-import { useTranslation } from 'react-i18next';
-import ReactMarkdown from 'react-markdown';
-
-import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
-
-import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
-
-import styles from './MessageCard.module.scss';
-
-const moduleClass = styles['message-card'] ?? '';
-
-
-const userMessageCardModuleClass = styles['user-message-card'] ?? '';
-
-const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
-  <div className={`card d-inline-flex align-self-end bg-success-subtle bg-info-subtle ${moduleClass} ${userMessageCardModuleClass}`}>
-    <div className="card-body">
-      <ReactMarkdown>{children}</ReactMarkdown>
-    </div>
-  </div>
-);
-
-
-const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
-
-const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  const { close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
-
-  const onClick = useCallback(() => {
-    closeAiAssistantChatSidebar();
-  }, [closeAiAssistantChatSidebar]);
-
-  return (
-    <NextLink href={props.href} onClick={onClick} className="link-primary">
-      {props.children}
-    </NextLink>
-  );
-};
-const AssistantMessageCard = ({ children }: { children: string }): JSX.Element => {
-  const { t } = useTranslation();
-
-  return (
-    <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
-      <div className="card-body d-flex">
-        <div className="me-2 me-lg-3">
-          <span className="growi-custom-icons grw-ai-icon rounded-pill">growi_ai</span>
-        </div>
-        <div>
-          { children.length > 0
-            ? (
-              <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
-            )
-            : (
-              <span className="text-thinking">
-                {t('sidebar_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
-              </span>
-            )
-          }
-        </div>
-      </div>
-    </div>
-  );
-};
-
-type Props = {
-  role: 'user' | 'assistant',
-  children: string,
-}
-
-export const MessageCard = (props: Props): JSX.Element => {
-  const { role, children } = props;
-
-  return role === 'user'
-    ? <UserMessageCard>{children}</UserMessageCard>
-    : <AssistantMessageCard>{children}</AssistantMessageCard>;
-};

+ 35 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView.tsx

@@ -0,0 +1,35 @@
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  description: string,
+  pagePathPatterns: string[],
+}
+
+export const AiAssistantChatInitialView: React.FC<Props> = ({ description, pagePathPatterns }: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <p className="fs-6 text-body-secondary mb-0">
+        {description}
+      </p>
+
+      <div>
+        <div className="d-flex align-items-center">
+          <p className="text-body-secondary mb-0">{t('sidebar_ai_assistant.reference_pages_label')}</p>
+        </div>
+        <div className="d-flex flex-column gap-1">
+          { pagePathPatterns.map(pagePathPattern => (
+            <a
+              key={pagePathPattern}
+              href="#"
+              className="fs-6 text-body-secondary text-decoration-none"
+            >
+              {pagePathPattern}
+            </a>
+          ))}
+        </div>
+      </div>
+    </>
+  );
+};

+ 74 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown.tsx

@@ -0,0 +1,74 @@
+
+import React, { useMemo, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  UncontrolledDropdown,
+  DropdownToggle,
+  DropdownMenu,
+  DropdownItem,
+} from 'reactstrap';
+
+import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { useSWRxAiAssistants } from '../../../stores/ai-assistant';
+import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
+
+type Props = {
+  selectedAiAssistant?: AiAssistantHasId;
+  onSelect(aiAssistant?: AiAssistantHasId): void
+}
+
+export const AiAssistantDropdown = ({ selectedAiAssistant, onSelect }: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: aiAssistantData } = useSWRxAiAssistants();
+
+  const allAiAssistants = useMemo(() => {
+    if (aiAssistantData == null) {
+      return [];
+    }
+    return [...aiAssistantData.myAiAssistants, ...aiAssistantData.teamAiAssistants];
+  }, [aiAssistantData]);
+
+  const getAiAssistantLabel = useCallback((aiAssistant: AiAssistantHasId) => {
+    return (
+      <>
+        <span className="material-symbols-outlined fs-5 me-1">
+          {getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)}
+        </span>
+        {aiAssistant.name}
+      </>
+    );
+  }, []);
+
+  const selectAiAssistantHandler = useCallback((aiAssistant?: AiAssistantHasId) => {
+    onSelect(aiAssistant);
+  }, [onSelect]);
+
+  return (
+    <UncontrolledDropdown>
+      <DropdownToggle className="btn btn-outline-secondary" disabled={allAiAssistants.length === 0}>
+        {selectedAiAssistant != null
+          ? getAiAssistantLabel(selectedAiAssistant)
+          : <><span className="material-symbols-outlined fs-5">Add</span>{t('sidebar_ai_assistant.use_assistant')}</>
+        }
+      </DropdownToggle>
+      <DropdownMenu>
+        {allAiAssistants.map((aiAssistant) => {
+          return (
+            <DropdownItem
+              key={aiAssistant._id}
+              active={selectedAiAssistant?._id === aiAssistant._id}
+              onClick={() => selectAiAssistantHandler(aiAssistant)}
+            >
+              {getAiAssistantLabel(aiAssistant)}
+            </DropdownItem>
+          );
+        })}
+        <DropdownItem divider />
+        <DropdownItem onClick={() => selectAiAssistantHandler()}>
+          {t('sidebar_ai_assistant.remove_assistant')}
+        </DropdownItem>
+      </DropdownMenu>
+    </UncontrolledDropdown>
+  );
+};

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

@@ -2,7 +2,7 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 
-.grw-ai-assistant-chat-sidebar :global {
+.grw-ai-assistant-sidebar :global {
   z-index: bs.$zindex-fixed + 2;
   width: 100%;
 
@@ -20,7 +20,7 @@
 }
 
 // == Colors
-.grw-ai-assistant-chat-sidebar :global {
+.grw-ai-assistant-sidebar :global {
   .growi-ai-chat-icon {
     color: growi-official-colors.$growi-ai-purple;
   }

+ 545 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -0,0 +1,545 @@
+import type { KeyboardEvent, JSX } from 'react';
+import {
+  type FC, memo, useRef, useEffect, useState, useCallback, useMemo,
+} from 'react';
+
+import { Controller } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { Collapse } from 'reactstrap';
+import SimpleBar from 'simplebar-react';
+
+import { toastError } from '~/client/util/toastr';
+import { useGrowiCloudUri, useIsEnableUnifiedMergeView } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
+
+import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import type { MessageLog } from '../../../../interfaces/message';
+import { MessageErrorCode, StreamErrorCode } from '../../../../interfaces/message-error';
+import type { IThreadRelationHasId } from '../../../../interfaces/thread-relation';
+import {
+  useEditorAssistant,
+  isEditorAssistantFormData,
+  type FormData as FormDataForEditorAssistant,
+} from '../../../services/editor-assistant';
+import {
+  useKnowledgeAssistant,
+  useFetchAndSetMessageDataEffect,
+  type FormData as FormDataForKnowledgeAssistant,
+} from '../../../services/knowledge-assistant';
+import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
+import { useSWRxThreads } from '../../../stores/thread';
+
+import { MessageCard, type MessageCardRole } from './MessageCard';
+import { ResizableTextarea } from './ResizableTextArea';
+
+import styles from './AiAssistantSidebar.module.scss';
+
+const logger = loggerFactory('growi:openai:client:components:AiAssistantSidebar');
+
+const moduleClass = styles['grw-ai-assistant-sidebar'] ?? '';
+
+type FormData = FormDataForEditorAssistant | FormDataForKnowledgeAssistant;
+
+type AiAssistantSidebarSubstanceProps = {
+  isEditorAssistant: boolean;
+  aiAssistantData?: AiAssistantHasId;
+  threadData?: IThreadRelationHasId;
+  onCloseButtonClicked?: () => void;
+  onNewThreadCreated?: (thread: IThreadRelationHasId) => void;
+  onMessageReceived?: () => void;
+}
+
+const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> = (props: AiAssistantSidebarSubstanceProps) => {
+  const {
+    isEditorAssistant,
+    aiAssistantData,
+    threadData,
+    onCloseButtonClicked,
+    onNewThreadCreated,
+    onMessageReceived,
+  } = props;
+
+  // States
+  const [messageLogs, setMessageLogs] = useState<MessageLog[]>([]);
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<MessageLog>();
+  const [errorMessage, setErrorMessage] = useState<string | undefined>();
+  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
+
+  // Hooks
+  const { t } = useTranslation();
+  const { data: growiCloudUri } = useGrowiCloudUri();
+
+  const {
+    createThread: createThreadForKnowledgeAssistant,
+    postMessage: postMessageForKnowledgeAssistant,
+    processMessage: processMessageForKnowledgeAssistant,
+    form: formForKnowledgeAssistant,
+    resetForm: resetFormForKnowledgeAssistant,
+
+    // Views
+    initialView: initialViewForKnowledgeAssistant,
+    generateMessageCard: generateMessageCardForKnowledgeAssistant,
+    generateModeSwitchesDropdown: generateModeSwitchesDropdownForKnowledgeAssistant,
+    headerIcon: headerIconForKnowledgeAssistant,
+    headerText: headerTextForKnowledgeAssistant,
+    placeHolder: placeHolderForKnowledgeAssistant,
+  } = useKnowledgeAssistant();
+
+  const {
+    createThread: createThreadForEditorAssistant,
+    postMessage: postMessageForEditorAssistant,
+    processMessage: processMessageForEditorAssistant,
+    form: formForEditorAssistant,
+    resetForm: resetFormEditorAssistant,
+    isTextSelected,
+
+    // Views
+    generateInitialView: generateInitialViewForEditorAssistant,
+    generateMessageCard: generateMessageCardForEditorAssistant,
+    headerIcon: headerIconForEditorAssistant,
+    headerText: headerTextForEditorAssistant,
+    placeHolder: placeHolderForEditorAssistant,
+  } = useEditorAssistant();
+
+  const form = isEditorAssistant ? formForEditorAssistant : formForKnowledgeAssistant;
+
+  // Effects
+  useFetchAndSetMessageDataEffect(setMessageLogs, threadData?.threadId);
+
+  // Functions
+  const resetForm = useCallback(() => {
+    if (isEditorAssistant) {
+      resetFormEditorAssistant();
+    }
+
+    resetFormForKnowledgeAssistant();
+  }, [isEditorAssistant, resetFormEditorAssistant, resetFormForKnowledgeAssistant]);
+
+  const createThread = useCallback(async(initialUserMessage: string) => {
+    if (isEditorAssistant) {
+      const thread = await createThreadForEditorAssistant();
+      return thread;
+    }
+
+    if (aiAssistantData == null) {
+      return;
+    }
+    const thread = await createThreadForKnowledgeAssistant(aiAssistantData._id, initialUserMessage);
+    return thread;
+  }, [aiAssistantData, createThreadForEditorAssistant, createThreadForKnowledgeAssistant, isEditorAssistant]);
+
+  const postMessage = useCallback(async(threadId: string, formData: FormData) => {
+    if (threadId == null) {
+      throw new Error('threadId is not set');
+    }
+
+    if (isEditorAssistant) {
+      if (isEditorAssistantFormData(formData)) {
+        const response = await postMessageForEditorAssistant(threadId, formData);
+        return response;
+      }
+      return;
+    }
+    if (aiAssistantData?._id != null) {
+      const response = await postMessageForKnowledgeAssistant(aiAssistantData._id, threadId, formData);
+      return response;
+    }
+  }, [aiAssistantData?._id, isEditorAssistant, postMessageForEditorAssistant, postMessageForKnowledgeAssistant]);
+
+  const isGenerating = generatingAnswerMessage != null;
+  const submitSubstance = 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]);
+
+    resetForm();
+
+    setErrorMessage(undefined);
+
+    // add an empty assistant message
+    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
+    setGeneratingAnswerMessage(newAnswerMessage);
+
+    // create thread
+    let threadId = threadData?.threadId;
+    if (threadId == null) {
+      try {
+        const newThread = await createThread(newUserMessage.content);
+        if (newThread == null) {
+          return;
+        }
+
+        threadId = newThread.threadId;
+
+        onNewThreadCreated?.(newThread);
+      }
+      catch (err) {
+        logger.error(err.toString());
+        toastError(t('sidebar_ai_assistant.failed_to_create_or_retrieve_thread'));
+      }
+    }
+
+    // post message
+    try {
+      if (threadId == null) {
+        return;
+      }
+
+      const response = await postMessage(threadId, data);
+      if (response == null) {
+        return;
+      }
+
+      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_ai_assistant.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;
+          });
+
+          // refresh thread data
+          onMessageReceived?.();
+          return;
+        }
+
+        const chunk = decoder.decode(value);
+
+        const textValues: string[] = [];
+        const lines = chunk.split('\n\n');
+        lines.forEach((line) => {
+          const trimmedLine = line.trim();
+          if (trimmedLine.startsWith('data:')) {
+            const data = JSON.parse(line.replace('data: ', ''));
+
+            processMessageForKnowledgeAssistant(data, {
+              onMessage: (data) => {
+                textValues.push(data.content[0].text.value);
+              },
+            });
+
+            processMessageForEditorAssistant(data, {
+              onMessage: (data) => {
+                textValues.push(data.appendedMessage);
+              },
+              onDetectedDiff: (data) => {
+                logger.debug('sse diff', { data });
+              },
+              onFinalized: (data) => {
+                logger.debug('sse finalized', { data });
+              },
+            });
+          }
+          else if (trimmedLine.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_ai_assistant.budget_exceeded_for_growi_cloud' : 'sidebar_ai_assistant.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() });
+    }
+
+  // eslint-disable-next-line max-len
+  }, [isGenerating, messageLogs, resetForm, threadData?.threadId, createThread, onNewThreadCreated, t, postMessage, form, onMessageReceived, processMessageForKnowledgeAssistant, processMessageForEditorAssistant, growiCloudUri]);
+
+  const submit = useCallback((data: FormData) => {
+    if (isEditorAssistant) {
+      const markdownType = (() => {
+        if (isEditorAssistantFormData(data) && data.markdownType != null) {
+          return data.markdownType;
+        }
+
+        return isTextSelected ? 'selected' : 'none';
+      })();
+
+      return submitSubstance({ ...data, markdownType });
+    }
+
+    return submitSubstance(data);
+  }, [isEditorAssistant, isTextSelected, submitSubstance]);
+
+  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
+    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+      form.handleSubmit(submit)();
+    }
+  };
+
+  // Views
+  const headerIcon = useMemo(() => {
+    return isEditorAssistant
+      ? headerIconForEditorAssistant
+      : headerIconForKnowledgeAssistant;
+  }, [headerIconForEditorAssistant, headerIconForKnowledgeAssistant, isEditorAssistant]);
+
+  const headerText = useMemo(() => {
+    if (threadData?.title) {
+      return threadData.title;
+    }
+    return isEditorAssistant
+      ? headerTextForEditorAssistant
+      : headerTextForKnowledgeAssistant;
+  }, [threadData?.title, isEditorAssistant, headerTextForEditorAssistant, headerTextForKnowledgeAssistant]);
+
+  const placeHolder = useMemo(() => {
+    if (form.formState.isSubmitting) {
+      return '';
+    }
+    return t(isEditorAssistant
+      ? placeHolderForEditorAssistant
+      : placeHolderForKnowledgeAssistant);
+  }, [form.formState.isSubmitting, isEditorAssistant, placeHolderForEditorAssistant, placeHolderForKnowledgeAssistant, t]);
+
+  const initialView = useMemo(() => {
+    if (isEditorAssistant) {
+      return generateInitialViewForEditorAssistant(submit);
+    }
+
+    return initialViewForKnowledgeAssistant;
+  }, [generateInitialViewForEditorAssistant, initialViewForKnowledgeAssistant, isEditorAssistant, submit]);
+
+  const messageCard = useCallback(
+    (role: MessageCardRole, children: string, messageId?: string, messageLogs?: MessageLog[], generatingAnswerMessage?: MessageLog) => {
+      if (isEditorAssistant) {
+        if (messageId == null || messageLogs == null) {
+          return <></>;
+        }
+        return generateMessageCardForEditorAssistant(role, children, messageId, messageLogs, generatingAnswerMessage);
+      }
+
+      return generateMessageCardForKnowledgeAssistant(role, children);
+    }, [generateMessageCardForEditorAssistant, generateMessageCardForKnowledgeAssistant, isEditorAssistant],
+  );
+
+  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">
+          {headerIcon}
+          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">
+            {headerText}
+          </h5>
+          <button
+            type="button"
+            className="btn btn-link p-0 border-0"
+            onClick={onCloseButtonClicked}
+          >
+            <span className="material-symbols-outlined">close</span>
+          </button>
+        </div>
+        <div className="p-4 d-flex flex-column gap-4 vh-100">
+
+          { threadData != null
+            ? (
+              <div className="vstack gap-4 pb-2">
+                { messageLogs.map(message => (
+                  <>
+                    {messageCard(message.isUserMessage ? 'user' : 'assistant', message.content, message.id, messageLogs, generatingAnswerMessage)}
+                  </>
+                )) }
+                { 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_ai_assistant.caution_against_hallucination')}
+                    </span>
+                  </div>
+                )}
+              </div>
+            )
+            : (
+              <>{ initialView }</>
+            )
+          }
+
+          <div className="mt-auto">
+            <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-1">
+              <Controller
+                name="input"
+                control={form.control}
+                render={({ field }) => (
+                  <ResizableTextarea
+                    {...field}
+                    required
+                    className="form-control textarea-ask"
+                    style={{ resize: 'none' }}
+                    rows={1}
+                    placeholder={placeHolder}
+                    onKeyDown={keyDownHandler}
+                    disabled={form.formState.isSubmitting}
+                  />
+                )}
+              />
+              <div className="flex-fill hstack gap-2 justify-content-between m-0">
+                { !isEditorAssistant && generateModeSwitchesDropdownForKnowledgeAssistant(isGenerating) }
+                { isEditorAssistant && <div /> }
+                <button
+                  type="submit"
+                  className="btn btn-submit no-border"
+                  disabled={form.formState.isSubmitting || isGenerating}
+                >
+                  <span className="material-symbols-outlined">send</span>
+                </button>
+              </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_ai_assistant.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_ai_assistant.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 AiAssistantSidebar: FC = memo((): JSX.Element => {
+  const sidebarRef = useRef<HTMLDivElement>(null);
+  const sidebarScrollerRef = useRef<HTMLDivElement>(null);
+
+  const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar, refreshThreadData } = useAiAssistantSidebar();
+  const { mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
+
+  const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
+  const threadData = aiAssistantSidebarData?.threadData;
+  const isOpened = aiAssistantSidebarData?.isOpened;
+  const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false;
+
+  const { data: threads, mutate: mutateThreads } = useSWRxThreads(aiAssistantData?._id);
+
+  const newThreadCreatedHandler = useCallback((thread: IThreadRelationHasId): void => {
+    refreshThreadData(thread);
+  }, [refreshThreadData]);
+
+  useEffect(() => {
+    if (!aiAssistantSidebarData?.isOpened) {
+      mutateIsEnableUnifiedMergeView(false);
+    }
+  }, [aiAssistantSidebarData?.isOpened, mutateIsEnableUnifiedMergeView]);
+
+  // refresh thread data when the data is changed
+  useEffect(() => {
+    if (threads == null) {
+      return;
+    }
+
+    const currentThread = threads.find(t => t.threadId === threadData?.threadId);
+    if (currentThread != null) {
+      refreshThreadData(currentThread);
+    }
+  }, [threads, refreshThreadData, threadData?.threadId]);
+
+  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
+      >
+        <AiAssistantSidebarSubstance
+          isEditorAssistant={isEditorAssistant}
+          threadData={threadData}
+          aiAssistantData={aiAssistantData}
+          onMessageReceived={mutateThreads}
+          onNewThreadCreated={newThreadCreatedHandler}
+          onCloseButtonClicked={closeAiAssistantSidebar}
+        />
+      </SimpleBar>
+    </div>
+  );
+});

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


+ 126 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx

@@ -0,0 +1,126 @@
+import { useCallback, useState, type JSX } from 'react';
+
+import type { LinkProps } from 'next/link';
+import { useTranslation } from 'react-i18next';
+import ReactMarkdown from 'react-markdown';
+
+import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+
+import styles from './MessageCard.module.scss';
+
+const moduleClass = styles['message-card'] ?? '';
+
+
+const userMessageCardModuleClass = styles['user-message-card'] ?? '';
+
+const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
+  <div className={`card d-inline-flex align-self-end bg-success-subtle bg-info-subtle ${moduleClass} ${userMessageCardModuleClass}`}>
+    <div className="card-body">
+      <ReactMarkdown>{children}</ReactMarkdown>
+    </div>
+  </div>
+);
+
+
+const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
+
+const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
+  return (
+    <NextLink href={props.href} className="link-primary">
+      {props.children}
+    </NextLink>
+  );
+};
+
+const AssistantMessageCard = ({
+  children, showActionButtons, onAccept, onDiscard,
+}: {
+  children: string,
+  showActionButtons?: boolean
+  onAccept?: () => void,
+  onDiscard?: () => void,
+}): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isActionButtonClicked, setIsActionButtonClicked] = useState(false);
+
+  const clickActionButtonHandler = useCallback((action: 'accept' | 'discard') => {
+    setIsActionButtonClicked(true);
+    if (action === 'accept') {
+      onAccept?.();
+      return;
+    }
+
+    onDiscard?.();
+  }, [onAccept, onDiscard]);
+
+  return (
+    <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
+      <div className="card-body d-flex">
+        <div className="me-2 me-lg-3">
+          <span className="growi-custom-icons grw-ai-icon rounded-pill">growi_ai</span>
+        </div>
+        <div>
+          { children.length > 0
+            ? (
+              <>
+                <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
+
+                {showActionButtons && !isActionButtonClicked && (
+                  <div className="d-flex mt-2 justify-content-start">
+                    <button
+                      type="button"
+                      className="btn btn-outline-secondary me-2"
+                      onClick={() => clickActionButtonHandler('discard')}
+                    >
+                      {t('sidebar_ai_assistant.discard')}
+                    </button>
+                    <button
+                      type="button"
+                      className="btn btn-success"
+                      onClick={() => clickActionButtonHandler('accept')}
+                    >
+                      {t('sidebar_ai_assistant.accept')}
+                    </button>
+                  </div>
+                )}
+              </>
+            )
+            : (
+              <span className="text-thinking">
+                {t('sidebar_ai_assistant.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+              </span>
+            )
+          }
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export type MessageCardRole = 'user' | 'assistant';
+
+type Props = {
+  role: MessageCardRole,
+  children: string,
+  showActionButtons?: boolean,
+  onDiscard?: () => void,
+  onAccept?: () => void,
+}
+
+export const MessageCard = (props: Props): JSX.Element => {
+  const {
+    role, children, showActionButtons, onAccept, onDiscard,
+  } = props;
+
+  return role === 'user'
+    ? <UserMessageCard>{children}</UserMessageCard>
+    : (
+      <AssistantMessageCard
+        showActionButtons={showActionButtons}
+        onAccept={onAccept}
+        onDiscard={onDiscard}
+      >{children}
+      </AssistantMessageCard>
+    );
+};

+ 40 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/QuickMenuList.tsx

@@ -0,0 +1,40 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  onClick: (presetPrompt: string) => void
+}
+
+const presetMenus = [
+  'summarize',
+  'correct',
+];
+
+export const QuickMenuList: React.FC<Props> = ({ onClick }: Props) => {
+  const { t } = useTranslation();
+
+  const clickQuickMenuHandler = useCallback((quickMenu: string) => {
+    onClick(t(`sidebar_ai_assistant.preset_menu.${quickMenu}.prompt`));
+  }, [onClick, t]);
+
+  return (
+    <div className="container">
+      <div className="d-flex flex-column gap-3">
+        {presetMenus.map(presetMenu => (
+          <button
+            type="button"
+            key={presetMenu}
+            onClick={() => clickQuickMenuHandler(presetMenu)}
+            className="btn text-body-secondary p-3 rounded-3 border border-1"
+          >
+            <div className="d-flex align-items-center">
+              <span className="material-symbols-outlined fs-5 me-3">lightbulb</span>
+              <span className="fs-6">{t(`sidebar_ai_assistant.preset_menu.${presetMenu}.title`)}</span>
+            </div>
+          </button>
+        ))}
+      </div>
+    </div>
+  );
+};

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


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

@@ -6,7 +6,7 @@ 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 { useAiAssistantSidebar, useSWRxAiAssistants } from '../../stores/ai-assistant';
 
 import styles from './OpenDefaultAiAssistantButton.module.scss';
 
@@ -14,7 +14,7 @@ const OpenDefaultAiAssistantButton = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: isAiEnabled } = useIsAiEnabled();
   const { data: aiAssistantData } = useSWRxAiAssistants();
-  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { openChat } = useAiAssistantSidebar();
 
   const defaultAiAssistant = useMemo(() => {
     if (aiAssistantData == null) {
@@ -30,8 +30,8 @@ const OpenDefaultAiAssistantButton = (): JSX.Element => {
       return;
     }
 
-    openAiAssistantChatSidebar(defaultAiAssistant);
-  }, [defaultAiAssistant, openAiAssistantChatSidebar]);
+    openChat(defaultAiAssistant);
+  }, [defaultAiAssistant, openChat]);
 
   if (!isAiEnabled) {
     return <></>;

+ 4 - 18
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -9,13 +9,13 @@ import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-r
 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 { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
+import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
 
 import styles from './AiAssistantTree.module.scss';
 
@@ -125,20 +125,6 @@ const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClic
 /*
 *  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;
@@ -298,7 +284,7 @@ type AiAssistantTreeProps = {
 
 export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onUpdated, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
-  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { openChat } = useAiAssistantSidebar();
   const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
 
   return (
@@ -309,7 +295,7 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           currentUser={currentUser}
           aiAssistant={assistant}
           onEditClick={openAiAssistantManagementModal}
-          onItemClick={openAiAssistantChatSidebar}
+          onItemClick={openChat}
           onUpdated={onUpdated}
           onDeleted={onDeleted}
         />

+ 389 - 0
apps/app/src/features/openai/client/services/editor-assistant.tsx

@@ -0,0 +1,389 @@
+import {
+  useCallback, useEffect, useState, useRef, useMemo,
+} from 'react';
+
+import { GlobalCodeMirrorEditorKey } from '@growi/editor';
+import {
+  acceptAllChunks, useTextSelectionEffect,
+} from '@growi/editor/dist/client/services/unified-merge-view';
+import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
+import { useSecondaryYdocs } from '@growi/editor/dist/client/stores/use-secondary-ydocs';
+import { useForm, type UseFormReturn } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { type Text as YText } from 'yjs';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import {
+  SseMessageSchema,
+  SseDetectedDiffSchema,
+  SseFinalizedSchema,
+  isReplaceDiff,
+  type SseMessage,
+  type SseDetectedDiff,
+  type SseFinalized,
+} from '~/features/openai/interfaces/editor-assistant/sse-schemas';
+import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
+import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
+import { useCurrentPageId } from '~/stores/page';
+
+import type { AiAssistantHasId } from '../../interfaces/ai-assistant';
+import type { MessageLog } from '../../interfaces/message';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+import { ThreadType } from '../../interfaces/thread-relation';
+import { AiAssistantDropdown } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
+import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
+import { QuickMenuList } from '../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
+import { useAiAssistantSidebar } from '../stores/ai-assistant';
+
+interface CreateThread {
+  (): Promise<IThreadRelationHasId>;
+}
+interface PostMessage {
+  (threadId: string, formData: FormData): Promise<Response>;
+}
+interface ProcessMessage {
+  (data: unknown, handler: {
+    onMessage: (data: SseMessage) => void;
+    onDetectedDiff: (data: SseDetectedDiff) => void;
+    onFinalized: (data: SseFinalized) => void;
+  }): void;
+}
+
+interface GenerateInitialView {
+  (onSubmit: (data: FormData) => Promise<void>): JSX.Element;
+}
+interface GenerateMessageCard {
+  (role: MessageCardRole, children: string, messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
+}
+export interface FormData {
+  input: string,
+  markdownType?: 'full' | 'selected' | 'none'
+}
+
+type DetectedDiff = Array<{
+  data: SseDetectedDiff,
+  applied: boolean,
+  id: string,
+}>
+
+type UseEditorAssistant = () => {
+  createThread: CreateThread,
+  postMessage: PostMessage,
+  processMessage: ProcessMessage,
+  form: UseFormReturn<FormData>
+  resetForm: () => void
+  isTextSelected: boolean,
+
+  // Views
+  generateInitialView: GenerateInitialView,
+  generateMessageCard: GenerateMessageCard,
+  headerIcon: JSX.Element,
+  headerText: JSX.Element,
+  placeHolder: string,
+}
+
+const insertTextAtLine = (yText: YText, lineNumber: number, textToInsert: string): void => {
+  // Get the entire text content
+  const content = yText.toString();
+
+  // Split by newlines to get all lines
+  const lines = content.split('\n');
+
+  // Calculate the index position for insertion
+  let insertPosition = 0;
+
+  // Sum the length of all lines before the target line (plus newline characters)
+  for (let i = 0; i < lineNumber && i < lines.length; i++) {
+    insertPosition += lines[i].length + 1; // +1 for the newline character
+  }
+
+  // Insert the text at the calculated position
+  yText.insert(insertPosition, textToInsert);
+};
+
+const appendTextLastLine = (yText: YText, textToAppend: string) => {
+  const content = yText.toString();
+  const insertPosition = content.length;
+  yText.insert(insertPosition, `\n\n${textToAppend}`);
+};
+
+const getLineInfo = (yText: YText, lineNumber: number): { text: string, startIndex: number } | null => {
+  // Get the entire text content
+  const content = yText.toString();
+
+  // Split by newlines to get all lines
+  const lines = content.split('\n');
+
+  // Check if the requested line exists
+  if (lineNumber < 0 || lineNumber >= lines.length) {
+    return null; // Line doesn't exist
+  }
+
+  // Get the text of the specified line
+  const text = lines[lineNumber];
+
+  // Calculate the start index of the line
+  let startIndex = 0;
+  for (let i = 0; i < lineNumber; i++) {
+    startIndex += lines[i].length + 1; // +1 for the newline character
+  }
+
+  // Return comprehensive line information
+  return {
+    text,
+    startIndex,
+  };
+};
+
+export const useEditorAssistant: UseEditorAssistant = () => {
+  // Refs
+  const lineRef = useRef<number>(0);
+
+  // States
+  const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
+  const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
+  const [selectedText, setSelectedText] = useState<string>();
+
+  const isTextSelected = useMemo(() => selectedText != null && selectedText.length !== 0, [selectedText]);
+
+  // Hooks
+  const { t } = useTranslation();
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: isEnableUnifiedMergeView, mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const yDocs = useSecondaryYdocs(isEnableUnifiedMergeView ?? false, { pageId: currentPageId ?? undefined, useSecondary: isEnableUnifiedMergeView ?? false });
+  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
+
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+    },
+  });
+
+  // Functions
+  const resetForm = useCallback(() => {
+    form.reset({ input: '' });
+  }, [form]);
+
+  const createThread: CreateThread = useCallback(async() => {
+    const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
+      type: ThreadType.EDITOR,
+      aiAssistantId: selectedAiAssistant?._id,
+    });
+    return response.data;
+  }, [selectedAiAssistant?._id]);
+
+  const postMessage: PostMessage = useCallback(async(threadId, formData) => {
+    const getMarkdown = (): string | undefined => {
+      if (formData.markdownType === 'none') {
+        return undefined;
+      }
+
+      if (formData.markdownType === 'selected') {
+        return selectedText;
+      }
+
+      if (formData.markdownType === 'full') {
+        return codeMirrorEditor?.getDoc();
+      }
+    };
+
+    // Disable UnifiedMergeView when a Form is submitted with UnifiedMergeView enabled
+    mutateIsEnableUnifiedMergeView(false);
+
+    const response = await fetch('/_api/v3/openai/edit', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        threadId,
+        userMessage: formData.input,
+        markdown: getMarkdown(),
+      }),
+    });
+
+    return response;
+  }, [codeMirrorEditor, mutateIsEnableUnifiedMergeView, selectedText]);
+
+  const processMessage: ProcessMessage = useCallback((data, handler) => {
+    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handler.onMessage(data);
+    });
+    handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data: SseDetectedDiff) => {
+      mutateIsEnableUnifiedMergeView(true);
+      setDetectedDiff((prev) => {
+        const newData = { data, applied: false, id: crypto.randomUUID() };
+        if (prev == null) {
+          return [newData];
+        }
+        return [...prev, newData];
+      });
+      handler.onDetectedDiff(data);
+    });
+    handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
+      handler.onFinalized(data);
+    });
+  }, [mutateIsEnableUnifiedMergeView]);
+
+  const selectTextHandler = useCallback((selectedText: string, selectedTextFirstLineNumber: number) => {
+    setSelectedText(selectedText);
+    lineRef.current = selectedTextFirstLineNumber;
+  }, []);
+
+  // Effects
+  useTextSelectionEffect(codeMirrorEditor, selectTextHandler);
+
+  useEffect(() => {
+    const pendingDetectedDiff: DetectedDiff | undefined = detectedDiff?.filter(diff => diff.applied === false);
+    if (yDocs?.secondaryDoc != null && pendingDetectedDiff != null && pendingDetectedDiff.length > 0) {
+      const yText = yDocs.secondaryDoc.getText('codemirror');
+      yDocs.secondaryDoc.transact(() => {
+        pendingDetectedDiff.forEach((detectedDiff) => {
+          if (isReplaceDiff(detectedDiff.data)) {
+
+            if (isTextSelected) {
+              const lineInfo = getLineInfo(yText, lineRef.current);
+              if (lineInfo != null && lineInfo.text !== detectedDiff.data.diff.replace) {
+                yText.delete(lineInfo.startIndex, lineInfo.text.length);
+                insertTextAtLine(yText, lineRef.current, detectedDiff.data.diff.replace);
+              }
+
+              lineRef.current += 1;
+            }
+            else {
+              appendTextLastLine(yText, detectedDiff.data.diff.replace);
+            }
+          }
+        });
+      });
+
+      // Mark items as applied after applying to secondaryDoc
+      setDetectedDiff((prev) => {
+        if (!prev) return prev;
+        const pendingDetectedDiffIds = pendingDetectedDiff.map(diff => diff.id);
+        return prev.map((diff) => {
+          if (pendingDetectedDiffIds.includes(diff.id)) {
+            return { ...diff, applied: true };
+          }
+          return diff;
+        });
+      });
+    }
+  }, [codeMirrorEditor, detectedDiff, isTextSelected, selectedText, yDocs?.secondaryDoc]);
+
+  // Set detectedDiff to undefined after applying all detectedDiff to secondaryDoc
+  useEffect(() => {
+    if (detectedDiff?.filter(detectedDiff => detectedDiff.applied === false).length === 0) {
+      setSelectedText(undefined);
+      setDetectedDiff(undefined);
+      lineRef.current = 0;
+    }
+  }, [detectedDiff]);
+
+  // Views
+  const headerIcon = useMemo(() => {
+    return <span className="material-symbols-outlined growi-ai-chat-icon me-3 fs-4">support_agent</span>;
+  }, []);
+
+  const headerText = useMemo(() => {
+    return <>{t('Editor Assistant')}</>;
+  }, [t]);
+
+  const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.editor_assistant_placeholder' }, []);
+
+  const generateInitialView: GenerateInitialView = useCallback((onSubmit) => {
+    const selectAiAssistantHandler = (aiAssistant?: AiAssistantHasId) => {
+      setSelectedAiAssistant(aiAssistant);
+    };
+
+    const clickQuickMenuHandler = async(quickMenu: string) => {
+      await onSubmit({ input: quickMenu, markdownType: 'full' });
+    };
+
+    return (
+      <>
+        <div className="py-2">
+          <AiAssistantDropdown
+            selectedAiAssistant={selectedAiAssistant}
+            onSelect={selectAiAssistantHandler}
+          />
+        </div>
+        <QuickMenuList
+          onClick={clickQuickMenuHandler}
+        />
+      </>
+    );
+  }, [selectedAiAssistant]);
+
+
+  const generateMessageCard: GenerateMessageCard = useCallback((role, children, messageId, messageLogs, generatingAnswerMessage) => {
+    const isActionButtonShown = (() => {
+      if (!aiAssistantSidebarData?.isEditorAssistant) {
+        return false;
+      }
+
+      if (!isEnableUnifiedMergeView) {
+        return false;
+      }
+
+      if (generatingAnswerMessage != null) {
+        return false;
+      }
+
+      const latestAssistantMessageLogId = messageLogs
+        .filter(message => !message.isUserMessage)
+        .slice(-1)[0];
+
+      if (messageId === latestAssistantMessageLogId?.id) {
+        return true;
+      }
+
+      return false;
+    })();
+
+
+    const accept = () => {
+      if (codeMirrorEditor?.view == null) {
+        return;
+      }
+
+      acceptAllChunks(codeMirrorEditor.view);
+      mutateIsEnableUnifiedMergeView(false);
+    };
+
+    const reject = () => {
+      mutateIsEnableUnifiedMergeView(false);
+    };
+
+    return (
+      <MessageCard
+        role={role}
+        showActionButtons={isActionButtonShown}
+        onAccept={accept}
+        onDiscard={reject}
+      >
+        {children}
+      </MessageCard>
+    );
+  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, isEnableUnifiedMergeView, mutateIsEnableUnifiedMergeView]);
+
+  return {
+    createThread,
+    postMessage,
+    processMessage,
+    form,
+    resetForm,
+    isTextSelected,
+
+    // Views
+    generateInitialView,
+    generateMessageCard,
+    headerIcon,
+    headerText,
+    placeHolder,
+  };
+};
+
+// type guard
+export const isEditorAssistantFormData = (formData): formData is FormData => {
+  return 'markdownType' in formData;
+};

+ 332 - 0
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -0,0 +1,332 @@
+import type { Dispatch, SetStateAction } from 'react';
+import {
+  useCallback, useMemo, useState, useEffect,
+} from 'react';
+
+import { useForm, type UseFormReturn } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import {
+  UncontrolledTooltip, Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { SseMessageSchema, type SseMessage } from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
+import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
+
+import type { MessageLog, MessageWithCustomMetaData } from '../../interfaces/message';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+import { ThreadType } from '../../interfaces/thread-relation';
+import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
+import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
+import { useAiAssistantSidebar } from '../stores/ai-assistant';
+import { useSWRMUTxMessages } from '../stores/message';
+import { useSWRMUTxThreads } from '../stores/thread';
+
+interface CreateThread {
+  (aiAssistantId: string, initialUserMessage: string): Promise<IThreadRelationHasId>;
+}
+
+interface PostMessage {
+  (aiAssistantId: string, threadId: string, formData: FormData): Promise<Response>;
+}
+
+interface ProcessMessage {
+  (data: unknown, handler: {
+    onMessage: (data: SseMessage) => void}
+  ): void;
+}
+
+interface GenerateMessageCard {
+  (role: MessageCardRole, children: string): JSX.Element;
+}
+
+export interface FormData {
+  input: string
+  summaryMode?: boolean
+  extendedThinkingMode?: boolean
+}
+
+interface GenerateModeSwitchesDropdown {
+  (isGenerating: boolean): JSX.Element
+}
+
+type UseKnowledgeAssistant = () => {
+  createThread: CreateThread
+  postMessage: PostMessage
+  processMessage: ProcessMessage
+  form: UseFormReturn<FormData>
+  resetForm: () => void
+
+  // Views
+  initialView: JSX.Element
+  generateMessageCard: GenerateMessageCard
+  generateModeSwitchesDropdown: GenerateModeSwitchesDropdown
+  headerIcon: JSX.Element
+  headerText: JSX.Element
+  placeHolder: string
+}
+
+export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
+  // Hooks
+  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
+  const { aiAssistantData } = aiAssistantSidebarData ?? {};
+  const { threadData } = aiAssistantSidebarData ?? {};
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
+  const { t } = useTranslation();
+
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+      summaryMode: true,
+      extendedThinkingMode: false,
+    },
+  });
+
+  // States
+  const [currentThreadTitle, setCurrentThreadId] = useState(threadData?.title);
+
+  // Functions
+  const resetForm = useCallback(() => {
+    const summaryMode = form.getValues('summaryMode');
+    const extendedThinkingMode = form.getValues('extendedThinkingMode');
+    form.reset({ input: '', summaryMode, extendedThinkingMode });
+  }, [form]);
+
+  const createThread: CreateThread = useCallback(async(aiAssistantId, initialUserMessage) => {
+    const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
+      type: ThreadType.KNOWLEDGE,
+      aiAssistantId,
+      initialUserMessage,
+    });
+    const thread = response.data;
+
+    setCurrentThreadId(thread.title);
+
+    // No need to await because data is not used
+    mutateThreadData();
+
+    return thread;
+  }, [mutateThreadData]);
+
+  const postMessage: PostMessage = useCallback(async(aiAssistantId, threadId, formData) => {
+    const response = await fetch('/_api/v3/openai/message', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        aiAssistantId,
+        threadId,
+        userMessage: formData.input,
+        summaryMode: form.getValues('summaryMode'),
+        extendedThinkingMode: form.getValues('extendedThinkingMode'),
+      }),
+    });
+    return response;
+  }, [form]);
+
+  const processMessage: ProcessMessage = useCallback((data, handler) => {
+    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handler.onMessage(data);
+    });
+  }, []);
+
+  // Views
+  const headerIcon = useMemo(() => {
+    return <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>;
+  }, []);
+
+  const headerText = useMemo(() => {
+    return <>{currentThreadTitle ?? aiAssistantData?.name}</>;
+  }, [aiAssistantData?.name, currentThreadTitle]);
+
+  const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.knowledge_assistant_placeholder' }, []);
+
+  const initialView = useMemo(() => {
+    if (aiAssistantSidebarData?.aiAssistantData == null) {
+      return <></>;
+    }
+
+    return (
+      <AiAssistantChatInitialView
+        description={aiAssistantSidebarData.aiAssistantData.description}
+        pagePathPatterns={aiAssistantSidebarData.aiAssistantData.pagePathPatterns}
+      />
+    );
+  }, [aiAssistantSidebarData?.aiAssistantData]);
+
+  const generateMessageCard: GenerateMessageCard = useCallback((role, children) => {
+    return (
+      <MessageCard
+        role={role}
+      >
+        {children}
+      </MessageCard>
+    );
+  }, []);
+
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+
+  const toggleDropdown = useCallback(() => {
+    setDropdownOpen(prevState => !prevState);
+  }, []);
+
+  const generateModeSwitchesDropdown: GenerateModeSwitchesDropdown = useCallback((isGenerating) => {
+    return (
+      <Dropdown isOpen={dropdownOpen} toggle={toggleDropdown} direction="up">
+        <DropdownToggle size="sm" outline className="border-0">
+          <span className="material-symbols-outlined">tune</span>
+        </DropdownToggle>
+        <DropdownMenu>
+          <DropdownItem tag="div" toggle={false}>
+            <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_ai_assistant.summary_mode_label')}
+              </label>
+              <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_ai_assistant.summary_mode_help')}
+              </UncontrolledTooltip>
+            </div>
+          </DropdownItem>
+          <DropdownItem tag="div" toggle={false}>
+            <div className="form-check form-switch">
+              <input
+                id="swExtendedThinkingMode"
+                type="checkbox"
+                role="switch"
+                className="form-check-input"
+                {...form.register('extendedThinkingMode')}
+                disabled={form.formState.isSubmitting || isGenerating}
+              />
+              <label className="form-check-label" htmlFor="swExtendedThinkingMode">
+                {t('sidebar_ai_assistant.extended_thinking_mode_label')}
+              </label>
+              <a
+                id="tooltipForHelpOfExtendedThinkingMode"
+                role="button"
+                className="ms-1"
+              >
+                <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
+              </a>
+              <UncontrolledTooltip
+                target="tooltipForHelpOfExtendedThinkingMode"
+              >
+                {t('sidebar_ai_assistant.extended_thinking_mode_help')}
+              </UncontrolledTooltip>
+            </div>
+          </DropdownItem>
+        </DropdownMenu>
+      </Dropdown>
+    );
+  }, [dropdownOpen, toggleDropdown, form, t]);
+
+  return {
+    createThread,
+    postMessage,
+    processMessage,
+    form,
+    resetForm,
+
+    // Views
+    initialView,
+    generateMessageCard,
+    generateModeSwitchesDropdown,
+    headerIcon,
+    headerText,
+    placeHolder,
+  };
+};
+
+
+// Helper function to transform API message data to MessageLog[]
+const transformApiMessagesToLogs = (
+    apiMessageData: MessageWithCustomMetaData | null | undefined,
+): MessageLog[] => {
+  if (apiMessageData?.data == null || !Array.isArray(apiMessageData.data)) {
+    return [];
+  }
+
+  // Define a type for the items in apiMessageData.data for clarity
+  type ApiMessageItem = (typeof apiMessageData.data)[number];
+
+  return apiMessageData.data
+    .slice() // Create a shallow copy before reversing
+    .reverse()
+    .filter((message: ApiMessageItem) => message.metadata?.shouldHideMessage !== 'true')
+    .map((message: ApiMessageItem): MessageLog => {
+      // Extract the first text content block, if any
+      let messageTextContent = '';
+      const textContentBlock = message.content?.find(contentBlock => contentBlock.type === 'text');
+      if (textContentBlock != null && textContentBlock.type === 'text') {
+        messageTextContent = textContentBlock.text.value;
+      }
+
+      return {
+        id: message.id, // Use the actual message ID from OpenAI
+        content: messageTextContent,
+        isUserMessage: message.role === 'user',
+      };
+    });
+};
+
+export const useFetchAndSetMessageDataEffect = (
+    setMessageLogs: Dispatch<SetStateAction<MessageLog[]>>,
+    threadId?: string,
+): void => {
+  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
+  const { trigger: mutateMessageData } = useSWRMUTxMessages(
+    aiAssistantSidebarData?.aiAssistantData?._id,
+    threadId,
+  );
+
+  useEffect(() => {
+    if (aiAssistantSidebarData?.isEditorAssistant) {
+      return;
+    }
+
+    if (threadId == null) {
+      setMessageLogs([]);
+      return; // Early return if no threadId
+    }
+
+    const fetchAndSetLogs = async() => {
+      try {
+        // Assuming mutateMessageData() returns a Promise<MessageWithCustomMetaData | null | undefined>
+        const rawApiMessageData: MessageWithCustomMetaData | null | undefined = await mutateMessageData();
+        const fetchedLogs = transformApiMessagesToLogs(rawApiMessageData);
+
+        setMessageLogs((currentLogs) => {
+          // Preserve current logs if they represent a single, user-submitted message
+          // AND the newly fetched logs are empty (common for new threads).
+          const shouldPreserveCurrentMessage = currentLogs.length === 1
+            && currentLogs[0].isUserMessage
+            && fetchedLogs.length === 0;
+
+          // Update with fetched logs, or preserve current if applicable
+          return shouldPreserveCurrentMessage ? currentLogs : fetchedLogs;
+        });
+      }
+      catch (error) {
+        // console.error('Failed to fetch or process message data:', error); // Optional: for debugging
+        setMessageLogs([]); // Clear logs on error to avoid inconsistent state
+      }
+    };
+
+    fetchAndSetLogs();
+  }, [threadId, mutateMessageData, setMessageLogs]); // Dependencies
+};

+ 35 - 11
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -7,7 +7,7 @@ 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';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation'; // IThreadHasId を削除
 
 export const AiAssistantManagementModalPageMode = {
   HOME: 'home',
@@ -55,33 +55,57 @@ export const useSWRxAiAssistants = (): SWRResponse<AccessibleAiAssistantsHasId,
 };
 
 
-type AiAssistantChatSidebarStatus = {
+/*
+*  useAiAssistantSidebar
+*/
+type AiAssistantSidebarStatus = {
   isOpened: boolean,
+  isEditorAssistant?: boolean,
   aiAssistantData?: AiAssistantHasId,
   threadData?: IThreadRelationHasId,
 }
 
-type AiAssistantChatSidebarUtils = {
-  open(
+type AiAssistantSidebarUtils = {
+  openChat(
     aiAssistantData: AiAssistantHasId,
     threadData?: IThreadRelationHasId,
   ): void
+  openEditor(): void
   close(): void
+  refreshThreadData(threadData?: IThreadRelationHasId): void
 }
 
-export const useAiAssistantChatSidebar = (
-    status?: AiAssistantChatSidebarStatus,
-): SWRResponse<AiAssistantChatSidebarStatus, Error> & AiAssistantChatSidebarUtils => {
+export const useAiAssistantSidebar = (
+    status?: AiAssistantSidebarStatus,
+): SWRResponse<AiAssistantSidebarStatus, Error> & AiAssistantSidebarUtils => {
   const initialStatus = { isOpened: false };
-  const swrResponse = useSWRStatic<AiAssistantChatSidebarStatus, Error>('AiAssistantChatSidebar', status, { fallbackData: initialStatus });
+  const swrResponse = useSWRStatic<AiAssistantSidebarStatus, Error>('AiAssistantSidebar', status, { fallbackData: initialStatus });
 
   return {
     ...swrResponse,
-    open: useCallback(
-      (aiAssistantData: AiAssistantHasId, threadData: IThreadRelationHasId) => {
+    openChat: useCallback(
+      (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => {
         swrResponse.mutate({ isOpened: true, aiAssistantData, threadData });
       }, [swrResponse],
     ),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+    openEditor: useCallback(
+      () => {
+        swrResponse.mutate({
+          isOpened: true, isEditorAssistant: true, aiAssistantData: undefined, threadData: undefined,
+        });
+      }, [swrResponse],
+    ),
+    close: useCallback(
+      () => swrResponse.mutate({
+        isOpened: false, isEditorAssistant: false, aiAssistantData: undefined, threadData: undefined,
+      }), [swrResponse],
+    ),
+    refreshThreadData: useCallback(
+      (threadData?: IThreadRelationHasId) => {
+        swrResponse.mutate((currentState = { isOpened: false }) => {
+          return { ...currentState, threadData };
+        });
+      }, [swrResponse],
+    ),
   };
 };

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

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

+ 4 - 3
apps/app/src/features/openai/client/stores/thread.tsx

@@ -6,9 +6,9 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 
-const getKey = (aiAssistantId: string) => [`/openai/threads/${aiAssistantId}`];
+const getKey = (aiAssistantId?: string) => (aiAssistantId != null ? [`/openai/threads/${aiAssistantId}`] : null);
 
-export const useSWRxThreads = (aiAssistantId: string): SWRResponse<IThreadRelationHasId[], Error> => {
+export const useSWRxThreads = (aiAssistantId?: string): SWRResponse<IThreadRelationHasId[], Error> => {
   const key = getKey(aiAssistantId);
   return useSWRImmutable<IThreadRelationHasId[]>(
     key,
@@ -17,10 +17,11 @@ export const useSWRxThreads = (aiAssistantId: string): SWRResponse<IThreadRelati
 };
 
 
-export const useSWRMUTxThreads = (aiAssistantId: string): SWRMutationResponse<IThreadRelationHasId[], Error> => {
+export const useSWRMUTxThreads = (aiAssistantId?: string): SWRMutationResponse<IThreadRelationHasId[], Error> => {
   const key = getKey(aiAssistantId);
   return useSWRMutation(
     key,
     ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+    { revalidate: true },
   );
 };

+ 17 - 0
apps/app/src/features/openai/client/utils/get-share-scope-Icon.ts

@@ -0,0 +1,17 @@
+import type { AiAssistantAccessScope } from '../../interfaces/ai-assistant';
+import { AiAssistantShareScope } from '../../interfaces/ai-assistant';
+import { determineShareScope } from '../../utils/determine-share-scope';
+
+export 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 '';
+  }
+};

+ 32 - 0
apps/app/src/features/openai/interfaces/editor-assistant/llm-response-schemas.ts

@@ -0,0 +1,32 @@
+import { z } from 'zod';
+
+// -----------------------------------------------------------------------------
+// Type definitions
+// -----------------------------------------------------------------------------
+
+// Schema definitions
+export const LlmEditorAssistantMessageSchema = z.object({
+  message: z.string().describe('A friendly message explaining what changes were made or suggested'),
+});
+
+export const LlmEditorAssistantDiffSchema = z
+  .object({
+    replace: z.string().describe('The text that should replace the current content'),
+  });
+  // .object({
+  //   insert: z.string().describe('The text that should insert the content in the current position'),
+  // })
+  // .or(
+  //   z.object({
+  //     delete: z.number().int().describe('The number of characters that should be deleted from the current position'),
+  //   }),
+  // )
+  // .or(
+  //   z.object({
+  //     retain: z.number().int().describe('The number of characters that should be retained in the current position'),
+  //   }),
+  // );
+
+// Type definitions
+export type LlmEditorAssistantMessage = z.infer<typeof LlmEditorAssistantMessageSchema>;
+export type LlmEditorAssistantDiff = z.infer<typeof LlmEditorAssistantDiffSchema>;

+ 47 - 0
apps/app/src/features/openai/interfaces/editor-assistant/sse-schemas.ts

@@ -0,0 +1,47 @@
+import { z } from 'zod';
+
+import { LlmEditorAssistantDiffSchema } from './llm-response-schemas';
+
+// -----------------------------------------------------------------------------
+// Type definitions
+// -----------------------------------------------------------------------------
+
+// Schema definitions
+export const SseMessageSchema = z.object({
+  appendedMessage: z.string().describe('The message that should be appended to the chat window'),
+});
+
+export const SseDetectedDiffSchema = z
+  .object({
+    diff: LlmEditorAssistantDiffSchema,
+  });
+
+export const SseFinalizedSchema = z
+  .object({
+    finalized: z.object({
+      message: z.string().describe('The final message that should be displayed in the chat window'),
+      replacements: z.array(LlmEditorAssistantDiffSchema),
+    }),
+  });
+
+// Type definitions
+export type SseMessage = z.infer<typeof SseMessageSchema>;
+export type SseDetectedDiff = z.infer<typeof SseDetectedDiffSchema>;
+export type SseFinalized = z.infer<typeof SseFinalizedSchema>;
+
+// Type guard for SseDetectedDiff
+// export const isInsertDiff = (diff: SseDetectedDiff): diff is { diff: { insert: string } } => {
+//   return 'insert' in diff.diff;
+// };
+
+// export const isDeleteDiff = (diff: SseDetectedDiff): diff is { diff: { delete: number } } => {
+//   return 'delete' in diff.diff;
+// };
+
+// export const isRetainDiff = (diff: SseDetectedDiff): diff is { diff : { retain: number} } => {
+//   return 'retain' in diff.diff;
+// };
+
+export const isReplaceDiff = (diff: SseDetectedDiff): diff is { diff: { replace: string } } => {
+  return 'replace' in diff.diff;
+};

+ 16 - 0
apps/app/src/features/openai/interfaces/knowledge-assistant/sse-schemas.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+// Schema definitions
+export const SseMessageSchema = z.object({
+  content: z.array(z.object({
+    index: z.number(),
+    type: z.string(),
+    text: z.object({
+      value: z.string().describe('The message that should be appended to the chat window'),
+    }),
+  })),
+});
+
+
+// Type definitions
+export type SseMessage = z.infer<typeof SseMessageSchema>;

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

@@ -11,3 +11,9 @@ export type MessageWithCustomMetaData = Omit<OpenAI.Beta.Threads.Messages.Messag
 };
 
 export type MessageListParams = OpenAI.Beta.Threads.Messages.MessageListParams;
+
+export type MessageLog = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}

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

@@ -2,11 +2,20 @@ import type { IUser, Ref, HasObjectId } from '@growi/core';
 
 import type { AiAssistant } from './ai-assistant';
 
+
+export const ThreadType = {
+  KNOWLEDGE: 'knowledge',
+  EDITOR: 'editor',
+} as const;
+
+export type ThreadType = typeof ThreadType[keyof typeof ThreadType];
+
 export interface IThreadRelation {
   userId: Ref<IUser>
   aiAssistant: Ref<AiAssistant>
   threadId: string;
   title?: string;
+  type: ThreadType;
   expiredAt: Date;
 }
 

+ 6 - 2
apps/app/src/features/openai/server/models/thread-relation.ts

@@ -3,7 +3,7 @@ import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import type { IThreadRelation } from '../../interfaces/thread-relation';
+import { type IThreadRelation, ThreadType } from '../../interfaces/thread-relation';
 
 const DAYS_UNTIL_EXPIRATION = 3;
 
@@ -28,7 +28,6 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
   aiAssistant: {
     type: Schema.Types.ObjectId,
     ref: 'AiAssistant',
-    required: true,
   },
   threadId: {
     type: String,
@@ -38,6 +37,11 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
   title: {
     type: String,
   },
+  type: {
+    type: String,
+    enum: Object.values(ThreadType),
+    required: true,
+  },
   expiredAt: {
     type: Date,
     default: generateExpirationDate,

+ 55 - 17
apps/app/src/features/openai/server/models/vector-store-file-relation.ts

@@ -7,6 +7,7 @@ import { getOrCreateModel } from '~/server/util/mongoose-utils';
 export interface VectorStoreFileRelation {
   vectorStoreRelationId: mongoose.Types.ObjectId;
   page: mongoose.Types.ObjectId;
+  attachment?: mongoose.Types.ObjectId;
   fileIds: string[];
   isAttachedToVectorStore: boolean;
 }
@@ -19,10 +20,21 @@ interface VectorStoreFileRelationModel extends Model<VectorStoreFileRelation> {
 }
 
 export const prepareVectorStoreFileRelations = (
-    vectorStoreRelationId: Types.ObjectId, page: Types.ObjectId, fileId: string, relationsMap: Map<string, VectorStoreFileRelation>,
+    vectorStoreRelationId: Types.ObjectId,
+    page: Types.ObjectId,
+    fileId: string,
+    relationsMap: Map<string, VectorStoreFileRelation>,
+    attachment?: Types.ObjectId,
 ): Map<string, VectorStoreFileRelation> => {
-  const pageIdStr = page.toHexString();
-  const existingData = relationsMap.get(pageIdStr);
+
+  const key = (() => {
+    if (attachment == null) {
+      return page.toHexString();
+    }
+    return page.toHexString() + attachment.toHexString();
+  })();
+
+  const existingData = relationsMap.get(key);
 
   // If the data exists, add the fileId to the fileIds array
   if (existingData != null) {
@@ -30,11 +42,12 @@ export const prepareVectorStoreFileRelations = (
   }
   // If the data doesn't exist, create a new one and add it to the map
   else {
-    relationsMap.set(pageIdStr, {
+    relationsMap.set(key, {
       vectorStoreRelationId,
       page,
       fileIds: [fileId],
       isAttachedToVectorStore: false,
+      attachment,
     });
   }
 
@@ -52,6 +65,10 @@ const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelati
     ref: 'Page',
     required: true,
   },
+  attachment: {
+    type: Schema.Types.ObjectId,
+    ref: 'Attachment',
+  },
   fileIds: [{
     type: String,
     required: true,
@@ -64,22 +81,43 @@ const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelati
 });
 
 // define unique compound index
-schema.index({ vectorStoreRelationId: 1, page: 1 }, { unique: true });
+schema.index({ vectorStoreRelationId: 1, page: 1, attachment: 1 }, { unique: true });
 
 schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void> {
-  await this.bulkWrite(
-    vectorStoreFileRelations.map((data) => {
-      return {
-        updateOne: {
-          filter: { page: data.page, vectorStoreRelationId: data.vectorStoreRelationId },
-          update: {
-            $addToSet: { fileIds: { $each: data.fileIds } },
-          },
-          upsert: true,
+  const upsertOps = vectorStoreFileRelations
+    .filter(data => data.attachment == null)
+    .map(data => ({
+      updateOne: {
+        filter: {
+          page: data.page,
+          vectorStoreRelationId: data.vectorStoreRelationId,
+          attachment: { $exists: false },
         },
-      };
-    }),
-  );
+        update: {
+          $addToSet: { fileIds: { $each: data.fileIds } },
+        },
+        upsert: true,
+      },
+    }));
+
+  const insertOps = vectorStoreFileRelations
+    .filter(data => data.attachment != null)
+    .map(data => ({
+      insertOne: {
+        document: {
+          vectorStoreRelationId: data.vectorStoreRelationId,
+          page: data.page,
+          attachment: data.attachment,
+          fileIds: data.fileIds,
+          isAttachedToVectorStore: data.isAttachedToVectorStore,
+        },
+      },
+    }));
+
+  const bulkOps = [...upsertOps, ...insertOps];
+  if (bulkOps.length > 0) {
+    await this.bulkWrite(bulkOps);
+  }
 };
 
 // Used when attached to VectorStore

+ 146 - 0
apps/app/src/features/openai/server/routes/edit/README.ja.md

@@ -0,0 +1,146 @@
+# Editor Assistant API 実装解説
+
+## 要求仕様
+
+Editor Assistant API は、OpenAI AssistantAPI を使用して、マークダウンエディタの編集をサポートする機能です。主な要件は以下の通りです:
+
+1. **ストリーミング処理**:
+   - OpenAI からの応答をストリーミングで受け取り、Server-Sent Events (SSE) でクライアントにリアルタイムに転送
+   - JSON データを適切なタイミングで解析し、クライアントに送信
+
+2. **データ形式**:
+   - SSE による応答は `SseMessageSchema`, `SseDetectedDiffSchema`, `SseFinalizedSchema` に準拠した JSON 形式
+   - `{ message: "..." }` と delta 形式の差分情報(`insert`, `delete`, `retain`)を含む
+
+3. **エラーハンドリング**:
+   - 不完全な JSON データの処理時のエラーを適切に処理
+   - リソースリークの防止
+
+4. **効率性**:
+   - メモリ使用量を最小限に抑える
+   - 不要な通信を避け、クライアントへの適切なタイミングでのデータ送信を実現
+   - メッセージの増分送信による通信量削減と、すでに処理済みの要素のスキップによる処理効率の向上
+
+## 重要なインプット
+
+### 実装時に参照したコード
+
+1. **jsonrepair ライブラリ**:
+   - 壊れた JSON や不完全な JSON を修復するライブラリ
+   - 特に部分的なストリーミング JSON の処理に有効
+
+2. **型定義**:
+   - `message-error.ts`: エラー型と定義
+   - `schema.ts`: エディタアシスタントのメッセージと差分の Zod スキーマ定義
+
+### 今後のリファクタリングに重要なインプット
+
+1. **OpenAI API の仕様変更**:
+   - AssistantAPI のレスポンス形式の変更に注意
+
+2. **jsonrepair のアップデート**:
+   - 新バージョンでの API 変更や最適化手法の変更を確認
+
+3. **パフォーマンス監視**:
+   - メモリ使用量と処理時間のモニタリング
+   - 大規模 JSON 処理時のボトルネック特定
+
+## 実装のポイント
+
+### 1. ストリーミング処理と不完全JSONの修復
+
+ストリーミング処理において、最大の課題は不完全なJSON文字列の処理です。OpenAI APIから部分的に届くJSONデータを即座に解析するために、以下の対策を実装しています:
+
+- **jsonrepair ライブラリの採用理由**:
+  - 通常、JSON文字列は完全な形でなければパースできません。これはストリーム処理において大きな制約となります。
+  - 全ての文字列を受け取るまで待たずに、途中経過をリアルタイムにユーザーに提示するため、jsonrepairを使用して部分的なJSON文字列を修復しています。
+  - これにより、メッセージと差分情報を受信次第、速やかにクライアントに届けることが可能になり、ユーザー体験が大幅に向上します。
+  
+  **具体例**:
+  ```javascript
+  // ストリームから受け取った不完全なJSONの例
+  const partialJson = '{"contents": [{"message": "テキストを修正し';
+  
+  // 通常のJSON.parseではエラー
+  // JSON.parse(partialJson); // SyntaxError: Unexpected end of JSON input
+  
+  // jsonrepairを使用した修復
+  const repairedJson = jsonrepair(partialJson);
+  // 結果: '{"contents": [{"message": "テキストを修正しています"}]}'
+  
+  // 修復されたJSONはパース可能
+  const parsedJson = JSON.parse(repairedJson);
+  // 結果: { contents: [{ message: 'テキストを修正しています' }] }
+  ```
+  
+  - このように、正常なJSONとして完結していない途中のデータでも、jsonrepairは欠けている部分を補完して有効なJSONに変換します。OpenAI APIからの応答では、完全なJSONが揃うまで待つことなく、部分的に受信したデータを即座に処理できるようになります。
+
+- **rawBufferの累積と継続的な解析**:
+  - 受信したテキストチャンクを`rawBuffer`に累積し、その都度jsonrepairでパース可能な形に修復しています。
+  - これは特にOpenAI APIの応答がJSON形式で指定されているにもかかわらず、ストリームではその一部だけが届く特性に対応するための実装です。
+
+### 2. 差分検出と適応的送信制御
+
+エディタアシスタントの核心部分は、OpenAI APIからのレスポンスから差分情報を適切に抽出し、効率的にクライアントに送信する機能です。以下のような工夫を行っています:
+
+- **メッセージと差分の処理の統合と最適化**:
+  - UI/UX要件に基づく設計として、メッセージと差分の処理を単一ループで効率的に実装しています。
+  - **メッセージ処理**:メッセージの「増分」(新しく追加された部分)のみをクライアントに送信します。これにより通信量を削減し、クライアント側の処理負荷を軽減します。
+  - **差分処理**:JSONノードとして確定した差分は即座に検出し通知します。ただし、確定していない(変更中の可能性がある)差分は送信を控えることでエディタの過剰な更新を防止します。
+
+- **処理効率の向上メカニズム**:
+  - `processedMessages` Mapを使って、各メッセージ要素の前回の内容を記録し、差分のみを計算します。
+  - `lastProcessedContentLength` を用いて、すでに処理済みの要素をスキップします。これにより大量のデータでも効率的に処理できます。
+  ```javascript
+  // 処理開始位置の最適化 - 確定済み要素のスキップ
+  const startProcessingIndex = Math.max(0, Math.min(this.lastProcessedContentLength, contents.length) - 1);
+  
+  // 単一ループでメッセージと差分を処理
+  for (let i = startProcessingIndex; i < contents.length; i++) {
+    // メッセージと差分の処理
+  }
+  ```
+
+- **OpenAIストリームの特性に対応した差分確定判定**:
+  - OpenAI APIからのJSONストリームは「前方から順に確定していく」特性があります。このAPIの特性を活用し、以下の判定ロジックを実装しています:
+  ```javascript
+  // 最終要素が変化した、またはこれが最終要素ではない場合 → 差分を確定とみなす
+  if (i < currentContentIndex || currentContentIndex > this.lastContentIndex) {
+    // 差分を確定して送信リストに追加
+  }
+  ```
+  - この条件判定は単なる技術的工夫ではなく、UXの向上を目的としています。確定していない差分を頻繁に送信すると、エディタが頻繁に更新されてユーザー体験が悪化するためです。
+
+- **重複防止メカニズム**:
+  - 差分の重複送信を避けるため、一意のキーを生成する`getDiffKey`メソッドを実装しています。
+  - Setデータ構造(`sentDiffKeys`)を使うことで、O(1)の時間複雑度で効率的に重複チェックを行います。
+  - この実装は、ストリームデータの累積的な性質(同じデータが何度も現れる可能性がある)に対応するために不可欠です。
+
+- **増分メッセージ計算の最適化**:
+  - メッセージ要素ごとに前回のメッセージとの差分を計算する`getAppendedContent`メソッドを実装しています。
+  - これにより、クライアントには新たに追加された部分のみを送信でき、通信量を大幅に削減できます。
+  ```javascript
+  private getAppendedContent(previousMessage: string, currentMessage: string): string {
+    // 前回のメッセージから増分部分のみを返す
+    return currentMessage.slice(previousMessage.length);
+  }
+  ```
+
+### 3. エラー耐性とリソース管理
+
+ストリーミング処理においてエラー耐性とリソース管理は特に重要です。以下の対策を講じています:
+
+- **エラーハンドリングの階層化**:
+  - JSONパースエラーはデバッグ用にログ出力するのみとし、処理を継続します。これはストリーミングの性質上、部分的なデータでパースエラーが発生するのは正常な動作だからです。
+  - 重大なエラーはクライアントに適切に通知し、リソースを解放します。
+
+- **リソース解放の徹底**:
+  - クライアント切断時やエラー発生時、処理完了時など、あらゆるシナリオでリソースを確実に解放するクリーンアップ処理を実装しています。
+  - `destroy`メソッドでメモリキャッシュをクリアし、イベントリスナーを解除することで、メモリリークを防止しています。
+
+- **非同期ストリーム処理の安全な終了**:
+  - ストリームの終了を適切に検出し、完全な結果を送信してから接続を終了する機構を設けています。
+  - エラー時でも可能な限り正常な形でレスポンスを返し、クライアント側での復旧を容易にします。
+
+このような設計と実装により、リアルタイム性と正確性を両立したエディタアシスタント機能を実現しています。ストリーミング処理の特性を活かしつつ、効率的なデータ処理と適応的な通知制御によって優れたユーザー体験を提供しています。
+

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

@@ -0,0 +1,272 @@
+import { getIdStringForRef } from '@growi/core';
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler, Response } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import { zodResponseFormat } from 'openai/helpers/zod';
+import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
+import { z } from 'zod';
+
+// Necessary imports
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { LlmEditorAssistantDiffSchema, LlmEditorAssistantMessageSchema } from '../../../interfaces/editor-assistant/llm-response-schemas';
+import type { SseDetectedDiff, SseFinalized, SseMessage } from '../../../interfaces/editor-assistant/sse-schemas';
+import { MessageErrorCode } from '../../../interfaces/message-error';
+import ThreadRelationModel from '../../models/thread-relation';
+import { getOrCreateEditorAssistant } from '../../services/assistant';
+import { openaiClient } from '../../services/client';
+import { LlmResponseStreamProcessor } from '../../services/editor-assistant';
+import { getStreamErrorCode } from '../../services/getStreamErrorCode';
+import { getOpenaiService } from '../../services/openai';
+import { replaceAnnotationWithPageLink } from '../../services/replace-annotation-with-page-link';
+import { certifyAiService } from '../middlewares/certify-ai-service';
+import { SseHelper } from '../utils/sse-helper';
+
+
+const logger = loggerFactory('growi:routes:apiv3:openai:message');
+
+// -----------------------------------------------------------------------------
+// Type definitions
+// -----------------------------------------------------------------------------
+
+const LlmEditorAssistantResponseSchema = z.object({
+  contents: z.array(z.union([LlmEditorAssistantMessageSchema, LlmEditorAssistantDiffSchema])),
+}).describe('The response format for the editor assistant');
+
+
+type ReqBody = {
+  userMessage: string,
+  markdown?: string,
+  threadId?: string,
+}
+
+type Req = Request<undefined, Response, ReqBody> & {
+  user: IUserHasId,
+}
+
+
+// -----------------------------------------------------------------------------
+// Endpoint handler factory
+// -----------------------------------------------------------------------------
+
+type PostMessageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+
+// -----------------------------------------------------------------------------
+// Instructions
+// -----------------------------------------------------------------------------
+/* eslint-disable max-len */
+const withMarkdownCaution = `# IMPORTANT:
+- Spaces and line breaks are also counted as individual characters.
+- The text for lines that do not need correction must be returned exactly as in the original text.
+- Include original text in the replace object even if it contains only spaces or line breaks
+`;
+
+function instruction(withMarkdown: boolean): string {
+  return `# RESPONSE FORMAT:
+You must respond with a JSON object in the following format example:
+{
+  "contents": [
+    { "message": "Your brief message about the upcoming change or proposal.\n\n" },
+    { "replace": "New text 1" },
+    { "message": "Additional explanation if needed" },
+    { "replace": "New text 2" },
+    ...more items if needed
+    { "message": "Your friendly message explaining what changes were made or suggested." }
+  ]
+}
+
+The array should contain:
+- [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+- Objects with a "message" key for explanatory text to the user if needed.
+- Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
+- [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
+
+${withMarkdown ? withMarkdownCaution : ''}
+
+# Multilingual Support:
+Always provide messages in the same language as the user's request.`;
+}
+/* eslint-disable max-len */
+
+/**
+ * Create endpoint handlers for editor assistant
+ */
+export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  // Validator setup
+  const validator: ValidationChain[] = [
+    body('userMessage')
+      .isString()
+      .withMessage('userMessage must be string')
+      .notEmpty()
+      .withMessage('userMessage must be set'),
+    body('markdown')
+      .optional()
+      .isString()
+      .withMessage('markdown must be string'),
+    body('threadId').optional().isString().withMessage('threadId must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const {
+        userMessage, markdown, threadId,
+      } = req.body;
+
+      // Parameter check
+      if (threadId == null) {
+        return res.apiv3Err(new ErrorV3('threadId is not set', MessageErrorCode.THREAD_ID_IS_NOT_SET), 400);
+      }
+
+      // Service check
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      const threadRelation = await ThreadRelationModel.findOne({ threadId: { $eq: threadId } });
+      if (threadRelation == null) {
+        return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
+      }
+
+      // Check if usable
+      if (threadRelation.aiAssistant != null) {
+        const aiAssistantId = getIdStringForRef(threadRelation.aiAssistant);
+        const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+      }
+
+      // Initialize SSE helper and stream processor
+      const sseHelper = new SseHelper(res);
+      const streamProcessor = new LlmResponseStreamProcessor({
+        messageCallback: (appendedMessage) => {
+          sseHelper.writeData<SseMessage>({ appendedMessage });
+        },
+        diffDetectedCallback: (detected) => {
+          sseHelper.writeData<SseDetectedDiff>({ diff: detected });
+        },
+        dataFinalizedCallback: (message, replacements) => {
+          sseHelper.writeData<SseFinalized>({ finalized: { message: message ?? '', replacements } });
+        },
+      });
+
+      try {
+        // Set response headers
+        res.writeHead(200, {
+          'Content-Type': 'text/event-stream;charset=utf-8',
+          'Cache-Control': 'no-cache, no-transform',
+        });
+
+        let rawBuffer = '';
+
+        // Get assistant and process thread
+        const assistant = await getOrCreateEditorAssistant();
+        const thread = await openaiClient.beta.threads.retrieve(threadId);
+
+        // Create stream
+        const stream = openaiClient.beta.threads.runs.stream(thread.id, {
+          assistant_id: assistant.id,
+          additional_messages: [
+            {
+              role: 'assistant',
+              content: instruction(markdown != null),
+            },
+            {
+              role: 'user',
+              content: `Current markdown content:\n\`\`\`markdown\n${markdown}\n\`\`\`\n\nUser request: ${userMessage}`,
+            },
+          ],
+          response_format: zodResponseFormat(LlmEditorAssistantResponseSchema, 'editor_assistant_response'),
+        });
+
+        // Message delta handler
+        const messageDeltaHandler = async(delta: MessageDelta) => {
+          const content = delta.content?.[0];
+
+          // Process annotations
+          if (content?.type === 'text' && content?.text?.annotations != null) {
+            await replaceAnnotationWithPageLink(content, req.user.lang);
+          }
+
+          // Process text
+          if (content?.type === 'text' && content.text?.value) {
+            const chunk = content.text.value;
+
+            // Process data with JSON processor
+            streamProcessor.process(rawBuffer, chunk);
+
+            rawBuffer += chunk;
+          }
+          else {
+            sseHelper.writeData(delta);
+          }
+        };
+
+        // Register event handlers
+        stream.on('messageDelta', messageDeltaHandler);
+
+        // Run error handler
+        stream.on('event', (delta) => {
+          if (delta.event === 'thread.run.failed') {
+            const errorMessage = delta.data.last_error?.message;
+            if (errorMessage == null) return;
+
+            logger.error(errorMessage);
+            sseHelper.writeError(errorMessage, getStreamErrorCode(errorMessage));
+          }
+        });
+
+        // Completion handler
+        stream.once('messageDone', () => {
+          // Process and send final result
+          streamProcessor.sendFinalResult(rawBuffer);
+
+          // Clean up stream
+          streamProcessor.destroy();
+          stream.off('messageDelta', messageDeltaHandler);
+          sseHelper.end();
+        });
+
+        // Error handler
+        stream.once('error', (err) => {
+          logger.error('Stream error:', err);
+
+          // Clean up
+          streamProcessor.destroy();
+          stream.off('messageDelta', messageDeltaHandler);
+          sseHelper.writeError('An error occurred while processing your request');
+          sseHelper.end();
+        });
+
+        // Clean up on client disconnect
+        req.on('close', () => {
+          streamProcessor.destroy();
+
+          if (stream) {
+            stream.off('messageDelta', () => {});
+            stream.off('event', () => {});
+          }
+
+          logger.debug('Connection closed by client');
+        });
+      }
+      catch (err) {
+        // Clean up and respond on error
+        logger.error('Error in edit handler:', err);
+        streamProcessor.destroy();
+        return res.status(500).send(err.message);
+      }
+    },
+  ];
+};

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

@@ -31,12 +31,13 @@ export const factory = (crowi: Crowi): express.Router => {
       router.delete('/thread/:aiAssistantId/:threadRelationId', deleteThreadFactory(crowi));
     });
 
-    import('./message').then(({ postMessageHandlersFactory }) => {
+    import('./message').then(({ getMessagesFactory, postMessageHandlersFactory }) => {
       router.post('/message', postMessageHandlersFactory(crowi));
+      router.get('/messages/:aiAssistantId/:threadId', getMessagesFactory(crowi));
     });
 
-    import('./get-messages').then(({ getMessagesFactory }) => {
-      router.get('/messages/:aiAssistantId/:threadId', getMessagesFactory(crowi));
+    import('./edit').then(({ postMessageToEditHandlersFactory }) => {
+      router.post('/edit', postMessageToEditHandlersFactory(crowi));
     });
 
     import('./ai-assistant').then(({ createAiAssistantFactory }) => {

+ 2 - 3
apps/app/src/features/openai/server/routes/get-messages.ts → apps/app/src/features/openai/server/routes/message/get-messages.ts

@@ -10,9 +10,8 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-import { getOpenaiService } from '../services/openai';
-
-import { certifyAiService } from './middlewares/certify-ai-service';
+import { getOpenaiService } from '../../services/openai';
+import { certifyAiService } from '../middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:get-message');
 

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

@@ -0,0 +1,2 @@
+export * from './get-messages';
+export * from './post-message';

+ 20 - 20
apps/app/src/features/openai/server/routes/message.ts → apps/app/src/features/openai/server/routes/message/post-message.ts

@@ -14,16 +14,14 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-import { shouldHideMessageKey } from '../../interfaces/message';
-import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error';
-import AiAssistantModel from '../models/ai-assistant';
-import ThreadRelationModel from '../models/thread-relation';
-import { openaiClient } from '../services/client';
-import { getStreamErrorCode } from '../services/getStreamErrorCode';
-import { getOpenaiService } from '../services/openai';
-import { replaceAnnotationWithPageLink } from '../services/replace-annotation-with-page-link';
-
-import { certifyAiService } from './middlewares/certify-ai-service';
+import { MessageErrorCode, type StreamErrorCode } from '../../../interfaces/message-error';
+import AiAssistantModel from '../../models/ai-assistant';
+import ThreadRelationModel from '../../models/thread-relation';
+import { openaiClient } from '../../services/client';
+import { getStreamErrorCode } from '../../services/getStreamErrorCode';
+import { getOpenaiService } from '../../services/openai';
+import { replaceAnnotationWithPageLink } from '../../services/replace-annotation-with-page-link';
+import { certifyAiService } from '../middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:message');
 
@@ -33,6 +31,7 @@ type ReqBody = {
   aiAssistantId: string,
   threadId?: string,
   summaryMode?: boolean,
+  extendedThinkingMode?: boolean,
 }
 
 type Req = Request<undefined, Response, ReqBody> & {
@@ -86,6 +85,8 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
       threadRelation.updateThreadExpiration();
 
       let stream: AssistantStream;
+      const useSummaryMode = req.body.summaryMode ?? false;
+      const useExtendedThinkingMode = req.body.extendedThinkingMode ?? false;
 
       try {
         const assistant = await getOrCreateChatAssistant();
@@ -94,18 +95,17 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
         stream = openaiClient.beta.threads.runs.stream(thread.id, {
           assistant_id: assistant.id,
           additional_messages: [
-            {
-              role: 'assistant',
-              content: req.body.summaryMode
-                ? 'Turn on summary mode: I will try to answer concisely, aiming for 1-3 sentences.'
-                : 'I will turn off summary mode and answer.',
-              metadata: {
-                [shouldHideMessageKey]: 'true',
-              },
-            },
             { role: 'user', content: req.body.userMessage },
           ],
-          additional_instructions: aiAssistant.additionalInstruction,
+          additional_instructions: [
+            aiAssistant.additionalInstruction,
+            useSummaryMode
+              ? '**IMPORTANT** : Turn on "Summary Mode"'
+              : '**IMPORTANT** : Turn off "Summary Mode"',
+            useExtendedThinkingMode
+              ? '**IMPORTANT** : Turn on "Extended Thinking Mode"'
+              : '**IMPORTANT** : Turn off "Extended Thinking Mode"',
+          ].join('\n'),
         });
 
       }

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