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

Merge branch 'feat/openai-vector-searching' into feat/153983-154087-split-markdown-per-header-sections

nHigashiWeseek 1 год назад
Родитель
Сommit
520d47e0e6
73 измененных файлов с 1627 добавлено и 392 удалено
  1. 1 0
      .github/mergify.yml
  2. 35 1
      CHANGELOG.md
  3. 1 1
      apps/app/docker/README.md
  4. 4 2
      apps/app/package.json
  5. 11 0
      apps/app/public/static/locales/en_US/admin.json
  6. 1 1
      apps/app/public/static/locales/en_US/commons.json
  7. 11 0
      apps/app/public/static/locales/fr_FR/admin.json
  8. 1 1
      apps/app/public/static/locales/fr_FR/commons.json
  9. 11 0
      apps/app/public/static/locales/ja_JP/admin.json
  10. 1 1
      apps/app/public/static/locales/ja_JP/commons.json
  11. 11 0
      apps/app/public/static/locales/zh_CN/admin.json
  12. 4 0
      apps/app/resource/search/mappings-es7.json
  13. 4 0
      apps/app/resource/search/mappings-es8.json
  14. 46 0
      apps/app/src/client/components/Admin/AiIntegration/AiIntegration.tsx
  15. 28 0
      apps/app/src/client/components/Admin/AiIntegration/AiIntegrationDisableMode.tsx
  16. 1 1
      apps/app/src/client/components/Admin/G2GDataTransfer.tsx
  17. 8 2
      apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx
  18. 0 1
      apps/app/src/client/components/CustomNavigation/CustomNav.module.scss
  19. 19 3
      apps/app/src/client/components/CustomNavigation/CustomNav.tsx
  20. 1 1
      apps/app/src/client/components/DataTransferForm.tsx
  21. 4 0
      apps/app/src/client/components/DescendantsPageListModal.module.scss
  22. 70 0
      apps/app/src/client/components/DescendantsPageListModal.spec.tsx
  23. 25 9
      apps/app/src/client/components/DescendantsPageListModal.tsx
  24. 5 1
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  25. 4 0
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.module.scss
  26. 24 9
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  27. 5 1
      apps/app/src/client/components/PageControls/PageControls.tsx
  28. 12 0
      apps/app/src/client/components/PageControls/RagSearchButton.module.scss
  29. 28 0
      apps/app/src/client/components/PageControls/RagSearchButton.tsx
  30. 12 8
      apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx
  31. 1 0
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  32. 23 0
      apps/app/src/client/components/RagSearch/MessageCard.tsx
  33. 97 0
      apps/app/src/client/components/RagSearch/RagSearchModal.tsx
  34. 3 0
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  35. 2 1
      apps/app/src/components/Layout/BasicLayout.tsx
  36. 17 4
      apps/app/src/features/search/client/components/SearchModal.tsx
  37. 7 0
      apps/app/src/features/search/utils/ai.ts
  38. 6 0
      apps/app/src/interfaces/ai.ts
  39. 61 0
      apps/app/src/pages/admin/ai-integration.page.tsx
  40. 10 2
      apps/app/src/pages/admin/data-transfer.page.tsx
  41. 2 1
      apps/app/src/pages/installer.page.tsx
  42. 26 0
      apps/app/src/server/middlewares/certify-ai-service.ts
  43. 3 0
      apps/app/src/server/routes/apiv3/index.js
  44. 77 0
      apps/app/src/server/routes/apiv3/openai/chat.ts
  45. 12 0
      apps/app/src/server/routes/apiv3/openai/index.ts
  46. 41 0
      apps/app/src/server/routes/apiv3/openai/rebuild-vector-store.ts
  47. 4 4
      apps/app/src/server/routes/search.ts
  48. 48 0
      apps/app/src/server/service/config-loader.ts
  49. 82 0
      apps/app/src/server/service/openai/assistant/assistant.ts
  50. 1 0
      apps/app/src/server/service/openai/assistant/index.ts
  51. 41 0
      apps/app/src/server/service/openai/client-delegator/azure-openai-client-delegator.ts
  52. 34 0
      apps/app/src/server/service/openai/client-delegator/get-client.ts
  53. 1 0
      apps/app/src/server/service/openai/client-delegator/index.ts
  54. 9 0
      apps/app/src/server/service/openai/client-delegator/interfaces.ts
  55. 47 0
      apps/app/src/server/service/openai/client-delegator/openai-client-delegator.ts
  56. 7 0
      apps/app/src/server/service/openai/client.ts
  57. 27 0
      apps/app/src/server/service/openai/embeddings.ts
  58. 32 0
      apps/app/src/server/service/openai/file-upload.ts
  59. 3 0
      apps/app/src/server/service/openai/index.ts
  60. 54 0
      apps/app/src/server/service/openai/openai.ts
  61. 3 1
      apps/app/src/server/service/search-delegator/bulk-write.d.ts
  62. 73 4
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  63. 7 1
      apps/app/src/server/service/search.ts
  64. 14 11
      apps/app/src/stores-universal/context.tsx
  65. 26 0
      apps/app/src/stores/rag-search.ts
  66. 4 2
      apps/app/src/stores/search.tsx
  67. 1 1
      apps/slackbot-proxy/package.json
  68. 2 1
      package.json
  69. 5 0
      packages/core-styles/scss/bootstrap/theming/_dropdown-dark.scss
  70. 6 0
      packages/core-styles/scss/bootstrap/theming/_dropdown-light.scss
  71. 1 0
      packages/core-styles/scss/bootstrap/theming/apply-dark.scss
  72. 1 0
      packages/core-styles/scss/bootstrap/theming/apply-light.scss
  73. 318 316
      yarn.lock

+ 1 - 0
.github/mergify.yml

@@ -23,6 +23,7 @@ pull_request_rules:
   - name: Automatic queue to merge
     conditions:
       - '#approved-reviews-by >= 1'
+      - '#changes-requested-reviews-by = 0'
       - '#review-requested = 0'
       - check-success = check-title
     actions:

+ 35 - 1
CHANGELOG.md

@@ -1,9 +1,43 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.19...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.20...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.20](https://github.com/weseek/growi/compare/v7.0.19...v7.0.20) - 2024-09-25
+
+### 🚀 Improvement
+
+* imprv: The color of the dropdown list when it is activated (#9102) @WNomunomu
+* imprv: PageTitleHeader max-width (#9166) @yuki-takei
+* imprv: Documentation URL for g2gtransfer (#9157) @yuki-takei
+* imprv: Corrected wording on admin page (/admin/data-transfer) (#9106) @miya
+* imprv: Add hover-activated clipboard copy button with icon (#9095) @reiji-h
+
+### 🐛 Bug Fixes
+
+* fix: Make PageAccessoriesModal responsive (#9171) @moekumasaka
+* fix: PageControls unexpectedly move in response to opening and closing the sidebar (#9094) @WNomunomu
+* fix: Make CustomNavTab responsive (#9123) @moekumasaka
+
+### 🧰 Maintenance
+
+* ci(deps): bump rollup from 4.22.0 to 4.22.4 (#9160) @dependabot
+* ci(deps): bump google-github-actions/setup-gcloud from 1 to 2 (#9153) @dependabot
+* ci(deps): bump softprops/action-gh-release from 1 to 2 (#9152) @dependabot
+* support: Improve vitest environment (#9144) @yuki-takei
+* ci(deps): bump next from 14.1.3 to 14.2.13 (#9154) @dependabot
+* support: Upgrade @testing-library/react (#9141) @yuki-takei
+* support: Update logo image in README.md for the official docker image (#9139) @satof3
+* ci(deps-dev): bump vite from 5.2.9 to 5.2.14 (#9134) @dependabot
+* ci(deps): bump myrotvorets/info-from-package-json-action from 1.2.0 to 2.0.1 (#9129) @dependabot
+* ci(deps): bump stefanzweifel/git-auto-commit-action from 4 to 5 (#9128) @dependabot
+* ci(deps): bump nodemailer from 6.9.14 to 6.9.15 (#9075) @dependabot
+* ci(deps): bump docker/setup-buildx-action from 2 to 3 (#8207) @dependabot
+* ci(deps): bump jose from 4.11.4 to 4.15.9 (#9114) @dependabot
+* ci(deps): bump express from 4.19.2 to 4.20.0 (#9110) @dependabot
+* ci(deps): bump body-parser from 1.20.2 to 1.20.3 (#9109) @dependabot
+
 ## [v7.0.19](https://github.com/weseek/growi/compare/v7.0.18...v7.0.19) - 2024-09-12
 
 ### 🐛 Bug Fixes

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.19`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.19/apps/app/docker/Dockerfile)
+* [`7.0.20`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.20/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 4 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.20-RC.0",
+  "version": "7.0.21-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -60,7 +60,8 @@
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
     "@aws-sdk/client-s3": "3.454.0",
     "@aws-sdk/s3-request-presigner": "3.454.0",
-    "@azure/identity": "^4.3.0",
+    "@azure/identity": "^4.4.1",
+    "@azure/openai": "^2.0.0-beta.2",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@dqbd/tiktoken": "^1.0.16",
@@ -150,6 +151,7 @@
     "node-cron": "^3.0.2",
     "nodemailer": "^6.9.15",
     "nodemailer-ses-transport": "~1.5.0",
+    "openai": "^4.56.0",
     "openid-client": "^5.4.0",
     "p-retry": "^4.0.0",
     "passport": "^0.6.0",

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

@@ -1132,5 +1132,16 @@
   },
   "forbidden_page": {
     "do_not_have_admin_permission": "Users without administrative rights cannot access the administration screen"
+  },
+  "ai_integration": {
+    "ai_integration": "AI Integration",
+    "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, please set the environment variable <code>AI_ENABLED</code> to true.",
+    "ai_search_management": "AI search management",
+    "rebuild_vector_store": "Rebuild Vector Store",
+    "rebuild_vector_store_label": "Rebuild",
+    "rebuild_vector_store_explanation1": "Delete the existing Vector Store and recreate the Vector Store on the public page.",
+    "rebuild_vector_store_explanation2": "This process may take several minutes.",
+    "rebuild_vector_store_requested": "Vector Store rebuild has been requested",
+    "rebuild_vector_store_failed": "Vector Store rebuild failed"
   }
 }

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

@@ -157,6 +157,6 @@
     "publish_transfer_key": "Publish transfer key",
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
-    "transfer_to_growi_cloud": "For more details, please click <a href='https://{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
+    "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
   }
 }

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

@@ -1131,5 +1131,16 @@
   },
   "forbidden_page": {
     "do_not_have_admin_permission": "Seul les administrateurs peuvent accéder à cette page."
+  },
+  "ai_integration": {
+    "ai_integration": "Intégration de l'IA",
+    "disable_mode_explanation": "Actuellement, l'intégration de l'IA est désactivée. Pour l'activer, veuillez définir la variable d'environnement <code>AI_ENABLED</code> sur true",
+    "ai_search_management": "Gestion de la recherche par l'IA",
+    "rebuild_vector_store": "Reconstruire le magasin Vector",
+    "rebuild_vector_store_label": "Reconstruire",
+    "rebuild_vector_store_explanation1": "Supprimez le Vector Store existant et recréez le Vector Store sur la page publique.",
+    "rebuild_vector_store_explanation2": "Ce processus peut prendre plusieurs minutes.",
+    "rebuild_vector_store_requested": "La reconstruction du magasin Vector a été demandée",
+    "rebuild_vector_store_failed": "Échec de la reconstruction du magasin de vecteurs"
   }
 }

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

@@ -157,6 +157,6 @@
     "publish_transfer_key": "Publier la clé de transfert",
     "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
     "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
-    "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='https://{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
+    "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
   }
 }

+ 11 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -1142,5 +1142,16 @@
   },
   "forbidden_page": {
     "do_not_have_admin_permission": "管理者権限のないユーザーでは管理画面にはアクセスできません"
+  },
+  "ai_integration": {
+    "ai_integration": "AI 連携",
+    "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> を true に設定してください。",
+    "ai_search_management": "AI 検索管理",
+    "rebuild_vector_store": "Vector Store のリビルド",
+    "rebuild_vector_store_label": "リビルド",
+    "rebuild_vector_store_explanation1": "既存の Vector Store を削除し、公開ページの Vector Store を再作成します。",
+    "rebuild_vector_store_explanation2": "この作業には数分かかる可能性があります。",
+    "rebuild_vector_store_requested": "Vector Store のリビルドを受け付けました",
+    "rebuild_vector_store_failed": "Vector Store のリビルドに失敗しました"
   }
 }

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

@@ -159,6 +159,6 @@
     "publish_transfer_key": "移行キーを発行する",
     "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
     "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ以降はご利用いただけなくなります。",
-    "transfer_to_growi_cloud": "※ 詳しくは <a href='https://{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'> GROWI お引越し機能</a>をご確認ください。"
+    "transfer_to_growi_cloud": "※ 詳しくは <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'> GROWI お引越し機能</a>をご確認ください。"
   }
 }

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

@@ -1141,5 +1141,16 @@
   },
   "forbidden_page": {
     "do_not_have_admin_permission": "没有管理权限的用户无法访问管理屏幕"
+  },
+  "ai_integration": {
+    "ai_integration": "AI 集成",
+    "disable_mode_explanation": "目前,AI 集成已禁用。要启用它,请将环境变量 <code>AI_ENABLED</code> 设置为 true",
+    "ai_search_management": "AI 搜索管理",
+    "rebuild_vector_store": "重建矢量商店",
+    "rebuild_vector_store_label": "重建",
+    "rebuild_vector_store_explanation1": "删除现有的矢量存储,在公共页面上重新创建矢量存储。",
+    "rebuild_vector_store_explanation2": "这个过程可能需要几分钟。",
+    "rebuild_vector_store_requested": "已要求重建矢量存储库",
+    "rebuild_vector_store_failed": "向量存储区重建失败"
   }
 }

+ 4 - 0
apps/app/resource/search/mappings-es7.json

@@ -64,6 +64,10 @@
           }
         }
       },
+      "body_embedded": {
+        "type": "dense_vector",
+        "dims": 768
+      },
       "comments": {
         "type": "text",
         "fields": {

+ 4 - 0
apps/app/resource/search/mappings-es8.json

@@ -64,6 +64,10 @@
           }
         }
       },
+      "body_embedded": {
+        "type": "dense_vector",
+        "dims": 768
+      },
       "comments": {
         "type": "text",
         "fields": {

+ 46 - 0
apps/app/src/client/components/Admin/AiIntegration/AiIntegration.tsx

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

+ 28 - 0
apps/app/src/client/components/Admin/AiIntegration/AiIntegrationDisableMode.tsx

@@ -0,0 +1,28 @@
+import type { FC } from 'react';
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+export const AiIntegrationDisableMode: FC = () => {
+  const { t } = useTranslation('admin');
+
+  return (
+    <div className="ccontainer-lg">
+      <div className="container">
+        <div className="row justify-content-md-center">
+          <div className="col-md-6 mt-5">
+            <div className="text-center">
+              {/* error icon large */}
+              <h1><span className="material-symbols-outlined">error</span></h1>
+              <h1 className="text-center">{t('ai_integration.ai_integration')}</h1>
+              <h3
+                // eslint-disable-next-line react/no-danger
+                dangerouslySetInnerHTML={{ __html: t('ai_integration.disable_mode_explanation') }}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};

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

@@ -282,7 +282,7 @@ const G2GDataTransfer = (): JSX.Element => {
           className="mb-0"
           // eslint-disable-next-line react/no-danger
           dangerouslySetInnerHTML={{
-            __html: t('commons:g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl: documentationUrl || 'docs.growi.org' }),
+            __html: t('commons:g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
           }}
         />
       </div>

+ 8 - 2
apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx

@@ -14,7 +14,7 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
-import { CustomNavTab } from '../../CustomNavigation/CustomNav';
+import CustomNav from '../../CustomNavigation/CustomNav';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
@@ -155,7 +155,13 @@ function NotificationSetting(props) {
 
       <h2 className="admin-setting-header mt-5">{t('notification_settings.notification_settings')}</h2>
 
-      <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
+      <CustomNav
+        activeTab={activeTab}
+        navTabMapping={navTabMapping}
+        onNavSelected={switchActiveTab}
+        hideBorderBottom
+        breakpointToSwitchDropdownDown="md"
+      />
 
       <TabContent activeTab={activeTab} className="p-5">
         <TabPane tabId="user_trigger_notification">

+ 0 - 1
apps/app/src/client/components/CustomNavigation/CustomNav.module.scss

@@ -14,5 +14,4 @@
     border-bottom: 3px solid;
     transition: 0.3s ease-in-out;
   }
-
 }

+ 19 - 3
apps/app/src/client/components/CustomNavigation/CustomNav.tsx

@@ -42,26 +42,42 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
 
   const { Icon, i18n } = navTabMapping[activeTab];
 
+  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+  const dropdownButtonRef = useRef<HTMLButtonElement>(null);
+
+  const toggleDropdown = () => {
+    setIsDropdownOpen(prev => !prev);
+  };
+
   const menuItemClickHandler = useCallback((key) => {
     if (onNavSelected != null) {
       onNavSelected(key);
     }
+    // Manually close the dropdown
+    setIsDropdownOpen(false);
+    if (dropdownButtonRef.current) {
+      dropdownButtonRef.current.classList.remove('show');
+    }
   }, [onNavSelected]);
 
   return (
     <div className="btn-group">
       <button
+        ref={dropdownButtonRef}
         className="btn btn-outline-primary btn-lg dropdown-toggle text-end"
         type="button"
         data-bs-toggle="dropdown"
         aria-haspopup="true"
-        aria-expanded="false"
+        aria-expanded={isDropdownOpen}
+        onClick={toggleDropdown}
+        data-testid="custom-nav-dropdown"
       >
         <span className="float-start">
           { Icon != null && <Icon /> } {i18n}
         </span>
       </button>
-      <div className="dropdown-menu dropdown-menu-right">
+      <div className={`dropdown-menu dropdown-menu-right w-100 ${isDropdownOpen ? 'show' : ''} ${styles['dropdown-menu']}`}>
         {Object.entries(navTabMapping).map(([key, value]) => {
 
           const isActive = activeTab === key;
@@ -167,7 +183,7 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
   }
 
   return (
-    <div className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
+    <div data-testid="custom-nav-tab" className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
       <div ref={navContainerRef} className="d-flex justify-content-between">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {

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

@@ -39,7 +39,7 @@ const DataTransferForm = (): JSX.Element => {
           className="mb-0"
           // eslint-disable-next-line react/no-danger
           dangerouslySetInnerHTML={{
-            __html: t('g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl: documentationUrl || 'docs.growi.org' }),
+            __html: t('g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
           }}
         />
       </div>

+ 4 - 0
apps/app/src/client/components/DescendantsPageListModal.module.scss

@@ -9,6 +9,10 @@
     padding: 25px 30px;
   }
 
+  .grw-tab-content-style-md-down {
+    padding-top: 25px;
+  }
+
   .grw-modal-body-style {
     max-height: calc(100vh - 100px);
   }

+ 70 - 0
apps/app/src/client/components/DescendantsPageListModal.spec.tsx

@@ -0,0 +1,70 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import { DescendantsPageListModal } from './DescendantsPageListModal';
+
+const mockClose = vi.hoisted(() => vi.fn());
+const useIsDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue({ data: true }));
+
+vi.mock('next/router', () => ({
+  useRouter: () => ({
+    events: {
+      on: vi.fn(),
+      off: vi.fn(),
+    },
+  }),
+}));
+
+vi.mock('~/stores/modal', () => ({
+  useDescendantsPageListModal: vi.fn().mockReturnValue({
+    data: { isOpened: true },
+    close: mockClose,
+  }),
+}));
+
+vi.mock('~/stores/ui', () => ({
+  useIsDeviceLargerThanLg,
+}));
+
+describe('DescendantsPageListModal.tsx', () => {
+
+  it('should render the modal when isOpened is true', () => {
+    render(<DescendantsPageListModal />);
+    expect(screen.getByTestId('descendants-page-list-modal')).not.toBeNull();
+  });
+
+  it('should call close function when close button is clicked', () => {
+    render(<DescendantsPageListModal />);
+    const closeButton = screen.getByLabelText('Close');
+    fireEvent.click(closeButton);
+    expect(mockClose).toHaveBeenCalled();
+  });
+
+  describe('when device is larger than lg', () => {
+
+    it('should render CustomNavTab', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.getByTestId('custom-nav-tab')).not.toBeNull();
+    });
+
+    it('should not render CustomNavDropdown', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.queryByTestId('custom-nav-dropdown')).toBeNull();
+    });
+  });
+
+  describe('when device is smaller than lg', () => {
+    beforeEach(() => {
+      useIsDeviceLargerThanLg.mockReturnValue({ data: false });
+    });
+
+    it('should render CustomNavDropdown on devices smaller than lg', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.getByTestId('custom-nav-dropdown')).not.toBeNull();
+    });
+
+    it('should not render CustomNavTab', () => {
+      render(<DescendantsPageListModal />);
+      expect(screen.queryByTestId('custom-nav-tab')).toBeNull();
+    });
+  });
+});

+ 25 - 9
apps/app/src/client/components/DescendantsPageListModal.tsx

@@ -10,8 +10,9 @@ import {
 
 import { useIsSharedUser } from '~/stores-universal/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
+import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
-import { CustomNavTab } from './CustomNavigation/CustomNav';
+import { CustomNavDropdown, CustomNavTab } from './CustomNavigation/CustomNav';
 import CustomTabContent from './CustomNavigation/CustomTabContent';
 import type { DescendantsPageListProps } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
@@ -34,6 +35,8 @@ export const DescendantsPageListModal = (): JSX.Element => {
 
   const { events } = useRouter();
 
+  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
+
   useEffect(() => {
     events.on('routeChangeStart', close);
     return () => {
@@ -93,17 +96,30 @@ export const DescendantsPageListModal = (): JSX.Element => {
       data-testid="descendants-page-list-modal"
       className={`grw-descendants-page-list-modal ${styles['grw-descendants-page-list-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
-      <ModalHeader className="p-0" toggle={close} close={buttons}>
-        <CustomNavTab
+      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
+        {isDeviceLargerThanLg && (
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            breakpointToHideInactiveTabsDown="md"
+            onNavSelected={v => setActiveTab(v)}
+            hideBorderBottom
+          />
+        )}
+      </ModalHeader>
+      <ModalBody>
+        {!isDeviceLargerThanLg && (
+          <CustomNavDropdown
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={v => setActiveTab(v)}
+          />
+        )}
+        <CustomTabContent
           activeTab={activeTab}
           navTabMapping={navTabMapping}
-          breakpointToHideInactiveTabsDown="md"
-          onNavSelected={v => setActiveTab(v)}
-          hideBorderBottom
+          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
         />
-      </ModalHeader>
-      <ModalBody>
-        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
       </ModalBody>
     </Modal>
   );

+ 5 - 1
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -348,7 +348,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     <>
       <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
 
-      <Sticky className="z-1" onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}>
+      <Sticky
+        className="z-1"
+        onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
+        innerActiveClass="w-100 end-0"
+      >
         <GroundGlassBar>
 
           <nav

+ 4 - 0
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.module.scss

@@ -9,6 +9,10 @@
     padding: 25px 30px;
   }
 
+  .grw-tab-content-style-md-down {
+    padding-top: 25px;
+  }
+
   .grw-modal-body-style {
     max-height: calc(100vh - 100px);
   }

+ 24 - 9
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -10,8 +10,9 @@ import {
   useDisableLinkSharing, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser,
 } from '~/stores-universal/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
-import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import ExpandOrContractButton from '../ExpandOrContractButton';
 
@@ -35,6 +36,7 @@ export const PageAccessoriesModal = (): JSX.Element => {
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isLinkSharingDisabled } = useDisableLinkSharing();
+  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
 
   const { data: status, close, selectContents } = usePageAccessoriesModal();
 
@@ -93,17 +95,30 @@ export const PageAccessoriesModal = (): JSX.Element => {
       data-testid="page-accessories-modal"
       className={`grw-page-accessories-modal ${styles['grw-page-accessories-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
-      <ModalHeader className="p-0" toggle={close} close={buttons}>
-        <CustomNavTab
+      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
+        {isDeviceLargerThanLg && (
+          <CustomNavTab
+            activeTab={status.activatedContents}
+            navTabMapping={navTabMapping}
+            breakpointToHideInactiveTabsDown="md"
+            onNavSelected={selectContents}
+            hideBorderBottom
+          />
+        )}
+      </ModalHeader>
+      <ModalBody className="overflow-auto grw-modal-body-style">
+        {!isDeviceLargerThanLg && (
+          <CustomNavDropdown
+            activeTab={status.activatedContents}
+            navTabMapping={navTabMapping}
+            onNavSelected={selectContents}
+          />
+        )}
+        <CustomTabContent
           activeTab={status.activatedContents}
           navTabMapping={navTabMapping}
-          breakpointToHideInactiveTabsDown="md"
-          onNavSelected={selectContents}
-          hideBorderBottom
+          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
         />
-      </ModalHeader>
-      <ModalBody className="overflow-auto grw-modal-body-style">
-        <CustomTabContent activeTab={status.activatedContents} navTabMapping={navTabMapping} />
       </ModalBody>
     </Modal>
   );

+ 5 - 1
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -36,6 +36,7 @@ import {
 
 import { BookmarkButtons } from './BookmarkButtons';
 import LikeButtons from './LikeButtons';
+import RagSearchButton from './RagSearchButton';
 import SearchButton from './SearchButton';
 import SeenUserInfo from './SeenUserInfo';
 import SubscribeButton from './SubscribeButton';
@@ -282,7 +283,10 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   return (
     <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
       { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
-        <SearchButton />
+        <>
+          <SearchButton />
+          <RagSearchButton />
+        </>
       )}
 
       {revisionId != null && !isViewMode && _isIPageInfoForOperation && (

+ 12 - 0
apps/app/src/client/components/PageControls/RagSearchButton.module.scss

@@ -0,0 +1,12 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use '@growi/ui/scss/atoms/btn-muted';
+@use './button-styles';
+
+.btn-rag-search :global {
+  @extend %btn-basis;
+}
+
+// == Colors
+.btn-rag-search {
+  @include btn-muted.colorize(bs.$success);
+}

+ 28 - 0
apps/app/src/client/components/PageControls/RagSearchButton.tsx

@@ -0,0 +1,28 @@
+import React, { useCallback } from 'react';
+
+import { useRagSearchModal } from '~/stores/rag-search';
+
+import styles from './RagSearchButton.module.scss';
+
+const RagSearchButton = (): JSX.Element => {
+
+  const { open: openRagSearchModal } = useRagSearchModal();
+
+  const ragSearchButtonClickHandler = useCallback(() => {
+    openRagSearchModal();
+  }, [openRagSearchModal]);
+
+
+  return (
+    <button
+      type="button"
+      className={`btn btn-search ${styles['btn-rag-search']}`}
+      onClick={ragSearchButtonClickHandler}
+      data-testid="open-search-modal-button"
+    >
+      <span className="material-symbols-outlined">chat</span>
+    </button>
+  );
+};
+
+export default RagSearchButton;

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

@@ -1,11 +1,13 @@
 import '@testing-library/jest-dom/vitest';
 
+import { faker } from '@faker-js/faker';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import {
   fireEvent, render, screen, waitFor,
 } from '@testing-library/react';
 import { mock } from 'vitest-mock-extended';
 
+
 import { EditorMode } from '~/stores-universal/ui';
 
 import { PageTitleHeader } from './PageTitleHeader';
@@ -32,7 +34,7 @@ describe('PageTitleHeader Component with untitled page', () => {
   it('should render the textbox correctly', async() => {
     // arrange
     const currentPage = mock<IPagePopulatedToShowRevision>({
-      _id: 'dummy-id',
+      _id: faker.database.mongodbObjectId(),
       path: '/path/to/page/Untitled-1',
     });
 
@@ -63,9 +65,10 @@ describe('PageTitleHeader Component', () => {
 
   it('should render the title correctly', async() => {
     // arrange
+    const pageTitle = faker.lorem.slug();
     const currentPage = mock<IPagePopulatedToShowRevision>({
-      _id: 'dummy-id',
-      path: '/path/to/page/page-title',
+      _id: faker.database.mongodbObjectId(),
+      path: `/path/to/page/${pageTitle}`,
     });
 
     // act
@@ -73,7 +76,7 @@ describe('PageTitleHeader Component', () => {
 
     // assert
     // header should be rendered
-    const headerElement = screen.getByText('page-title');
+    const headerElement = screen.getByText(pageTitle);
     await waitFor(() => {
       expect(headerElement).toBeInTheDocument();
       expect(headerElement).not.toHaveClass('invisible');
@@ -85,15 +88,16 @@ describe('PageTitleHeader Component', () => {
 
   it('should render text input after clicking', async() => {
     // arrange
+    const pageTitle = faker.lorem.slug();
     const currentPage = mock<IPagePopulatedToShowRevision>({
-      _id: 'dummy-id',
-      path: '/path/to/page/page-title',
+      _id: faker.database.mongodbObjectId(),
+      path: `/path/to/page/${pageTitle}`,
     });
 
     // act
     render(<PageTitleHeader currentPage={currentPage} />);
 
-    const headerElement = screen.getByText('page-title');
+    const headerElement = screen.getByText(pageTitle);
     await waitFor(() => expect(headerElement).toBeInTheDocument());
 
     // click
@@ -103,7 +107,7 @@ describe('PageTitleHeader Component', () => {
     const inputElement = screen.getByRole('textbox');
     await waitFor(() => {
       expect(inputElement).toBeInTheDocument();
-      expect(inputElement).toHaveValue('page-title');
+      expect(inputElement).toHaveValue(pageTitle);
       expect(headerElement).toHaveClass('invisible');
     });
   });

+ 1 - 0
apps/app/src/client/components/PageHeader/PageTitleHeader.tsx

@@ -129,6 +129,7 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
             ${isRenameInputShown ? 'invisible' : ''} text-truncate
             ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
           `}
+          style={{ maxWidth: inputMaxWidth }}
           onClick={onClickPageTitle}
         >
           {pageTitle}

+ 23 - 0
apps/app/src/client/components/RagSearch/MessageCard.tsx

@@ -0,0 +1,23 @@
+import ReactMarkdown from 'react-markdown';
+
+type Props = {
+  children?: string,
+  right?: boolean,
+}
+
+export const MessageCard = (props: Props): JSX.Element => {
+  const { children, right } = props;
+
+  const alignClass = right ? 'align-self-end bg-success-subtle' : 'align-self-start';
+  const bgClass = right ? 'bg-info-subtle' : '';
+
+  return (
+    <div className={`card d-inline-flex ${alignClass} ${bgClass}`} style={{ maxWidth: '75%' }}>
+      <div className="card-body">
+        { children != null && children.length > 0 && (
+          <ReactMarkdown>{children}</ReactMarkdown>
+        ) }
+      </div>
+    </div>
+  );
+};

+ 97 - 0
apps/app/src/client/components/RagSearch/RagSearchModal.tsx

@@ -0,0 +1,97 @@
+import React, { useState } from 'react';
+
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { useRagSearchModal } from '~/stores/rag-search';
+import loggerFactory from '~/utils/logger';
+
+import { MessageCard } from './MessageCard';
+
+
+const logger = loggerFactory('growi:clinet:components:RagSearchModal');
+
+
+type Message = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}
+
+const RagSearchModal = (): JSX.Element => {
+
+  const [input, setInput] = useState('');
+
+  const [threadId, setThreadId] = useState<string | undefined>();
+  const [messages, setMessages] = useState<Message[]>([]);
+
+  const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
+
+  const onClickSubmitUserMessageHandler = async() => {
+    const newUserMessage = { id: messages.length.toString(), content: input, isUserMessage: true };
+    setMessages(msgs => [...msgs, newUserMessage]);
+
+    setInput('');
+
+    try {
+      const res = await apiv3Post('/openai/chat', { userMessage: input, threadId });
+      const assistantMessageData = res.data.messages;
+
+      if (assistantMessageData.data.length > 0) {
+        const newMessages: Message[] = assistantMessageData.data.reverse()
+          .map((message: any) => {
+            return {
+              id: message.id,
+              content: message.content[0].text.value,
+            };
+          });
+
+        setMessages(msgs => [...msgs, ...newMessages]);
+        setThreadId(assistantMessageData.data[0].threadId);
+      }
+
+    }
+    catch (err) {
+      logger.error(err.toString());
+    }
+  };
+
+  return (
+    <Modal size="lg" isOpen={ragSearchModalData?.isOpened ?? false} toggle={closeRagSearchModal} data-testid="search-modal">
+      <ModalBody>
+        <ModalHeader tag="h4" className="mb-3 p-0">
+          <span className="material-symbols-outlined me-2 text-primary">psychology</span>
+          GROWI Assistant
+        </ModalHeader>
+
+        <div className="vstack gap-4">
+          { messages.map(message => (
+            <MessageCard key={message.id} right={message.isUserMessage}>{message.content}</MessageCard>
+          )) }
+        </div>
+
+        <div className="input-group mt-5">
+          <input
+            type="text"
+            className="form-control"
+            placeholder="お手伝いできることはありますか?"
+            aria-label="Recipient's username"
+            aria-describedby="button-addon2"
+            value={input}
+            onChange={e => setInput(e.target.value)}
+          />
+          <button
+            type="button"
+            id="button-addon2"
+            className="btn btn-outline-secondary"
+            onClick={onClickSubmitUserMessageHandler}
+          >
+            <span className="material-symbols-outlined">arrow_upward</span>
+          </button>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+export default RagSearchModal;

+ 3 - 0
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -32,6 +32,7 @@ const MenuLabel = ({ menu }: { menu: string }) => {
     case 'user-groups':              return <><span className="material-symbols-outlined me-1">group</span>{          t('user_group_management.user_group_management') }</>;
     case 'audit-log':                return <><span className="material-symbols-outlined me-1">feed</span>{            t('audit_log_management.audit_log')}</>;
     case 'plugins':                  return <><span className="material-symbols-outlined me-1">extension</span>{          t('plugins.plugins')}</>;
+    case 'ai-integration':           return <><span className="material-symbols-outlined me-1">psychology</span>{          t('ai_integration.ai_integration')}</>;
     case 'search':                   return <><span className="material-symbols-outlined me-1">search</span>{       t('full_text_search_management.full_text_search_management') }</>;
     case 'cloud':                    return <><span className="material-symbols-outlined me-1">share</span>{       t('cloud_setting_management.to_cloud_settings')} </>;
     default:                         return <><span className="material-symbols-outlined me-1">home</span>{            t('wiki_management_homepage') }</>;
@@ -106,6 +107,7 @@ export const AdminNavigation = (): JSX.Element => {
         <MenuLink menu="user-groups" isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/user-groups', 'user-group-detail'])} />
         <MenuLink menu="audit-log" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="plugins" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/plugins')} />
+        <MenuLink menu="ai-integration" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/ai-integration')} />
         <MenuLink menu="search" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
@@ -159,6 +161,7 @@ export const AdminNavigation = (): JSX.Element => {
             {isActiveMenu('/audit-log')             && <MenuLabel menu="audit-log" />}
             {isActiveMenu('/plugins')               && <MenuLabel menu="plugins" />}
             {isActiveMenu('/data-transfer')         && <MenuLabel menu="data-transfer" />}
+            {isActiveMenu('/ai-integration')                && <MenuLabel menu="ai-integration" />}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>

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

@@ -34,7 +34,7 @@ const DeleteBookmarkFolderModal = dynamic(
   () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
 );
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
-
+const RagSearchModal = dynamic(() => import('~/client/components/RagSearch/RagSearchModal'), { ssr: false });
 
 type Props = {
   children?: ReactNode
@@ -67,6 +67,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <DeleteBookmarkFolderModal />
       <PutbackPageModal />
       <SearchModal />
+      <RagSearchModal />
 
       <PagePresentationModal />
       <HotkeysManager />

+ 17 - 4
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -7,6 +7,7 @@ import Downshift, { type DownshiftState, type StateChangeOptions } from 'downshi
 import { useRouter } from 'next/router';
 import { Modal, ModalBody } from 'reactstrap';
 
+import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
 import type { DownshiftItem } from '../interfaces/downshift';
 import { useSearchModal } from '../stores/search';
 
@@ -16,7 +17,9 @@ import { SearchMethodMenuItem } from './SearchMethodMenuItem';
 import { SearchResultMenuItem } from './SearchResultMenuItem';
 
 const SearchModal = (): JSX.Element => {
+
   const [searchKeyword, setSearchKeyword] = useState('');
+  const [isMenthionedToAi, setMenthionedToAi] = useState(false);
 
   const { data: searchModalData, close: closeSearchModal } = useSearchModal();
 
@@ -32,7 +35,9 @@ const SearchModal = (): JSX.Element => {
   }, [closeSearchModal, router]);
 
   const submitHandler = useCallback(() => {
-    router.push(`/_search?q=${searchKeyword}`);
+    const url = new URL('_search', 'http://example.com');
+    url.searchParams.set('q', searchKeyword);
+    router.push(url.pathname + url.search);
     closeSearchModal();
   }, [closeSearchModal, router, searchKeyword]);
 
@@ -60,6 +65,12 @@ const SearchModal = (): JSX.Element => {
     }
   }, [searchModalData?.isOpened, searchModalData?.searchKeyword]);
 
+  useEffect(() => {
+    setMenthionedToAi(isIncludeAiMenthion(searchKeyword));
+  }, [searchKeyword]);
+
+  const searchKeywordWithoutAi = removeAiMenthion(searchKeyword);
+
   return (
     <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal} data-testid="search-modal">
       <ModalBody className="pb-2">
@@ -77,7 +88,9 @@ const SearchModal = (): JSX.Element => {
           }) => (
             <div {...getRootProps({}, { suppressRefError: true })}>
               <div className="text-muted d-flex justify-content-center align-items-center p-1">
-                <span className="material-symbols-outlined fs-4 me-3">search</span>
+                <span className={`material-symbols-outlined fs-4 me-3 ${isMenthionedToAi ? 'text-primary' : ''}`}>
+                  {isMenthionedToAi ? 'psychology' : 'search'}
+                </span>
                 <SearchForm
                   searchKeyword={searchKeyword}
                   onChange={changeSearchTextHandler}
@@ -97,12 +110,12 @@ const SearchModal = (): JSX.Element => {
                 <div className="border-top mt-2 mb-2" />
                 <SearchMethodMenuItem
                   activeIndex={highlightedIndex}
-                  searchKeyword={searchKeyword}
+                  searchKeyword={searchKeywordWithoutAi}
                   getItemProps={getItemProps}
                 />
                 <SearchResultMenuItem
                   activeIndex={highlightedIndex}
-                  searchKeyword={searchKeyword}
+                  searchKeyword={searchKeywordWithoutAi}
                   getItemProps={getItemProps}
                 />
                 <div className="border-top mt-2 mb-2" />

+ 7 - 0
apps/app/src/features/search/utils/ai.ts

@@ -0,0 +1,7 @@
+export const isIncludeAiMenthion = (keyword: string): boolean => {
+  return keyword.match(/(^|\s)@ai(\s|$)/) != null;
+};
+
+export const removeAiMenthion = (keyword: string): string => {
+  return keyword.replaceAll(/(^|\s)@ai(\s|$)/g, '');
+};

+ 6 - 0
apps/app/src/interfaces/ai.ts

@@ -0,0 +1,6 @@
+export const OpenaiServiceType = {
+  OPENAI: 'openai',
+  AZURE_OPENAI: 'azure-openai',
+} as const;
+export type OpenaiServiceType = typeof OpenaiServiceType[keyof typeof OpenaiServiceType];
+export const OpenaiServiceTypes = Object.values(OpenaiServiceType);

+ 61 - 0
apps/app/src/pages/admin/ai-integration.page.tsx

@@ -0,0 +1,61 @@
+import type {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import Head from 'next/head';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { CommonProps } from '~/pages/utils/commons';
+import { generateCustomTitle } from '~/pages/utils/commons';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const ForbiddenPage = dynamic(() => import('~/client/components/Admin/ForbiddenPage').then(mod => mod.ForbiddenPage), { ssr: false });
+const AiIntegration = dynamic(() => import('~/client/components/Admin/AiIntegration/AiIntegration').then(mod => mod.AiIntegration), { ssr: false });
+const AiIntegrationDisableMode = dynamic(
+  () => import('~/client/components/Admin/AiIntegration/AiIntegrationDisableMode').then(mod => mod.AiIntegrationDisableMode), { ssr: false },
+);
+
+type Props = CommonProps & {
+  aiEnabled: boolean,
+};
+
+const AdminAiIntegrationPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation('admin');
+
+  const title = t('ai_integration.ai_integration');
+  const headTitle = generateCustomTitle(props, title);
+
+  if (props.isAccessDeniedForNonAdminUser) {
+    return <ForbiddenPage />;
+  }
+
+  return (
+    <AdminLayout componentTitle={title}>
+      <Head>
+        <title>{headTitle}</title>
+      </Head>
+      {props.aiEnabled
+        ? <AiIntegration />
+        : <AiIntegrationDisableMode />
+      }
+    </AdminLayout>
+  );
+};
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { configManager } = crowi;
+
+  props.aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+export default AdminAiIntegrationPage;

+ 10 - 2
apps/app/src/pages/admin/data-transfer.page.tsx

@@ -9,8 +9,9 @@ import Head from 'next/head';
 import type { Container } from 'unstated';
 import { Provider } from 'unstated';
 
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CommonProps } from '~/pages/utils/commons';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser, useGrowiCloudUri } from '~/stores-universal/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -25,6 +26,7 @@ type Props = CommonProps;
 const DataTransferPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('commons');
   useCurrentUser(props.currentUser ?? null);
+  useGrowiCloudUri(props.growiCloudUri);
 
   const title = t('g2g_data_transfer.data_transfer');
 
@@ -54,9 +56,15 @@ const DataTransferPage: NextPage<Props> = (props) => {
   );
 };
 
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+
+  props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
+};
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const props = await retrieveServerSideProps(context);
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
   return props;
 };
 

+ 2 - 1
apps/app/src/pages/installer.page.tsx

@@ -11,7 +11,7 @@ import Head from 'next/head';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import {
-  useCsrfToken, useAppTitle, useSiteUrl, useConfidential,
+  useCsrfToken, useAppTitle, useSiteUrl, useConfidential, useGrowiCloudUri,
 } from '~/stores-universal/context';
 
 import type { CommonProps } from './utils/commons';
@@ -57,6 +57,7 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   useSiteUrl(props.siteUrl);
   useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
   const title = generateCustomTitle(props, t('installer.title'));
   const classNames: string[] = [];

+ 26 - 0
apps/app/src/server/middlewares/certify-ai-service.ts

@@ -0,0 +1,26 @@
+import type { NextFunction, Request, Response } from 'express';
+
+import { OpenaiServiceTypes } from '~/interfaces/ai';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middlewares:certify-ai-service');
+
+export const certifyAiService = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
+  const aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+  const openaiServiceType = configManager.getConfig('crowi', 'app:openaiServiceType');
+
+  if (!aiEnabled) {
+    const message = 'AI_ENABLED is not true';
+    logger.error(message);
+    return res.apiv3Err(message, 403);
+  }
+
+  if (openaiServiceType == null || !OpenaiServiceTypes.includes(openaiServiceType)) {
+    const message = 'AI_SERVICE_TYPE is missing or contains an invalid value';
+    logger.error(message);
+    return res.apiv3Err(message, 403);
+  }
+
+  next();
+};

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

@@ -8,6 +8,7 @@ import * as registerFormValidator from '../../middlewares/register-form-validato
 
 import g2gTransfer from './g2g-transfer';
 import importRoute from './import';
+import openai from './openai';
 import pageListing from './page-listing';
 import securitySettings from './security-settings';
 import * as userActivation from './user-activation';
@@ -119,5 +120,7 @@ module.exports = (crowi, app) => {
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
   router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
 
+  router.use('/openai', openai(crowi));
+
   return [router, routerForAdmin, routerForAuth];
 };

+ 77 - 0
apps/app/src/server/routes/apiv3/openai/chat.ts

@@ -0,0 +1,77 @@
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { certifyAiService } from '~/server/middlewares/certify-ai-service';
+import { configManager } from '~/server/service/config-manager';
+import { openaiClient } from '~/server/service/openai';
+import { getOrCreateChatAssistant } from '~/server/service/openai/assistant';
+import loggerFactory from '~/utils/logger';
+
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:chat');
+
+type ReqBody = {
+  userMessage: string,
+  threadId?: string,
+}
+
+type Req = Request<undefined, ApiV3Response, ReqBody>
+
+type ChatHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const chatHandlersFactory: ChatHandlersFactory = (crowi) => {
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    body('userMessage').isString().withMessage('userMessage must be string'),
+    body('threadId').optional().isString().withMessage('threadId must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const vectorStoreId = configManager.getConfig('crowi', 'app:openaiVectorStoreId');
+      if (vectorStoreId == null) {
+        return res.apiv3Err('OPENAI_VECTOR_STORE_ID is not setup', 503);
+      }
+
+      try {
+        const assistant = await getOrCreateChatAssistant();
+
+        const threadId = req.body.threadId;
+        const thread = threadId == null
+          ? await openaiClient.beta.threads.create({
+            messages: [{ role: 'assistant', content: req.body.userMessage }],
+            tool_resources: {
+              file_search: {
+                vector_store_ids: [vectorStoreId],
+              },
+            },
+          })
+          : await openaiClient.beta.threads.retrieve(threadId);
+
+        const run = await openaiClient.beta.threads.runs.createAndPoll(thread.id, { assistant_id: assistant.id });
+
+        if (run.status === 'completed') {
+          const messages = await openaiClient.beta.threads.messages.list(run.thread_id, {
+            limit: 1,
+            order: 'desc',
+          });
+          return res.apiv3({ messages });
+        }
+
+        return res.apiv3({});
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 12 - 0
apps/app/src/server/routes/apiv3/openai/index.ts

@@ -0,0 +1,12 @@
+import express from 'express';
+
+import { chatHandlersFactory } from './chat';
+import { rebuildVectorStoreHandlersFactory } from './rebuild-vector-store';
+
+const router = express.Router();
+
+module.exports = (crowi) => {
+  router.post('/chat', chatHandlersFactory(crowi));
+  router.post('/rebuild-vector-store', rebuildVectorStoreHandlersFactory(crowi));
+  return router;
+};

+ 41 - 0
apps/app/src/server/routes/apiv3/openai/rebuild-vector-store.ts

@@ -0,0 +1,41 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { certifyAiService } from '~/server/middlewares/certify-ai-service';
+import { openaiService } from '~/server/service/openai/openai';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:rebuild-vector-store');
+
+type RebuildVectorStoreFactory = (crowi: Crowi) => RequestHandler[];
+
+export const rebuildVectorStoreHandlersFactory: RebuildVectorStoreFactory = (crowi) => {
+  const accessTokenParser = require('~/server/middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    //
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
+    async(req: Request, res: ApiV3Response) => {
+
+      try {
+        await openaiService.rebuildVectorStore();
+        return res.apiv3({});
+
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Vector Store rebuild failed'));
+      }
+    },
+  ];
+};

+ 4 - 4
apps/app/src/server/routes/search.ts

@@ -109,7 +109,7 @@ module.exports = function(crowi, app) {
   api.search = async function(req, res) {
     const user = req.user;
     const {
-      q = null, nq = null, type = null, sort = null, order = null,
+      q = null, nq = null, type = null, sort = null, order = null, vector = null,
     } = req.query;
     let paginateOpts;
 
@@ -135,15 +135,15 @@ module.exports = function(crowi, app) {
     ] : null;
 
     const searchOpts = {
-      ...paginateOpts, type, sort, order,
+      ...paginateOpts, type, sort, order, vector,
     };
 
     let searchResult;
     let delegatorName;
     try {
-      const keyword = decodeURIComponent(q);
+      const query = decodeURIComponent(q);
       const nqName = nq ?? decodeURIComponent(nq);
-      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, nqName, user, userGroups, searchOpts);
+      [searchResult, delegatorName] = await searchService.searchKeyword(query, nqName, user, userGroups, searchOpts);
     }
     catch (err) {
       logger.error('Failed to search', err);

+ 48 - 0
apps/app/src/server/service/config-loader.ts

@@ -736,6 +736,54 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.NUMBER,
     default: 172800, // 2 days
   },
+  AI_ENABLED: {
+    ns: 'crowi',
+    key: 'app:aiEnabled',
+    type: ValueType.BOOLEAN,
+    default: false,
+  },
+  OPENAI_SERVICE_TYPE: {
+    ns: 'crowi',
+    key: 'app:openaiServiceType',
+    type: ValueType.STRING,
+    default: null,
+  },
+  OPENAI_API_KEY: {
+    ns: 'crowi',
+    key: 'app:openaiApiKey',
+    type: ValueType.STRING,
+    default: null,
+  },
+  OPENAI_DIMENSIONS: {
+    ns: 'crowi',
+    key: 'app:openaiDimensions',
+    type: ValueType.NUMBER,
+    default: null,
+  },
+  OPENAI_SEARCH_ASSISTANT_INSTRUCTIONS: {
+    ns: 'crowi',
+    key: 'app:openaiSearchAssistantInstructions',
+    type: ValueType.STRING,
+    default: null,
+  },
+  OPENAI_CHAT_ASSISTANT_INSTRUCTIONS: {
+    ns: 'crowi',
+    key: 'app:openaiChatAssistantInstructions',
+    type: ValueType.STRING,
+    default: null,
+  },
+  OPENAI_ASSISTANT_NAME_SUFFIX: {
+    ns: 'crowi',
+    key: 'app:openaiAssistantNameSuffix',
+    type: ValueType.STRING,
+    default: null,
+  },
+  OPENAI_VECTOR_STORE_ID: {
+    ns: 'crowi',
+    key: 'app:openaiVectorStoreId',
+    type: ValueType.STRING,
+    default: null,
+  },
 };
 
 

+ 82 - 0
apps/app/src/server/service/openai/assistant/assistant.ts

@@ -0,0 +1,82 @@
+import type OpenAI from 'openai';
+
+import { configManager } from '../../config-manager';
+import { openaiClient } from '../client';
+
+
+const AssistantType = {
+  SEARCH: 'Search',
+  CHAT: 'Chat',
+} as const;
+
+type AssistantType = typeof AssistantType[keyof typeof AssistantType];
+
+
+const findAssistantByName = async(assistantName: string): Promise<OpenAI.Beta.Assistant | undefined> => {
+
+  // declare finder
+  const findAssistant = async(assistants: OpenAI.Beta.Assistants.AssistantsPage): Promise<OpenAI.Beta.Assistant | undefined> => {
+    const found = assistants.data.find(assistant => assistant.name === assistantName);
+
+    if (found != null) {
+      return found;
+    }
+
+    // recursively find assistant
+    if (assistants.hasNextPage()) {
+      return findAssistant(await assistants.getNextPage());
+    }
+  };
+
+  const storedAssistants = await openaiClient.beta.assistants.list({ order: 'desc' });
+
+  return findAssistant(storedAssistants);
+};
+
+const getOrCreateAssistant = async(type: AssistantType): Promise<OpenAI.Beta.Assistant> => {
+  const appSiteUrl = configManager.getConfig('crowi', 'app:siteUrl');
+  const assistantName = `GROWI ${type} Assistant for ${appSiteUrl} ${configManager.getConfig('crowi', 'app:openaiAssistantNameSuffix')}}`;
+
+  const assistantOnRemote = await findAssistantByName(assistantName);
+  if (assistantOnRemote != null) {
+    return assistantOnRemote;
+  }
+
+  const newAssistant = await openaiClient.beta.assistants.create({
+    name: assistantName,
+    model: 'gpt-4o',
+  });
+
+  return newAssistant;
+};
+
+let searchAssistant: OpenAI.Beta.Assistant | undefined;
+export const getOrCreateSearchAssistant = async(): Promise<OpenAI.Beta.Assistant> => {
+  if (searchAssistant != null) {
+    return searchAssistant;
+  }
+
+  searchAssistant = await getOrCreateAssistant(AssistantType.SEARCH);
+  openaiClient.beta.assistants.update(searchAssistant.id, {
+    instructions: configManager.getConfig('crowi', 'app:openaiSearchAssistantInstructions'),
+    tools: [{ type: 'file_search' }],
+  });
+
+  return searchAssistant;
+};
+
+
+let chatAssistant: OpenAI.Beta.Assistant | undefined;
+export const getOrCreateChatAssistant = async(): Promise<OpenAI.Beta.Assistant> => {
+  if (chatAssistant != null) {
+    return chatAssistant;
+  }
+
+  chatAssistant = await getOrCreateAssistant(AssistantType.CHAT);
+  openaiClient.beta.assistants.update(chatAssistant.id, {
+    instructions: configManager.getConfig('crowi', 'app:openaiChatAssistantInstructions'),
+    tools: [{ type: 'file_search' }],
+  });
+
+  return chatAssistant;
+};

+ 1 - 0
apps/app/src/server/service/openai/assistant/index.ts

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

+ 41 - 0
apps/app/src/server/service/openai/client-delegator/azure-openai-client-delegator.ts

@@ -0,0 +1,41 @@
+import { DefaultAzureCredential, getBearerTokenProvider } from '@azure/identity';
+import type OpenAI from 'openai';
+import { AzureOpenAI } from 'openai';
+import { type Uploadable } from 'openai/uploads';
+
+import type { IOpenaiClientDelegator } from './interfaces';
+
+
+export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
+
+  private client: AzureOpenAI;
+
+  private openaiVectorStoreId: string;
+
+  constructor() {
+    // Retrieve Azure OpenAI related values from environment variables
+    const credential = new DefaultAzureCredential();
+    const scope = 'https://cognitiveservices.azure.com/.default';
+    const azureADTokenProvider = getBearerTokenProvider(credential, scope);
+    this.client = new AzureOpenAI({ azureADTokenProvider });
+
+    // TODO: initialize openaiVectorStoreId property
+  }
+
+  async getVectorStoreFiles(): Promise<OpenAI.Beta.VectorStores.Files.VectorStoreFilesPage> {
+    return this.client.beta.vectorStores.files.list(this.openaiVectorStoreId);
+  }
+
+  async deleteVectorStoreFiles(fileId: string): Promise<OpenAI.Beta.VectorStores.Files.VectorStoreFileDeleted> {
+    return this.client.beta.vectorStores.files.del(this.openaiVectorStoreId, fileId);
+  }
+
+  async deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted> {
+    return this.client.files.del(fileId);
+  }
+
+  async uploadAndPoll(files: Uploadable[]): Promise<OpenAI.Beta.VectorStores.FileBatches.VectorStoreFileBatch> {
+    return this.client.beta.vectorStores.fileBatches.uploadAndPoll(this.openaiVectorStoreId, { files });
+  }
+
+}

+ 34 - 0
apps/app/src/server/service/openai/client-delegator/get-client.ts

@@ -0,0 +1,34 @@
+import { OpenaiServiceType } from '~/interfaces/ai';
+
+import { AzureOpenaiClientDelegator } from './azure-openai-client-delegator';
+import type { IOpenaiClientDelegator } from './interfaces';
+import { OpenaiClientDelegator } from './openai-client-delegator';
+
+type GetDelegatorOptions = {
+  openaiServiceType: OpenaiServiceType;
+}
+
+type IsAny<T> = 'dummy' extends (T & 'dummy') ? true : false;
+type Delegator<Opts extends GetDelegatorOptions> =
+  IsAny<Opts> extends true
+    ? IOpenaiClientDelegator
+    : Opts extends { openaiServiceType: 'openai' }
+      ? OpenaiClientDelegator
+      : Opts extends { openaiServiceType: 'azure-openai' }
+        ? AzureOpenaiClientDelegator
+        : IOpenaiClientDelegator;
+
+let instance;
+
+export const getClient = <Opts extends GetDelegatorOptions>(opts: Opts): Delegator<Opts> => {
+  // instanciate the client based on the service type
+  if (instance == null) {
+    if (opts.openaiServiceType === OpenaiServiceType.AZURE_OPENAI) {
+      instance = new AzureOpenaiClientDelegator();
+      return instance;
+    }
+    instance = new OpenaiClientDelegator();
+  }
+
+  return instance;
+};

+ 1 - 0
apps/app/src/server/service/openai/client-delegator/index.ts

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

+ 9 - 0
apps/app/src/server/service/openai/client-delegator/interfaces.ts

@@ -0,0 +1,9 @@
+import type OpenAI from 'openai';
+import type { Uploadable } from 'openai/uploads';
+
+export interface IOpenaiClientDelegator {
+  getVectorStoreFiles(): Promise<OpenAI.Beta.VectorStores.Files.VectorStoreFilesPage>;
+  deleteVectorStoreFiles(fileId: string): Promise<OpenAI.Beta.VectorStores.Files.VectorStoreFileDeleted>;
+  deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;
+  uploadAndPoll(files: Uploadable[]): Promise<OpenAI.Beta.VectorStores.FileBatches.VectorStoreFileBatch>;
+}

+ 47 - 0
apps/app/src/server/service/openai/client-delegator/openai-client-delegator.ts

@@ -0,0 +1,47 @@
+import OpenAI from 'openai';
+import { type Uploadable } from 'openai/uploads';
+
+import { configManager } from '~/server/service/config-manager';
+
+import type { IOpenaiClientDelegator } from './interfaces';
+
+
+export class OpenaiClientDelegator implements IOpenaiClientDelegator {
+
+  private client: OpenAI;
+
+  private openaiVectorStoreId: string;
+
+  constructor() {
+    // Retrieve OpenAI related values from environment variables
+    const apiKey = configManager.getConfig('crowi', 'app:openaiApiKey');
+    const vectorStoreId = configManager.getConfig('crowi', 'app:openaiVectorStoreId');
+
+    const isValid = [apiKey, vectorStoreId].every(value => value != null);
+    if (!isValid) {
+      throw new Error("Environment variables required to use OpenAI's API are not set");
+    }
+
+    this.openaiVectorStoreId = vectorStoreId;
+
+    // initialize client
+    this.client = new OpenAI({ apiKey });
+  }
+
+  async getVectorStoreFiles(): Promise<OpenAI.Beta.VectorStores.Files.VectorStoreFilesPage> {
+    return this.client.beta.vectorStores.files.list(this.openaiVectorStoreId);
+  }
+
+  async deleteVectorStoreFiles(fileId: string): Promise<OpenAI.Beta.VectorStores.Files.VectorStoreFileDeleted> {
+    return this.client.beta.vectorStores.files.del(this.openaiVectorStoreId, fileId);
+  }
+
+  async deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted> {
+    return this.client.files.del(fileId);
+  }
+
+  async uploadAndPoll(files: Uploadable[]): Promise<OpenAI.Beta.VectorStores.FileBatches.VectorStoreFileBatch> {
+    return this.client.beta.vectorStores.fileBatches.uploadAndPoll(this.openaiVectorStoreId, { files });
+  }
+
+}

+ 7 - 0
apps/app/src/server/service/openai/client.ts

@@ -0,0 +1,7 @@
+import OpenAI from 'openai';
+
+import { configManager } from '~/server/service/config-manager';
+
+export const openaiClient = new OpenAI({
+  apiKey: configManager?.getConfig('crowi', 'app:openaiApiKey'), // This is the default and can be omitted
+});

+ 27 - 0
apps/app/src/server/service/openai/embeddings.ts

@@ -0,0 +1,27 @@
+import crypto from 'crypto';
+
+import type { OpenAI } from 'openai';
+
+import { configManager } from '~/server/service/config-manager';
+
+import { openaiClient } from './client';
+
+
+export const embed = async(input: string, username?: string): Promise<OpenAI.Embedding[]> => {
+  let user;
+
+  if (username != null) {
+    const hasher = crypto.createHash('sha256');
+    hasher.update(username);
+    user = hasher.digest('hex');
+  }
+
+  const result = await openaiClient.embeddings.create({
+    input,
+    model: 'text-embedding-3-large',
+    dimensions: configManager.getConfig('crowi', 'app:openaiDimensions'),
+    user,
+  });
+
+  return result.data;
+};

+ 32 - 0
apps/app/src/server/service/openai/file-upload.ts

@@ -0,0 +1,32 @@
+import { Readable } from 'stream';
+
+import type { IPageHasId } from '@growi/core';
+import { toFile } from 'openai';
+
+import { configManager } from '~/server/service/config-manager';
+
+import { openaiClient } from './client';
+
+type PageToUpload = Omit<IPageHasId, 'revision'> & { revision: { body: string } };
+
+export const fileUpload = async(pages: PageToUpload[]): Promise<void> => {
+  const vectorStoreId = configManager.getConfig('crowi', 'app:openaiVectorStoreId');
+  if (vectorStoreId == null) {
+    return;
+  }
+
+  const filesPromise = pages
+    .filter(pages => pages.revision.body.length > 0)
+    .map(async(page) => {
+      const file = await toFile(Readable.from(page.revision.body), `${page._id}.md`);
+      return file;
+    });
+
+  if (filesPromise.length === 0) {
+    return;
+  }
+
+  const files = await Promise.all(filesPromise);
+
+  await openaiClient.beta.vectorStores.fileBatches.uploadAndPoll(vectorStoreId, { files });
+};

+ 3 - 0
apps/app/src/server/service/openai/index.ts

@@ -0,0 +1,3 @@
+export * from './embeddings';
+export * from './file-upload';
+export * from './client';

+ 54 - 0
apps/app/src/server/service/openai/openai.ts

@@ -0,0 +1,54 @@
+import { Readable } from 'stream';
+
+import { PageGrant } from '@growi/core';
+import type { HydratedDocument } from 'mongoose';
+import mongoose from 'mongoose';
+import { toFile } from 'openai';
+
+import type { PageDocument, PageModel } from '~/server/models/page';
+import { configManager } from '~/server/service/config-manager';
+
+import { getClient } from './client-delegator';
+
+export interface IOpenaiService {
+  rebuildVectorStore(): Promise<void>;
+}
+class OpenaiService implements IOpenaiService {
+
+  constructor() {
+    const aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+    if (!aiEnabled) {
+      return;
+    }
+  }
+
+  private get client() {
+    const openaiServiceType = configManager.getConfig('crowi', 'app:openaiServiceType');
+    return getClient({ openaiServiceType });
+  }
+
+  async rebuildVectorStore() {
+    // TODO: https://redmine.weseek.co.jp/issues/154364
+
+    // Create all public pages VectorStoreFile
+    const page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    const allPublicPages = await page.find({ grant: PageGrant.GRANT_PUBLIC }).populate('revision');
+
+    const filesPromise = allPublicPages
+      .filter(page => page.revision?.body != null && page.revision.body.length > 0)
+      .map(async(page) => {
+        const file = await toFile(Readable.from(page.revision.body), `${page._id}.md`);
+        return file;
+      });
+
+    if (filesPromise.length === 0) {
+      return;
+    }
+
+    const files = await Promise.all(filesPromise);
+    await this.client.uploadAndPoll(files);
+  }
+
+}
+
+export const openaiService = new OpenaiService();

+ 3 - 1
apps/app/src/server/service/search-delegator/bulk-write.d.ts

@@ -15,12 +15,13 @@ export type AggregatedPage = Pick<IPageHasId,
   bookmarksCount: number,
   likeCount: number,
   seenUsersCount: number,
-  creator: {
+  creator?: {
     username: string,
     email: string,
   },
 } & {
   tagNames: string[],
+  revisionBodyEmbedded?: number[],
 };
 
 export type BulkWriteCommand = {
@@ -42,6 +43,7 @@ export type BulkWriteBody = {
   created_at: Date;
   updated_at: Date;
   body: string;
+  body_embedded?: number[];
   username?: string;
   comments?: string[];
   comment_count: number;

+ 73 - 4
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -20,7 +20,8 @@ import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
 import { configManager } from '../config-manager';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
-
+// // import { embed, openaiClient, fileUpload } from '../openai';
+// import { getOrCreateSearchAssistant } from '../openai/assistant';
 
 import { aggregatePipelineToIndex } from './aggregate-to-index';
 import type { AggregatedPage, BulkWriteBody, BulkWriteCommand } from './bulk-write';
@@ -380,6 +381,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const document: BulkWriteBody = {
       path: page.path,
       body: page.revision.body,
+      body_embedded: page.revisionBodyEmbedded,
       username: page.creator?.username,
       comments: page.commentsCount > 0 ? page.comments : undefined,
       comment_count: page.commentsCount,
@@ -479,6 +481,28 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       },
     });
 
+    // const appendEmbeddingStream = new Transform({
+    //   objectMode: true,
+    //   async transform(chunk: AggregatedPage[], encoding, callback) {
+    //     // append embedding
+    //     for await (const doc of chunk) {
+    //       doc.revisionBodyEmbedded = (await embed(doc.revision.body, doc.creator?.username))[0].embedding;
+    //     }
+
+    //     this.push(chunk);
+    //     callback();
+    //   },
+    // });
+
+    // const appendFileUploadedStream = new Transform({
+    //   objectMode: true,
+    //   async transform(chunk, encoding, callback) {
+    //     await fileUpload(chunk);
+    //     this.push(chunk);
+    //     callback();
+    //   },
+    // });
+
     let count = 0;
     const writeStream = new Writable({
       objectMode: true,
@@ -532,6 +556,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     readStream
       .pipe(batchStream)
       .pipe(appendTagNamesStream)
+      // .pipe(appendEmbeddingStream)
+      // .pipe(appendFileUploadedStream)
       .pipe(writeStream);
 
     return streamToPromise(writeStream);
@@ -832,6 +858,42 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     };
   }
 
+  // async appendVectorScore(query, queryString: string, username?: string): Promise<void> {
+
+  //   const searchAssistant = await getOrCreateSearchAssistant();
+
+  //   // generate keywords for vector
+  //   const run = await openaiClient.beta.threads.createAndRunPoll({
+  //     assistant_id: searchAssistant.id,
+  //     thread: {
+  //       messages: [
+  //         { role: 'user', content: 'globalLang: "en_US", userLang: "ja_JP", user_input: "武井さんがジョインしたのはいつですか?"' },
+  //         { role: 'assistant', content: '武井さん 武井 takei yuki ジョイン join 入社 加入 雇用開始 年月日 start date join employee' },
+  //         { role: 'user', content: `globalLang: "en_US", userLang: "ja_JP", user_input: "${queryString}"` },
+  //       ],
+  //     },
+  //   });
+  //   const messages = await openaiClient.beta.threads.messages.list(run.thread_id, {
+  //     limit: 1,
+  //   });
+  //   const content = messages.data[0].content[0];
+  //   const keywordsForVector = content.type === 'text' ? content.text.value : queryString;
+
+  //   logger.debug('keywordsFor: ', keywordsForVector);
+
+  //   const queryVector = (await embed(queryString, username))[0].embedding;
+
+  //   query.body.query = {
+  //     script_score: {
+  //       query: { ...query.body.query },
+  //       script: {
+  //         source: "cosineSimilarity(params.query_vector, 'body_embedded') + 1.0",
+  //         params: { query_vector: queryVector },
+  //       },
+  //     },
+  //   };
+  // }
+
   appendHighlight(query) {
     query.body.highlight = {
       fragmenter: 'simple',
@@ -862,15 +924,22 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const size = option?.limit ?? null;
     const sort = option?.sort ?? null;
     const order = option?.order ?? null;
+
     const query = this.createSearchQuery();
-    this.appendCriteriaForQueryString(query, terms);
 
-    await this.filterPagesByViewer(query, user, userGroups);
+    if (option?.vector) {
+      // await this.filterPagesByViewer(query, user, userGroups);
+      // await this.appendVectorScore(query, queryString, user?.username);
+    }
+    else {
+      this.appendCriteriaForQueryString(query, terms);
+      await this.filterPagesByViewer(query, user, userGroups);
+      await this.appendFunctionScore(query, queryString);
+    }
 
     this.appendResultSize(query, from, size);
 
     this.appendSortOrder(query, sort, order);
-    await this.appendFunctionScore(query, queryString);
 
     this.appendHighlight(query);
 

+ 7 - 1
apps/app/src/server/service/search.ts

@@ -4,6 +4,7 @@ import mongoose from 'mongoose';
 import { FilterXSS } from 'xss';
 
 import { CommentEvent, commentEvent } from '~/features/comment/server';
+import { isIncludeAiMenthion, removeAiMenthion } from '~/features/search/utils/ai';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
@@ -39,7 +40,8 @@ const filterXss = new FilterXSS(filterXssOptions);
 
 const normalizeQueryString = (_queryString: string): string => {
   let queryString = _queryString.trim();
-  queryString = queryString.replace(/\s+/g, ' ');
+  queryString = removeAiMenthion(queryString)
+    .replace(/\s+/g, ' ');
 
   return queryString;
 };
@@ -300,6 +302,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
       throw err;
     }
 
+    if (isIncludeAiMenthion(keyword)) {
+      searchOpts.vector = true;
+    }
+
     let delegator: SearchDelegator;
     let data: SearchableData;
     // resolve

+ 14 - 11
apps/app/src/stores-universal/context.tsx

@@ -1,5 +1,3 @@
-import { useCallback, useEffect } from 'react';
-
 import type EventEmitter from 'events';
 
 import { AcceptedUploadFileType } from '@growi/core';
@@ -286,17 +284,22 @@ export const useAcceptedUploadFileType = (): SWRResponse<AcceptedUploadFileType,
   );
 };
 
-export const useGrowiDocumentationUrl = (): SWRResponse<'growi.cloud/help' | 'docs.growi.org', Error> => {
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useGrowiDocumentationUrl = () => {
   const { data: growiCloudUri } = useGrowiCloudUri();
-  const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
 
-  return useSWRImmutable(
-    ['documentationUrl', growiCloudUri, growiAppIdForGrowiCloud],
-    ([growiCloudUri, growiAppIdForGrowiCloud]) => {
-      if (growiCloudUri != null && growiAppIdForGrowiCloud != null) {
-        return 'growi.cloud/help';
-      }
-      return 'docs.growi.org';
+  return useSWR(
+    ['documentationUrl', growiCloudUri],
+    ([, growiCloudUri]) => {
+      const url = growiCloudUri != null
+        ? new URL('/help', growiCloudUri)
+        : new URL('https://docs.growi.org');
+      return url.toString();
+    },
+    {
+      fallbackData: 'https://docs.growi.org',
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
     },
   );
 };

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

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

+ 4 - 2
apps/app/src/stores/search.tsx

@@ -1,7 +1,9 @@
-import useSWR, { mutate, SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWR, { mutate } from 'swr';
 
 import { apiGet } from '~/client/util/apiv1-client';
-import { IFormattedSearchResult, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
+import type { IFormattedSearchResult } from '~/interfaces/search';
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 
 
 export type ISearchConfigurations = {

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.20-slackbot-proxy.0",
+  "version": "7.0.21-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.0.20-RC.0",
+  "version": "7.0.21-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",
@@ -57,6 +57,7 @@
   "devDependencies": {
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.3",
+    "@faker-js/faker": "^9.0.1",
     "@playwright/test": "^1.46.0",
     "@swc-node/register": "^1.9.1",
     "@swc/core": "^1.5.25",

+ 5 - 0
packages/core-styles/scss/bootstrap/theming/_dropdown-dark.scss

@@ -0,0 +1,5 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+.dropdown-item:active{
+  --#{$prefix}dropdown-link-active-bg: var(--#{$prefix}gray-700);
+}

+ 6 - 0
packages/core-styles/scss/bootstrap/theming/_dropdown-light.scss

@@ -0,0 +1,6 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+.dropdown-item:active{
+  --#{$prefix}dropdown-link-active-color: var(----#{$prefix}gray-600);
+  --#{$prefix}dropdown-link-active-bg: var(----#{$prefix}gray-200);
+}

+ 1 - 0
packages/core-styles/scss/bootstrap/theming/apply-dark.scss

@@ -5,3 +5,4 @@
 @import './pagination';
 @import './progress';
 @import './list-group';
+@import './dropdown-dark';

+ 1 - 0
packages/core-styles/scss/bootstrap/theming/apply-light.scss

@@ -5,3 +5,4 @@
 @import './pagination';
 @import './progress';
 @import './list-group';
+@import './dropdown-light';

Разница между файлами не показана из-за своего большого размера
+ 318 - 316
yarn.lock


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