فهرست منبع

Merge remote-tracking branch 'origin/feat/growi-ai-next' into feat/unified-merge-view

Yuki Takei 1 سال پیش
والد
کامیت
440722b1ab
79فایلهای تغییر یافته به همراه1610 افزوده شده و 1555 حذف شده
  1. 4 8
      apps/app/public/static/locales/en_US/admin.json
  2. 4 1
      apps/app/public/static/locales/en_US/translation.json
  3. 4 8
      apps/app/public/static/locales/fr_FR/admin.json
  4. 4 1
      apps/app/public/static/locales/fr_FR/translation.json
  5. 4 9
      apps/app/public/static/locales/ja_JP/admin.json
  6. 4 1
      apps/app/public/static/locales/ja_JP/translation.json
  7. 4 8
      apps/app/public/static/locales/zh_CN/admin.json
  8. 7 4
      apps/app/public/static/locales/zh_CN/translation.json
  9. 14 0
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx
  10. 1 1
      apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss
  11. 9 9
      apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx
  12. 0 5
      apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.module.scss
  13. 0 45
      apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.tsx
  14. 2 2
      apps/app/src/client/components/PageControls/PageControls.tsx
  15. 15 1
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  16. 11 0
      apps/app/src/client/services/AdminCustomizeContainer.js
  17. 18 15
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  18. 4 0
      apps/app/src/components/PageView/PageViewLayout.module.scss
  19. 6 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx
  20. 11 5
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  21. 0 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  22. 84 16
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  23. 39 14
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  24. 8 8
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx
  25. 12 6
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  26. 2 2
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.module.scss
  27. 52 0
      apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx
  28. 2 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  29. 59 23
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  30. 0 31
      apps/app/src/features/openai/client/components/AiIntegration/AiIntegration.tsx
  31. 0 36
      apps/app/src/features/openai/client/components/RagSearchButton.tsx
  32. 4 0
      apps/app/src/features/openai/client/services/ai-assistant.ts
  33. 0 26
      apps/app/src/features/openai/client/stores/rag-search.ts
  34. 3 2
      apps/app/src/features/openai/interfaces/ai-assistant.ts
  35. 19 13
      apps/app/src/features/openai/server/models/ai-assistant.ts
  36. 6 0
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  37. 4 4
      apps/app/src/features/openai/server/routes/index.ts
  38. 8 0
      apps/app/src/features/openai/server/routes/message.ts
  39. 10 4
      apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts
  40. 30 11
      apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts
  41. 4 8
      apps/app/src/features/openai/server/routes/thread.ts
  42. 6 0
      apps/app/src/features/openai/server/routes/update-ai-assistant.ts
  43. 0 573
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.spec.ts
  44. 0 133
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-splitter.ts
  45. 0 134
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.spec.ts
  46. 0 188
      apps/app/src/features/openai/server/services/markdown-splitter/markdown-token-splitter.ts
  47. 85 119
      apps/app/src/features/openai/server/services/openai.ts
  48. 6 0
      apps/app/src/features/openai/utils/determine-share-scope.ts
  49. 8 0
      apps/app/src/features/openai/utils/remove-glob-path.ts
  50. 0 5
      apps/app/src/features/rate-limiter/config/index.ts
  51. 5 0
      apps/app/src/interfaces/page.ts
  52. 8 2
      apps/app/src/pages/[[...path]].page.tsx
  53. 5 1
      apps/app/src/pages/share/[[...path]].page.tsx
  54. 53 3
      apps/app/src/server/models/page.ts
  55. 4 0
      apps/app/src/server/routes/apiv3/customize-setting.js
  56. 76 0
      apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts
  57. 293 8
      apps/app/src/server/routes/apiv3/page/index.ts
  58. 232 7
      apps/app/src/server/routes/apiv3/pages/index.js
  59. 26 4
      apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  60. 9 0
      apps/app/src/server/service/config-manager/config-definition.ts
  61. 26 0
      apps/app/src/server/service/normalize-data/delete-legacy-knowledge-assistant-vector-store.ts
  62. 2 0
      apps/app/src/server/service/normalize-data/index.ts
  63. 8 0
      apps/app/src/stores-universal/context.tsx
  64. 0 1
      apps/app/src/stores/page-listing.tsx
  65. 15 0
      apps/app/src/stores/page.tsx
  66. 0 30
      apps/app/src/utils/is-deep-equal.ts
  67. 3 2
      bin/data-migrations/README.md
  68. 2 1
      bin/data-migrations/src/migrations/v60x/index.js
  69. 25 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/README.ja.md
  70. 43 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/example-expected.md
  71. 37 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/example.md
  72. 1 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/index.js
  73. 65 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.js
  74. 43 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.spec.js
  75. 9 0
      bin/vitest.config.ts
  76. 40 13
      packages/core/src/utils/is-deep-equals.ts
  77. 1 1
      packages/preset-themes/src/styles/classic.scss
  78. 1 1
      packages/preset-themes/src/styles/default.scss
  79. 1 0
      vitest.workspace.mts

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

@@ -493,7 +493,9 @@
       "show_all_reply_comments": "Show all reply comments",
       "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted.",
       "select_search_scope_children_as_default": "Select 'Only children of this tree' as default value of search range",
-      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range."
+      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range.",
+      "show_page_side_authors": "Always display creators and updaters above the table of contents",
+      "show_page_side_authors_desc": "Displays information about the creator and the last updater above the table of contents in the page sidebar."
     },
       "presentation": "Presentation",
     "presentation_options": {
@@ -1139,12 +1141,6 @@
   "ai_integration": {
     "ai_integration": "AI Integration",
     "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, configure the <code>AI_ENABLED</code> environment variable along with the required additional variables.<br><br>For details, please refer to the <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
-    "ai_search_management": "AI search management",
-    "rebuild_vector_store": "Rebuild Vector Store",
-    "rebuild_vector_store_label": "Rebuild",
-    "rebuild_vector_store_explanation1": "Delete the existing Vector Store and recreate the Vector Store on the public page.",
-    "rebuild_vector_store_explanation2": "This process may take several minutes.",
-    "rebuild_vector_store_requested": "Vector Store rebuild has been requested",
-    "rebuild_vector_store_failed": "Vector Store rebuild failed"
+    "ai_search_management": "AI search management"
   }
 }

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

@@ -186,7 +186,9 @@
   },
   "author_info": {
     "created_at": "Created at",
-    "last_revision_posted_at": "Last revision posted at"
+    "created_by": "Created by",
+    "last_revision_posted_at": "Last revision posted at",
+    "updated_by": "Updated by"
   },
   "installer": {
     "tab": "Create account",
@@ -505,6 +507,7 @@
     "show_error_detail": "Show error details"
   },
   "modal_ai_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.",
     "page_mode_title": {
       "share": "Assistant Sharing",

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

@@ -493,7 +493,9 @@
       "show_all_reply_comments": "Afficher tout les commentaires",
       "show_all_reply_comments_desc": "Lorsque désactivé, seul les deux commentaires les plus récents sont affichés",
       "select_search_scope_children_as_default": "'Seulement enfant de ce chemin' lors de la recherche",
-      "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche."
+      "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche.",
+      "show_page_side_authors": "Toujours afficher les créateurs et les modificateurs au-dessus de la table des matières",
+      "show_page_side_authors_desc": "Affiche les informations sur le créateur et le dernier modificateur au-dessus de la table des matières dans la barre latérale de la page."
     },
     "presentation": "Présentation",
     "presentation_options": {
@@ -1138,12 +1140,6 @@
   "ai_integration": {
     "ai_integration": "Intégration de l'IA",
     "disable_mode_explanation": "Actuellement, l'intégration AI est désactivée. Pour l'activer, configurez la variable d'environnement <code>AI_ENABLED</code> ainsi que les autres variables nécessaires.<br><br>Pour plus de détails, veuillez consulter la <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
-    "ai_search_management": "Gestion de la recherche par l'IA",
-    "rebuild_vector_store": "Reconstruire le magasin Vector",
-    "rebuild_vector_store_label": "Reconstruire",
-    "rebuild_vector_store_explanation1": "Supprimez le Vector Store existant et recréez le Vector Store sur la page publique.",
-    "rebuild_vector_store_explanation2": "Ce processus peut prendre plusieurs minutes.",
-    "rebuild_vector_store_requested": "La reconstruction du magasin Vector a été demandée",
-    "rebuild_vector_store_failed": "Échec de la reconstruction du magasin de vecteurs"
+    "ai_search_management": "Gestion de la recherche par l'IA"
   }
 }

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

@@ -187,7 +187,9 @@
   },
   "author_info": {
     "created_at": "Crée le",
-    "last_revision_posted_at": "Dernière révision le"
+    "created_by": "Créé par",
+    "last_revision_posted_at": "Dernière révision le",
+    "updated_by": "Mis à jour par"
   },
   "installer": {
     "tab": "Créer compte",
@@ -500,6 +502,7 @@
     "show_error_detail": "Détails de l'exposition"
   },
   "modal_ai_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.",
     "page_mode_title": {
       "share": "Partage de l'assistant",

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

@@ -502,8 +502,9 @@
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。",
       "select_search_scope_children_as_default": "検索範囲のデフォルト設定を「この階層下の子ページ」にする",
-      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。"
-
+      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。",
+      "show_page_side_authors": "作成者・更新者を目次上部に常時表示する",
+      "show_page_side_authors_desc": "ページサイドバーの目次上部に作成者と最終更新者の情報を表示します。"
     },
     "presentation":"プレゼンテーション",
     "presentation_options":{
@@ -1149,12 +1150,6 @@
   "ai_integration": {
     "ai_integration": "AI 連携",
     "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> の他、必要な環境変数を設定してください。<br><br>詳細は<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}ja/guide/features/ai-knowledge-assistant.html>ドキュメント</a>を参照してください。",
-    "ai_search_management": "AI 検索管理",
-    "rebuild_vector_store": "Vector Store のリビルド",
-    "rebuild_vector_store_label": "リビルド",
-    "rebuild_vector_store_explanation1": "既存の Vector Store を削除し、公開ページの Vector Store を再作成します。",
-    "rebuild_vector_store_explanation2": "この作業には数分かかる可能性があります。",
-    "rebuild_vector_store_requested": "Vector Store のリビルドを受け付けました",
-    "rebuild_vector_store_failed": "Vector Store のリビルドに失敗しました"
+    "ai_search_management": "AI 検索管理"
   }
 }

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

@@ -187,7 +187,9 @@
   },
   "author_info": {
     "created_at": "作成日",
-    "last_revision_posted_at": "最終更新日"
+    "created_by": "作成者:",
+    "last_revision_posted_at": "最終更新日",
+    "updated_by": "最終更新者:"
   },
   "installer": {
     "tab": "アカウント作成",
@@ -539,6 +541,7 @@
   },
   "modal_ai_assistant": {
     "default_instruction": "あなたはこのWikiの知識アシスタントです。以下の方針で支援を行ってください:\n\n- 文書の関連性分析と情報の関連付け\n- 新しい視点の提案\n- 質問の意図を理解した的確な情報提供 必要に応じて構造化された形式で情報を提供します。",
+    "edit_page_description": " アシスタントが参照するページを編集します。<br> 参照できるページは配下ページも含めて {{limitLearnablePageCountPerAssistant}} ページまでです。",
     "page_mode_title": {
       "share": "アシスタントの共有",
       "pages": "参照ページ",

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

@@ -502,7 +502,9 @@
       "show_all_reply_comments": "显示所有回复评论",
       "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。",
       "select_search_scope_children_as_default": "选择“当前分支以下内容”, 作为搜索范围的默认值",
-      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。"
+      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。",
+      "show_page_side_authors": "在目录上方始终显示创建者和更新者",
+      "show_page_side_authors_desc": "在页面侧边栏的目录上方显示创建者和最后更新者的信息。"
     },
       "presentation": "表达",
       "presentation_options": {
@@ -1148,12 +1150,6 @@
   "ai_integration": {
     "ai_integration": "AI 集成",
     "disable_mode_explanation": "目前,AI 集成已被禁用。若要启用,请配置 <code>AI_ENABLED</code> 环境变量以及其他必要的变量。<br><br>详细信息请参考<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>文档</a>。",
-    "ai_search_management": "AI 搜索管理",
-    "rebuild_vector_store": "重建矢量商店",
-    "rebuild_vector_store_label": "重建",
-    "rebuild_vector_store_explanation1": "删除现有的矢量存储,在公共页面上重新创建矢量存储。",
-    "rebuild_vector_store_explanation2": "这个过程可能需要几分钟。",
-    "rebuild_vector_store_requested": "已要求重建矢量存储库",
-    "rebuild_vector_store_failed": "向量存储区重建失败"
+    "ai_search_management": "AI 搜索管理"
   }
 }

+ 7 - 4
apps/app/public/static/locales/zh_CN/translation.json

@@ -191,10 +191,12 @@
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
   },
-  "author_info": {
-    "created_at": "Created at",
-    "last_revision_posted_at": "Last revision posted at"
-  },
+"author_info": {
+  "created_at": "创建日期",
+  "created_by": "创建者:",
+  "last_revision_posted_at": "最后更新日期",
+  "updated_by": "更新者:"
+},
   "installer": {
     "tab": "创建账户",
     "title": "安装",
@@ -495,6 +497,7 @@
     "show_error_detail": "显示详情"
   },
   "modal_ai_assistant": {
+    "edit_page_description": "编辑助手可以参考的页面。<br> 助手可以参考最多 {{limitLearnablePageCountPerAssistant}} 个页面,包括子页面。",
     "default_instruction": "您是这个Wiki的知识助手。请按照以下方针提供支持:\n\n- 分析文档相关性并连接信息\n- 提出新的观点\n- 理解问题意图并提供准确信息\n必要时我会以结构化的形式提供信息。",
     "page_mode_title": {
       "share": "助理共享",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 6 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx

@@ -125,11 +125,16 @@ const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceP
     let currentThreadId_ = currentThreadId;
     if (currentThreadId_ == null) {
       try {
-        const res = await apiv3Post<IThreadRelationHasId>('/openai/thread', { aiAssistantId: aiAssistantData._id, initialUserMessage: newUserMessage.content });
+        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

+ 11 - 5
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx

@@ -1,14 +1,16 @@
 import React, { useCallback } from 'react';
 
+import { useTranslation } from 'react-i18next';
 import { ModalBody } from 'reactstrap';
 
 import type { IPageForItem } from '~/interfaces/page';
+import { useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
 import { usePageSelectModal } from '~/stores/modal';
 
 import type { SelectedPage } from '../../../../interfaces/selected-page';
-import { SelectedPageList } from '../../Common/SelectedPageList';
 
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectedPageList } from './SelectedPageList';
 
 
 type Props = {
@@ -18,6 +20,9 @@ type Props = {
 }
 
 export const AiAssistantManagementEditPages = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: limitLearnablePageCountPerAssistant } = useLimitLearnablePageCountPerAssistant();
+
   const { selectedPages, onSelect, onRemove } = props;
 
   const { open: openPageSelectModal } = usePageSelectModal();
@@ -31,10 +36,11 @@ export const AiAssistantManagementEditPages = (props: Props): JSX.Element => {
       <AiAssistantManagementHeader />
 
       <ModalBody className="px-4">
-        <p className="text-secondary py-1">
-          アシスタントが参照するページを編集します。<br />
-          参照できるページは配下ページも含めて200ページまでです。
-        </p>
+        <p
+          className="text-secondary py-1"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('modal_ai_assistant.edit_page_description', { limitLearnablePageCountPerAssistant }) }}
+        />
 
         <button
           type="button"

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

@@ -106,7 +106,6 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
             id="shareAssistantSwitch"
             className="form-check-input"
             checked={isShared}
-            defaultChecked={isShared}
             onChange={changeShareToggleHandler}
           />
           <Label className="form-check-label" for="shareAssistantSwitch">

+ 84 - 16
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx

@@ -1,13 +1,16 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useState, useMemo } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
   ModalHeader, ModalBody, ModalFooter, Input,
 } from 'reactstrap';
 
-import { AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
-import { useCurrentUser } from '~/stores-universal/context';
+import { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant';
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import { useCurrentUser, useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
 
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
 
 import { ShareScopeWarningModal } from './ShareScopeWarningModal';
@@ -17,10 +20,14 @@ type Props = {
   name: string;
   description: string;
   instruction: string;
-  shareScope: AiAssistantShareScope
+  shareScope: AiAssistantShareScope,
+  accessScope: AiAssistantAccessScope,
+  selectedPages: SelectedPage[];
+  selectedUserGroupsForAccessScope: PopulatedGrantedGroup[],
+  selectedUserGroupsForShareScope: PopulatedGrantedGroup[],
   onNameChange: (value: string) => void;
   onDescriptionChange: (value: string) => void;
-  onCreateAiAssistant: () => Promise<void>
+  onUpsertAiAssistant: () => Promise<void>
 }
 
 export const AiAssistantManagementHome = (props: Props): JSX.Element => {
@@ -30,17 +37,32 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     description,
     instruction,
     shareScope,
+    accessScope,
+    selectedPages,
+    selectedUserGroupsForAccessScope,
+    selectedUserGroupsForShareScope,
     onNameChange,
     onDescriptionChange,
-    onCreateAiAssistant,
+    onUpsertAiAssistant,
   } = props;
 
   const { t } = useTranslation();
   const { data: currentUser } = useCurrentUser();
+  const { data: limitLearnablePageCountPerAssistant } = useLimitLearnablePageCountPerAssistant();
   const { close: closeAiAssistantManagementModal, changePageMode } = useAiAssistantManagementModal();
 
   const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] = useState(false);
 
+  const totalSelectedPageCount = useMemo(() => {
+    return selectedPages.reduce((total, selectedPage) => {
+      const descendantCount = selectedPage.isIncludeSubPage
+        ? selectedPage.page.descendantCount ?? 0
+        : 0;
+      const pageCountWithDescendants = descendantCount + 1;
+      return total + pageCountWithDescendants;
+    }, 0);
+  }, [selectedPages]);
+
   const getShareScopeLabel = useCallback((shareScope: AiAssistantShareScope) => {
     const baseLabel = `modal_ai_assistant.share_scope.${shareScope}.label`;
     return shareScope === AiAssistantShareScope.OWNER
@@ -48,16 +70,47 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
       : t(baseLabel);
   }, [currentUser?.username, t]);
 
-  const createAiAssistantHandler = useCallback(async() => {
-    // TODO: Implement the logic to check if the assistant has a share scope that includes private pages
-    // task: https://redmine.weseek.co.jp/issues/161341
-    if (true) {
+  const canUpsert = name !== '' && selectedPages.length !== 0 && (limitLearnablePageCountPerAssistant ?? 3000) >= totalSelectedPageCount;
+
+  const upsertAiAssistantHandler = useCallback(async() => {
+    const shouldWarning = () => {
+      const isDifferentUserGroup = () => {
+        const selectedShareScopeUserGroupIds = selectedUserGroupsForShareScope.map(userGroup => userGroup.item._id);
+        const selectedAccessScopeUserGroupIds = selectedUserGroupsForAccessScope.map(userGroup => userGroup.item._id);
+        if (selectedShareScopeUserGroupIds.length !== selectedAccessScopeUserGroupIds.length) {
+          return false;
+        }
+        return selectedShareScopeUserGroupIds.every((val, index) => val === selectedAccessScopeUserGroupIds[index]);
+      };
+
+      const determinedShareScope = determineShareScope(shareScope, accessScope);
+
+      if (determinedShareScope === AiAssistantShareScope.PUBLIC_ONLY && accessScope !== AiAssistantAccessScope.PUBLIC_ONLY) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.OWNER && accessScope !== AiAssistantAccessScope.OWNER) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.GROUPS && accessScope === AiAssistantAccessScope.OWNER) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.GROUPS && accessScope === AiAssistantAccessScope.GROUPS && !isDifferentUserGroup()) {
+        return true;
+      }
+
+      return false;
+    };
+
+    if (shouldWarning()) {
       setIsShareScopeWarningModalOpen(true);
       return;
     }
 
-    await onCreateAiAssistant();
-  }, [onCreateAiAssistant]);
+    await onUpsertAiAssistant();
+  }, [accessScope, onUpsertAiAssistant, selectedUserGroupsForAccessScope, selectedUserGroupsForShareScope, shareScope]);
 
   return (
     <>
@@ -116,7 +169,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
             >
               <span className="fw-normal">{t('modal_ai_assistant.page_mode_title.pages')}</span>
               <div className="d-flex align-items-center text-secondary">
-                <span>3ページ</span>
+                <span>{`${totalSelectedPageCount} ページ`}</span>
                 <span className="material-symbols-outlined ms-2 align-middle">chevron_right</span>
               </div>
             </button>
@@ -138,15 +191,30 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
         </ModalBody>
 
         <ModalFooter>
-          <button type="button" className="btn btn-outline-secondary" onClick={closeAiAssistantManagementModal}>キャンセル</button>
-          <button type="button" className="btn btn-primary" onClick={createAiAssistantHandler}>{t(shouldEdit ? 'アシスタントを更新する' : 'アシスタントを作成する')}</button>
+          <button
+            type="button"
+            className="btn btn-outline-secondary"
+            onClick={closeAiAssistantManagementModal}
+          >
+            キャンセル
+          </button>
+
+          <button
+            type="button"
+            disabled={!canUpsert}
+            className="btn btn-primary"
+            onClick={upsertAiAssistantHandler}
+          >
+            {t(shouldEdit ? 'アシスタントを更新する' : 'アシスタントを作成する')}
+          </button>
         </ModalFooter>
       </div>
 
       <ShareScopeWarningModal
         isOpen={isShareScopeWarningModalOpen}
+        selectedPages={selectedPages}
         closeModal={() => setIsShareScopeWarningModalOpen(false)}
-        onSubmit={onCreateAiAssistant}
+        onSubmit={onUpsertAiAssistant}
       />
     </>
   );

+ 39 - 14
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -1,16 +1,22 @@
-import React, { useCallback, useState, useEffect } from 'react';
+import React, {
+  useCallback, useState, useEffect,
+} from 'react';
 
-import { type IGrantedGroup, isPopulated } from '@growi/core';
+import {
+  type IGrantedGroup, isPopulated,
+} from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
-import type { IPageForItem } from '~/interfaces/page';
+import type { IPagePathWithDescendantCount, IPageForItem } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import { useSWRxPagePathsWithDescendantCount } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import type { SelectedPage } from '../../../../interfaces/selected-page';
+import { removeGlobPath } from '../../../../utils/remove-glob-path';
 import { createAiAssistant, updateAiAssistant } from '../../../services/ai-assistant';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode, useSWRxAiAssistants } from '../../../stores/ai-assistant';
 
@@ -39,13 +45,13 @@ const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): Popul
   return populatedGrantedGroups;
 };
 
-// string[] -> SelectedPage[]
-const convertToSelectedPages = (pagePathPatterns: string[]): SelectedPage[] => {
+const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectedPage[] => {
   return pagePathPatterns.map((pagePathPattern) => {
     const isIncludeSubPage = pagePathPattern.endsWith('/*');
     const path = isIncludeSubPage ? pagePathPattern.slice(0, -2) : pagePathPattern;
+    const page = pagePathsWithDescendantCount.find(page => page.path === path);
     return {
-      page: { path },
+      page: page ?? { path },
       isIncludeSubPage,
     };
   });
@@ -56,11 +62,18 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const { t } = useTranslation();
   const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
   const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
+  const { data: pagePathsWithDescendantCount } = useSWRxPagePathsWithDescendantCount(
+    removeGlobPath(aiAssistantManagementModalData?.aiAssistantData?.pagePathPatterns) ?? null,
+    undefined,
+    true,
+    true,
+  );
 
   const aiAssistant = aiAssistantManagementModalData?.aiAssistantData;
   const shouldEdit = aiAssistant != null;
   const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
 
+
   // States
   const [name, setName] = useState<string>('');
   const [description, setDescription] = useState<string>('');
@@ -71,13 +84,13 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
   const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
 
+
   // Effects
   useEffect(() => {
     if (shouldEdit) {
       setName(aiAssistant.name);
       setDescription(aiAssistant.description);
       setInstruction(aiAssistant.additionalInstruction);
-      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns));
       setSelectedShareScope(aiAssistant.shareScope);
       setSelectedAccessScope(aiAssistant.accessScope);
       setSelectedUserGroupsForShareScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForShareScope ?? []));
@@ -86,6 +99,13 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   // eslint-disable-next-line max-len
   }, [aiAssistant?.accessScope, aiAssistant?.additionalInstruction, aiAssistant?.description, aiAssistant?.grantedGroupsForAccessScope, aiAssistant?.grantedGroupsForShareScope, aiAssistant?.name, aiAssistant?.pagePathPatterns, aiAssistant?.shareScope, shouldEdit]);
 
+  useEffect(() => {
+    if (shouldEdit && pagePathsWithDescendantCount != null) {
+      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns, pagePathsWithDescendantCount));
+    }
+  }, [aiAssistant?.pagePathPatterns, pagePathsWithDescendantCount, shouldEdit]);
+
+
   /*
   *  For AiAssistantManagementHome methods
   */
@@ -97,7 +117,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
     setDescription(value);
   }, []);
 
-  const createAiAssistantHandler = useCallback(async() => {
+  const upsertAiAssistantHandler = useCallback(async() => {
     try {
       const pagePathPatterns = selectedPages
         .map(selectedPage => (selectedPage.isIncludeSubPage ? `${selectedPage.page.path}/*` : selectedPage.page.path))
@@ -120,6 +140,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         accessScope: selectedAccessScope,
         grantedGroupsForShareScope,
         grantedGroupsForAccessScope,
+        isDefault: shouldEdit ? aiAssistant.isDefault : false,
       };
 
       if (shouldEdit) {
@@ -138,7 +159,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       logger.error(err);
     }
   // eslint-disable-next-line max-len
-  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, mutateAiAssistants, closeAiAssistantManagementModal, aiAssistant?._id]);
+  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, aiAssistant?.isDefault, aiAssistant?._id, mutateAiAssistants, closeAiAssistantManagementModal]);
 
 
   /*
@@ -181,14 +202,14 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   *  For AiAssistantManagementEditPages methods
   */
   const selectPageHandler = useCallback((page: IPageForItem, isIncludeSubPage: boolean) => {
-    const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page._id);
-    if (page._id != null && !selectedPageIds.includes(page._id)) {
+    const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page.path);
+    if (page.path != null && !selectedPageIds.includes(page.path)) {
       setSelectedPages([...selectedPages, { page, isIncludeSubPage }]);
     }
   }, [selectedPages]);
 
-  const removePageHandler = useCallback((pageId: string) => {
-    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page._id !== pageId));
+  const removePageHandler = useCallback((pagePath: string) => {
+    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page.path !== pagePath));
   }, [selectedPages]);
 
 
@@ -212,10 +233,14 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
             name={name}
             description={description}
             shareScope={selectedShareScope}
+            accessScope={selectedAccessScope}
             instruction={instruction}
+            selectedPages={selectedPages}
+            selectedUserGroupsForShareScope={selectedUserGroupsForShareScope}
+            selectedUserGroupsForAccessScope={selectedUserGroupsForAccessScope}
             onNameChange={changeNameHandler}
             onDescriptionChange={changeDescriptionHandler}
-            onCreateAiAssistant={createAiAssistantHandler}
+            onUpsertAiAssistant={upsertAiAssistantHandler}
           />
         </TabPane>
 

+ 8 - 8
apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx

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

+ 12 - 6
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx

@@ -4,8 +4,11 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+
 type Props = {
   isOpen: boolean,
+  selectedPages: SelectedPage[],
   closeModal: () => void,
   onSubmit: () => Promise<void>,
 }
@@ -13,11 +16,12 @@ type Props = {
 export const ShareScopeWarningModal = (props: Props): JSX.Element => {
   const {
     isOpen,
+    selectedPages,
     closeModal,
     onSubmit,
   } = props;
 
-  const createAiAssistantHandler = useCallback(() => {
+  const upsertAiAssistantHandler = useCallback(() => {
     closeModal();
     onSubmit();
   }, [closeModal, onSubmit]);
@@ -38,10 +42,12 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
         </p>
 
         <div className="mb-4">
-          <p className="mb-2 text-secondary">含まれる限定公開ページ</p>
-          <code>
-            /Project/GROWI/新機能/GROWI AI
-          </code>
+          <p className="mb-2 text-secondary">選択されているページパス</p>
+          {selectedPages.map(selectedPage => (
+            <code key={selectedPage.page.path}>
+              {selectedPage.page.path}
+            </code>
+          ))}
         </div>
 
         <p>
@@ -61,7 +67,7 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
         <button
           type="button"
           className="btn btn-warning"
-          onClick={createAiAssistantHandler}
+          onClick={upsertAiAssistantHandler}
         >
           理解して続行する
         </button>

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

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

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

@@ -0,0 +1,52 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { NotAvailable } from '~/client/components/NotAvailable';
+import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
+import { useIsAiEnabled } from '~/stores-universal/context';
+
+import { useAiAssistantChatSidebar, useSWRxAiAssistants } from '../../stores/ai-assistant';
+
+import styles from './OpenDefaultAiAssistantButton.module.scss';
+
+const OpenDefaultAiAssistantButton = (): JSX.Element => {
+  const { data: isAiEnabled } = useIsAiEnabled();
+  const { data: aiAssistantData } = useSWRxAiAssistants();
+  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+
+  const defaultAiAssistant = useMemo(() => {
+    if (aiAssistantData == null) {
+      return null;
+    }
+
+    const allAiAssistants = [...aiAssistantData.myAiAssistants, ...aiAssistantData.teamAiAssistants];
+    return allAiAssistants.find(aiAssistant => aiAssistant.isDefault);
+  }, [aiAssistantData]);
+
+  const openDefaultAiAssistantButtonClickHandler = useCallback(() => {
+    if (defaultAiAssistant == null) {
+      return;
+    }
+
+    openAiAssistantChatSidebar(defaultAiAssistant);
+  }, [defaultAiAssistant, openAiAssistantChatSidebar]);
+
+  if (!isAiEnabled) {
+    return <></>;
+  }
+
+  return (
+    <NotAvailableForGuest>
+      <NotAvailable isDisabled={defaultAiAssistant == null} title="デフォルトアシスタントが設定されていません">
+        <button
+          type="button"
+          className={`btn btn-search ${styles['btn-open-default-ai-assistant']}`}
+          onClick={openDefaultAiAssistantButtonClickHandler}
+        >
+          <span className="growi-custom-icons fs-4 align-middle lh-1">ai_assistant</span>
+        </button>
+      </NotAvailable>
+    </NotAvailableForGuest>
+  );
+};
+
+export default OpenDefaultAiAssistantButton;

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

@@ -30,6 +30,7 @@ export const AiAssistantContent = (): JSX.Element => {
           </h3>
           {aiAssistants?.myAiAssistants != null && aiAssistants.myAiAssistants.length !== 0 && (
             <AiAssistantTree
+              onUpdated={mutateAiAssistants}
               onDeleted={mutateAiAssistants}
               aiAssistants={aiAssistants.myAiAssistants}
             />
@@ -42,6 +43,7 @@ export const AiAssistantContent = (): JSX.Element => {
           </h3>
           {aiAssistants?.teamAiAssistants != null && aiAssistants.teamAiAssistants.length !== 0 && (
             <AiAssistantTree
+              onUpdated={mutateAiAssistants}
               aiAssistants={aiAssistants.teamAiAssistants}
             />
           )}

+ 59 - 23
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback, useState } from 'react';
 
+import type { IUserHasId } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
@@ -9,7 +10,8 @@ import loggerFactory from '~/utils/logger';
 
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
-import { deleteAiAssistant } from '../../../services/ai-assistant';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
+import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-assistant';
 import { deleteThread } from '../../../services/thread';
 import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
@@ -121,7 +123,7 @@ const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClic
 *  AiAssistantItem
 */
 const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => {
-  const determinedSharedScope = shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE ? accessScope : shareScope;
+  const determinedSharedScope = determineShareScope(shareScope, accessScope);
   switch (determinedSharedScope) {
     case AiAssistantShareScope.OWNER:
       return 'lock';
@@ -129,22 +131,26 @@ const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAss
       return 'account_tree';
     case AiAssistantShareScope.PUBLIC_ONLY:
       return 'group';
+    case AiAssistantShareScope.SAME_AS_ACCESS_SCOPE:
+      return '';
   }
 };
 
 type AiAssistantItemProps = {
-  currentUserId?: string;
+  currentUser?: IUserHasId | null;
   aiAssistant: AiAssistantHasId;
   onEditClick: (aiAssistantData: AiAssistantHasId) => void;
   onItemClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onUpdated?: () => void;
   onDeleted?: () => void;
 };
 
 const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
-  currentUserId,
+  currentUser,
   aiAssistant,
   onEditClick,
   onItemClick,
+  onUpdated,
   onDeleted,
 }) => {
   const [isThreadsOpened, setIsThreadsOpened] = useState(false);
@@ -164,6 +170,18 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
     setIsThreadsOpened(toggle => !toggle);
   }, [mutateThreadData]);
 
+  const setDefaultAiAssistantHandler = useCallback(async() => {
+    try {
+      await setDefaultAiAssistant(aiAssistant._id, !aiAssistant.isDefault);
+      onUpdated?.();
+      toastSuccess('デフォルトアシスタントを切り替えました');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError('デフォルトアシスタントの切り替えに失敗しました');
+    }
+  }, [aiAssistant._id, aiAssistant.isDefault, onUpdated]);
+
   const deleteAiAssistantHandler = useCallback(async() => {
     try {
       await deleteAiAssistant(aiAssistant._id);
@@ -176,7 +194,9 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
     }
   }, [aiAssistant._id, onDeleted]);
 
-  const isOperable = currentUserId != null && getIdStringForRef(aiAssistant.owner) === currentUserId;
+  const isOperable = currentUser?._id != null && getIdStringForRef(aiAssistant.owner) === currentUser._id;
+  const isPublicAiAssistantOperable = currentUser?.admin
+    && determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) === AiAssistantShareScope.PUBLIC_ONLY;
 
   return (
     <>
@@ -211,30 +231,44 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
           <p className="text-truncate m-auto">{aiAssistant.name}</p>
         </div>
 
-        { isOperable && (
-          <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
-            <button
-              type="button"
-              className="btn btn-link text-secondary p-0 ms-2"
-              onClick={(e) => {
-                e.stopPropagation();
-                openManagementModalHandler(aiAssistant);
-              }}
-            >
-              <span className="material-symbols-outlined fs-5">edit</span>
-            </button>
+        <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
+          {isPublicAiAssistantOperable && (
             <button
               type="button"
               className="btn btn-link text-secondary p-0"
               onClick={(e) => {
                 e.stopPropagation();
-                deleteAiAssistantHandler();
+                setDefaultAiAssistantHandler();
               }}
             >
-              <span className="material-symbols-outlined fs-5">delete</span>
+              <span className={`material-symbols-outlined fs-5 ${aiAssistant.isDefault ? 'fill' : ''}`}>star</span>
             </button>
-          </div>
-        )}
+          )}
+          {isOperable && (
+            <>
+              <button
+                type="button"
+                className="btn btn-link text-secondary p-0"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  openManagementModalHandler(aiAssistant);
+                }}
+              >
+                <span className="material-symbols-outlined fs-5">edit</span>
+              </button>
+              <button
+                type="button"
+                className="btn btn-link text-secondary p-0"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  deleteAiAssistantHandler();
+                }}
+              >
+                <span className="material-symbols-outlined fs-5">delete</span>
+              </button>
+            </>
+          )}
+        </div>
       </li>
 
       { isThreadsOpened && (
@@ -254,10 +288,11 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
 */
 type AiAssistantTreeProps = {
   aiAssistants: AiAssistantHasId[];
+  onUpdated?: () => void;
   onDeleted?: () => void;
 };
 
-export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onDeleted }) => {
+export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onUpdated, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
   const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
   const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
@@ -267,10 +302,11 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
       {aiAssistants.map(assistant => (
         <AiAssistantItem
           key={assistant._id}
-          currentUserId={currentUser?._id}
+          currentUser={currentUser}
           aiAssistant={assistant}
           onEditClick={openAiAssistantManagementModal}
           onItemClick={openAiAssistantChatSidebar}
+          onUpdated={onUpdated}
           onDeleted={onDeleted}
         />
       ))}

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

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

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

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

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

@@ -10,6 +10,10 @@ export const updateAiAssistant = async(id: string, body: UpsertAiAssistantData):
   await apiv3Put(`/openai/ai-assistant/${id}`, body);
 };
 
+export const setDefaultAiAssistant = async(id: string, isDefault: boolean): Promise<void> => {
+  await apiv3Put(`/openai/ai-assistant/${id}/set-default`, { isDefault });
+};
+
 export const deleteAiAssistant = async(id: string): Promise<void> => {
   await apiv3Delete(`/openai/ai-assistant/${id}`);
 };

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

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

+ 3 - 2
apps/app/src/features/openai/interfaces/ai-assistant.ts

@@ -1,5 +1,5 @@
 import type {
-  IGrantedGroup, IUser, Ref, HasObjectId,
+  IGrantedGroup, IUserHasId, Ref, HasObjectId,
 } from '@growi/core';
 
 import type { IVectorStore } from './vector-store';
@@ -32,11 +32,12 @@ export interface AiAssistant {
   additionalInstruction: string
   pagePathPatterns: string[],
   vectorStore: Ref<IVectorStore>
-  owner: Ref<IUser>
+  owner: Ref<IUserHasId>
   grantedGroupsForShareScope?: IGrantedGroup[]
   grantedGroupsForAccessScope?: IGrantedGroup[]
   shareScope: AiAssistantShareScope
   accessScope: AiAssistantAccessScope
+  isDefault: boolean
 }
 
 export type AiAssistantHasId = AiAssistant & HasObjectId

+ 19 - 13
apps/app/src/features/openai/server/models/ai-assistant.ts

@@ -1,15 +1,15 @@
 import { type IGrantedGroup, GroupType } from '@growi/core';
+import createError from 'http-errors';
 import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 import { type AiAssistant, AiAssistantShareScope, AiAssistantAccessScope } from '../../interfaces/ai-assistant';
-import { generateGlobPatterns } from '../utils/generate-glob-patterns';
 
 export interface AiAssistantDocument extends AiAssistant, Document {}
 
 interface AiAssistantModel extends Model<AiAssistantDocument> {
-  findByPagePaths(pagePaths: string[]): Promise<AiAssistantDocument[]>;
+  setDefault(id: string, isDefault: boolean): Promise<AiAssistantDocument>;
 }
 
 /*
@@ -99,6 +99,11 @@ const schema = new Schema<AiAssistantDocument>(
       enum: Object.values(AiAssistantAccessScope),
       required: true,
     },
+    isDefault: {
+      type: Boolean,
+      required: true,
+      default: false,
+    },
   },
   {
     timestamps: true,
@@ -106,18 +111,19 @@ const schema = new Schema<AiAssistantDocument>(
 );
 
 
-schema.statics.findByPagePaths = async function(pagePaths: string[]): Promise<AiAssistantDocument[]> {
-  const pagePathsWithGlobPattern = pagePaths.map(pagePath => generateGlobPatterns(pagePath)).flat();
-  const assistants = await this.find({
-    $or: [
-      // Case 1: Exact match
-      { pagePathPatterns: { $in: pagePaths } },
-      // Case 2: Glob pattern match
-      { pagePathPatterns: { $in: pagePathsWithGlobPattern } },
-    ],
-  }).populate('vectorStore');
+schema.statics.setDefault = async function(id: string, isDefault: boolean): Promise<AiAssistantDocument> {
+  const aiAssistant = await this.findOne({ _id: id, shareScope: AiAssistantAccessScope.PUBLIC_ONLY });
+  if (aiAssistant == null) {
+    throw createError(404, 'AiAssistant document does not exist');
+  }
+
+  await this.updateMany({ isDefault: true }, { isDefault: false });
 
-  return assistants;
+  aiAssistant.isDefault = isDefault;
+  const updatedAiAssistant = await aiAssistant.save();
+
+  return updatedAiAssistant;
 };
 
+
 export default getOrCreateModel<AiAssistantDocument, AiAssistantModel>('AiAssistant', schema);

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

@@ -37,6 +37,12 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
 
       try {
         const aiAssistantData = { ...req.body, owner: req.user._id };
+
+        const isLearnablePageLimitExceeded = await openaiService.isLearnablePageLimitExceeded(req.user, aiAssistantData.pagePathPatterns);
+        if (isLearnablePageLimitExceeded) {
+          return res.apiv3Err(new ErrorV3('The number of learnable pages exceeds the limit'), 400);
+        }
+
         const aiAssistant = await openaiService.createAiAssistant(aiAssistantData);
 
         return res.apiv3({ aiAssistant });

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

@@ -19,10 +19,6 @@ export const factory = (crowi: Crowi): express.Router => {
   }
   // enabled
   else {
-    import('./rebuild-vector-store').then(({ rebuildVectorStoreHandlersFactory }) => {
-      router.post('/rebuild-vector-store', rebuildVectorStoreHandlersFactory(crowi));
-    });
-
     import('./thread').then(({ createThreadHandlersFactory }) => {
       router.post('/thread', createThreadHandlersFactory(crowi));
     });
@@ -59,6 +55,10 @@ export const factory = (crowi: Crowi): express.Router => {
       router.put('/ai-assistant/:id', updateAiAssistantsFactory(crowi));
     });
 
+    import('./set-default-ai-assistant').then(({ setDefaultAiAssistantFactory }) => {
+      router.put('/ai-assistant/:id/set-default', setDefaultAiAssistantFactory(crowi));
+    });
+
     import('./delete-ai-assistant').then(({ deleteAiAssistantsFactory }) => {
       router.delete('/ai-assistant/:id', deleteAiAssistantsFactory(crowi));
     });

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

@@ -16,6 +16,7 @@ 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';
@@ -76,6 +77,13 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
         return res.apiv3Err(new ErrorV3('AI assistant not found'), 404);
       }
 
+      const threadRelation = await ThreadRelationModel.findOne({ threadId });
+      if (threadRelation == null) {
+        return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
+      }
+
+      threadRelation.updateThreadExpiration();
+
       let stream: AssistantStream;
 
       try {

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

@@ -30,7 +30,14 @@ export const upsertAiAssistantValidator: ValidationChain[] = [
     .withMessage('pagePathPatterns must be an array of strings')
     .not()
     .isEmpty()
-    .withMessage('pagePathPatterns must not be empty'),
+    .withMessage('pagePathPatterns must not be empty')
+    .custom((pagePathPattens: string[]) => {
+      if (pagePathPattens.length > 300) {
+        throw new Error('pagePathPattens must be an array of strings with a maximum length of 300');
+      }
+
+      return true;
+    }),
 
   body('pagePathPatterns.*') // each item of pagePathPatterns
     .isString()
@@ -38,10 +45,9 @@ export const upsertAiAssistantValidator: ValidationChain[] = [
     .notEmpty()
     .withMessage('pagePathPatterns must not be empty')
     .custom((value: string) => {
-
-      // check if the value is a grob pattern path
+      // check if the value is a glob pattern path
       if (value.includes('*')) {
-        return isGlobPatternPath(value) && isCreatablePage(value.replace('*', ''));
+        return isGlobPatternPath(value) && isCreatablePage(value.replaceAll('*', ''));
       }
 
       return isCreatablePage(value);

+ 30 - 11
apps/app/src/features/openai/server/routes/rebuild-vector-store.ts → apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
-import type { ValidationChain } from 'express-validator';
+import { type ValidationChain, param, body } from 'express-validator';
+import { isHttpError } from 'http-errors';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
@@ -8,39 +9,57 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
+import AiAssistantModel from '../models/ai-assistant';
 import { getOpenaiService } from '../services/openai';
 
 import { certifyAiService } from './middlewares/certify-ai-service';
 
-const logger = loggerFactory('growi:routes:apiv3:openai:rebuild-vector-store');
+const logger = loggerFactory('growi:routes:apiv3:openai:set-default-ai-assistants');
 
-type RebuildVectorStoreFactory = (crowi: Crowi) => RequestHandler[];
+type setDefaultAiAssistantFactory = (crowi: Crowi) => RequestHandler[];
 
-export const rebuildVectorStoreHandlersFactory: RebuildVectorStoreFactory = (crowi) => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+type ReqParams = {
+  id: string,
+}
+
+type ReqBody = {
+  isDefault: boolean,
+}
+
+type Req = Request<ReqParams, Response, ReqBody>
+
+export const setDefaultAiAssistantFactory: setDefaultAiAssistantFactory = (crowi) => {
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   const validator: ValidationChain[] = [
-    //
+    param('id').isMongoId().withMessage('aiAssistant id is required'),
+    body('isDefault').isBoolean().withMessage('isDefault is required'),
   ];
 
   return [
     accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
-    async(req: Request, res: ApiV3Response) => {
-
+    async(req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();
       if (openaiService == null) {
         return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
       }
 
       try {
-        // await openaiService?.rebuildVectorStoreAll();
-        return res.apiv3({});
+        const { id } = req.params;
+        const { isDefault } = req.body;
 
+        const updatedAiAssistant = await AiAssistantModel.setDefault(id, isDefault);
+        return res.apiv3({ updatedAiAssistant });
       }
       catch (err) {
         logger.error(err);
-        return res.apiv3Err(new ErrorV3('Vector Store rebuild failed'));
+
+        if (isHttpError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message), err.status);
+        }
+
+        return res.apiv3Err(new ErrorV3('Failed to update AiAssistant'));
       }
     },
   ];

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

@@ -3,7 +3,6 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
-import { filterXSS } from 'xss';
 
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
@@ -19,8 +18,7 @@ const logger = loggerFactory('growi:routes:apiv3:openai:thread');
 
 type ReqBody = {
   aiAssistantId: string,
-  threadId?: string,
-  initialUserMessage?: string,
+  initialUserMessage: string,
 }
 
 type CreateThreadReq = Request<undefined, ApiV3Response, ReqBody> & { user: IUserHasId };
@@ -32,8 +30,7 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
 
   const validator: ValidationChain[] = [
     body('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
-    body('threadId').optional().isString().withMessage('threadId must be string'),
-    body('initialUserMessage').optional().isString().withMessage('initialUserMessage must be string'),
+    body('initialUserMessage').isString().withMessage('initialUserMessage must be string'),
   ];
 
   return [
@@ -46,17 +43,16 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
       }
 
       try {
-        const { aiAssistantId, threadId, initialUserMessage } = req.body;
+        const { aiAssistantId, initialUserMessage } = req.body;
 
         const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
         if (!isAiAssistantUsable) {
           return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
         }
 
-        const filteredThreadId = threadId != null ? filterXSS(threadId) : undefined;
         const vectorStoreRelation = await openaiService.getVectorStoreRelation(aiAssistantId);
+        const thread = await openaiService.createThread(req.user._id, vectorStoreRelation, initialUserMessage);
 
-        const thread = await openaiService.getOrCreateThread(req.user._id, vectorStoreRelation, filteredThreadId, initialUserMessage);
         return res.apiv3(thread);
       }
       catch (err) {

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

@@ -51,6 +51,12 @@ export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
 
       try {
         const aiAssistantData = { ...req.body, owner: user._id };
+
+        const isLearnablePageLimitExceeded = await openaiService.isLearnablePageLimitExceeded(user, aiAssistantData.pagePathPatterns);
+        if (isLearnablePageLimitExceeded) {
+          return res.apiv3Err(new ErrorV3('The number of learnable pages exceeds the limit'), 400);
+        }
+
         const updatedAiAssistant = await openaiService.updateAiAssistant(id, aiAssistantData);
 
         return res.apiv3({ updatedAiAssistant });

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

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

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

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

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

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

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

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

+ 85 - 119
apps/app/src/features/openai/server/services/openai.ts

@@ -2,7 +2,9 @@ import assert from 'node:assert';
 import { Readable, Transform } from 'stream';
 import { pipeline } from 'stream/promises';
 
-import type { IUser, Ref, Lang } from '@growi/core';
+import type {
+  IUser, Ref, Lang, IPage,
+} from '@growi/core';
 import {
   PageGrant, getIdForRef, getIdStringForRef, isPopulated, type IUserHasId,
 } from '@growi/core';
@@ -31,11 +33,12 @@ import {
   type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
 } from '../../interfaces/ai-assistant';
 import type { MessageListParams } from '../../interfaces/message';
+import { removeGlobPath } from '../../utils/remove-glob-path';
 import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
+import { generateGlobPatterns } from '../utils/generate-glob-patterns';
 
 import { getClient } from './client-delegator';
-// import { splitMarkdownIntoChunks } from './markdown-splitter/markdown-token-splitter';
 import { openaiApiErrorHandler } from './openai-api-error-handler';
 import { replaceAnnotationWithPageLink } from './replace-annotation-with-page-link';
 
@@ -45,7 +48,6 @@ const BATCH_SIZE = 100;
 
 const logger = loggerFactory('growi:service:openai');
 
-// const isVectorStoreForPublicScopeExist = false;
 
 type VectorStoreFileRelationsMap = Map<string, VectorStoreFileRelation>
 
@@ -63,14 +65,14 @@ const convertPathPatternsToRegExp = (pagePathPatterns: string[]): Array<string |
 };
 
 export interface IOpenaiService {
-  getOrCreateThread(
-    userId: string, vectorStoreRelation: VectorStoreDocument, threadId?: string, initialUserMessage?: string
+  createThread(
+    userId: string, vectorStoreRelation: VectorStoreDocument, initialUserMessage: string
   ): Promise<ThreadRelationDocument>;
   getThreads(vectorStoreRelationId: string): Promise<ThreadRelationDocument[]>
-  // getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument>;
   deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   deleteObsolatedVectorStoreRelations(): Promise<void> // for CronJob
+  deleteVectorStore(vectorStoreRelationId: string): Promise<void>;
   getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
   getVectorStoreRelation(aiAssistantId: string): Promise<VectorStoreDocument>
   getVectorStoreRelationsByPageIds(pageId: Types.ObjectId[]): Promise<VectorStoreDocument[]>;
@@ -80,13 +82,12 @@ export interface IOpenaiService {
   deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId): Promise<void>;
   deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
-  // rebuildVectorStoreAll(): Promise<void>;
-  // rebuildVectorStore(page: HydratedDocument<PageDocument>): Promise<void>;
   isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean>;
   createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
   updateAiAssistant(aiAssistantId: string, data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument>;
   getAccessibleAiAssistants(user: IUserHasId): Promise<AccessibleAiAssistants>
   deleteAiAssistant(ownerId: string, aiAssistantId: string): Promise<AiAssistantDocument>
+  isLearnablePageLimitExceeded(user: IUserHasId, pagePathPatterns: string[]): Promise<boolean>;
 }
 class OpenaiService implements IOpenaiService {
 
@@ -121,53 +122,29 @@ class OpenaiService implements IOpenaiService {
     return threadTitle;
   }
 
-  async getOrCreateThread(
-      userId: string, vectorStoreRelation: VectorStoreDocument, threadId?: string, initialUserMessage?: string,
-  ): Promise<ThreadRelationDocument> {
-    if (threadId == null) {
-      let threadTitle: string | null = null;
-      if (initialUserMessage != null) {
-        try {
-          threadTitle = await this.generateThreadTitle(initialUserMessage);
-        }
-        catch (err) {
-          logger.error(err);
-        }
-      }
-
+  async createThread(userId: string, vectorStoreRelation: VectorStoreDocument, initialUserMessage: string): Promise<ThreadRelationDocument> {
+    let threadTitle: string | null = null;
+    if (initialUserMessage != null) {
       try {
-        const thread = await this.client.createThread(vectorStoreRelation.vectorStoreId);
-        const threadRelation = await ThreadRelationModel.create({
-          userId,
-          threadId: thread.id,
-          vectorStore: vectorStoreRelation._id,
-          title: threadTitle,
-        });
-        return threadRelation;
+        threadTitle = await this.generateThreadTitle(initialUserMessage);
       }
       catch (err) {
-        throw new Error(err);
+        logger.error(err);
       }
     }
 
-    const threadRelation = await ThreadRelationModel.findOne({ threadId });
-    if (threadRelation == null) {
-      throw new Error('ThreadRelation document is not exists');
-    }
-
-    // Check if a thread entity exists
-    // If the thread entity does not exist, the thread-relation document is deleted
     try {
-      const thread = await this.client.retrieveThread(threadRelation.threadId);
-
-      // Update expiration date if thread entity exists
-      await threadRelation.updateThreadExpiration();
-
+      const thread = await this.client.createThread(vectorStoreRelation.vectorStoreId);
+      const threadRelation = await ThreadRelationModel.create({
+        userId,
+        threadId: thread.id,
+        vectorStore: vectorStoreRelation._id,
+        title: threadTitle,
+      });
       return threadRelation;
     }
     catch (err) {
-      await openaiApiErrorHandler(err, { notFoundError: async() => { await threadRelation.remove() } });
-      throw new Error(err);
+      throw err;
     }
   }
 
@@ -188,6 +165,7 @@ class OpenaiService implements IOpenaiService {
       await threadRelation.remove();
     }
     catch (err) {
+      await openaiApiErrorHandler(err, { notFoundError: async() => { await threadRelation.remove() } });
       throw err;
     }
 
@@ -232,38 +210,6 @@ class OpenaiService implements IOpenaiService {
     return messages;
   }
 
-  // TODO: https://redmine.weseek.co.jp/issues/160332
-  // public async getOrCreateVectorStoreForPublicScope(): Promise<VectorStoreDocument> {
-  //   const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ scopeType: VectorStoreScopeType.PUBLIC, isDeleted: false });
-
-  //   if (vectorStoreDocument != null && isVectorStoreForPublicScopeExist) {
-  //     return vectorStoreDocument;
-  //   }
-
-  //   if (vectorStoreDocument != null && !isVectorStoreForPublicScopeExist) {
-  //     try {
-  //       // Check if vector store entity exists
-  //       // If the vector store entity does not exist, the vector store document is deleted
-  //       await this.client.retrieveVectorStore(vectorStoreDocument.vectorStoreId);
-  //       isVectorStoreForPublicScopeExist = true;
-  //       return vectorStoreDocument;
-  //     }
-  //     catch (err) {
-  //       await oepnaiApiErrorHandler(err, { notFoundError: vectorStoreDocument.markAsDeleted });
-  //       throw new Error(err);
-  //     }
-  //   }
-
-  //   const newVectorStore = await this.client.createVectorStore(VectorStoreScopeType.PUBLIC);
-  //   const newVectorStoreDocument = await VectorStoreModel.create({
-  //     vectorStoreId: newVectorStore.id,
-  //     scopeType: VectorStoreScopeType.PUBLIC,
-  //   }) as VectorStoreDocument;
-
-  //   isVectorStoreForPublicScopeExist = true;
-
-  //   return newVectorStoreDocument;
-  // }
 
   async getVectorStoreRelation(aiAssistantId: string): Promise<VectorStoreDocument> {
     const aiAssistant = await AiAssistantModel.findById({ _id: aiAssistantId }).populate('vectorStore');
@@ -343,22 +289,6 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
-  // TODO: https://redmine.weseek.co.jp/issues/160332
-  // TODO: https://redmine.weseek.co.jp/issues/156643
-  // private async uploadFileByChunks(pageId: Types.ObjectId, body: string, vectorStoreFileRelationsMap: VectorStoreFileRelationsMap) {
-  //   const chunks = await splitMarkdownIntoChunks(body, 'gpt-4o');
-  //   for await (const [index, chunk] of chunks.entries()) {
-  //     try {
-  //       const file = await toFile(Readable.from(chunk), `${pageId}-chunk-${index}.md`);
-  //       const uploadedFile = await this.client.uploadFile(file);
-  //       prepareVectorStoreFileRelations(pageId, uploadedFile.id, vectorStoreFileRelationsMap);
-  //     }
-  //     catch (err) {
-  //       logger.error(err);
-  //     }
-  //   }
-  // }
-
   private async uploadFile(pageId: Types.ObjectId, pagePath: string, revisionBody: string): Promise<OpenAI.Files.FileObject> {
     const convertedHtml = await convertMarkdownToHtml({ pagePath, revisionBody });
     const file = await toFile(Readable.from(convertedHtml), `${pageId}.html`);
@@ -366,14 +296,15 @@ class OpenaiService implements IOpenaiService {
     return uploadedFile;
   }
 
-  private async deleteVectorStore(vectorStoreRelationId: string): Promise<void> {
+  async deleteVectorStore(vectorStoreRelationId: string): Promise<void> {
     const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ _id: vectorStoreRelationId, isDeleted: false });
     if (vectorStoreDocument == null) {
       return;
     }
 
     try {
-      await this.client.deleteVectorStore(vectorStoreDocument.vectorStoreId);
+      const deleteVectorStoreResponse = await this.client.deleteVectorStore(vectorStoreDocument.vectorStoreId);
+      logger.debug('Delete vector store', deleteVectorStoreResponse);
       await vectorStoreDocument.markAsDeleted();
     }
     catch (err) {
@@ -540,28 +471,6 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
-  // TODO: https://redmine.weseek.co.jp/issues/160332
-  // async rebuildVectorStoreAll() {
-  //   await this.deleteVectorStore(VectorStoreScopeType.PUBLIC);
-
-  //   // Create all public pages VectorStoreFile
-  //   const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-  //   const pagesStream = Page.find({ grant: PageGrant.GRANT_PUBLIC }).populate('revision').cursor({ batch_size: BATCH_SIZE });
-  //   const batchStrem = createBatchStream(BATCH_SIZE);
-
-  //   const createVectorStoreFile = this.createVectorStoreFile.bind(this);
-  //   const createVectorStoreFileStream = new Transform({
-  //     objectMode: true,
-  //     async transform(chunk: HydratedDocument<PageDocument>[], encoding, callback) {
-  //       await createVectorStoreFile(chunk);
-  //       this.push(chunk);
-  //       callback();
-  //     },
-  //   });
-
-  //   await pipeline(pagesStream, batchStrem, createVectorStoreFileStream);
-  // }
-
   async filterPagesByAccessScope(aiAssistant: AiAssistantDocument, pages: HydratedDocument<PageDocument>[]) {
     const isPublicPage = (page :HydratedDocument<PageDocument>) => page.grant === PageGrant.GRANT_PUBLIC;
 
@@ -602,13 +511,22 @@ class OpenaiService implements IOpenaiService {
 
   async createVectorStoreFileOnPageCreate(pages: HydratedDocument<PageDocument>[]): Promise<void> {
     const pagePaths = pages.map(page => page.path);
-    const aiAssistants = await AiAssistantModel.findByPagePaths(pagePaths);
+    const aiAssistants = await this.findAiAssistantByPagePath(pagePaths, { shouldPopulateOwner: true, shouldPopulateVectorStore: true });
 
     if (aiAssistants.length === 0) {
       return;
     }
 
     for await (const aiAssistant of aiAssistants) {
+      if (!isPopulated(aiAssistant.owner)) {
+        continue;
+      }
+
+      const isLearnablePageLimitExceeded = await this.isLearnablePageLimitExceeded(aiAssistant.owner, aiAssistant.pagePathPatterns);
+      if (isLearnablePageLimitExceeded) {
+        continue;
+      }
+
       const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, pages);
       const vectorStoreRelation = aiAssistant.vectorStore;
       if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
@@ -625,7 +543,7 @@ class OpenaiService implements IOpenaiService {
   }
 
   async updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>) {
-    const aiAssistants = await AiAssistantModel.findByPagePaths([page.path]);
+    const aiAssistants = await this.findAiAssistantByPagePath([page.path], { shouldPopulateVectorStore: true });
 
     if (aiAssistants.length === 0) {
       return;
@@ -960,6 +878,54 @@ class OpenaiService implements IOpenaiService {
     return deletedAiAssistant;
   }
 
+  async isLearnablePageLimitExceeded(user: IUserHasId, pagePathPatterns: string[]): Promise<boolean> {
+    const normalizedPagePathPatterns = removeGlobPath(pagePathPatterns);
+
+    const PageModel = mongoose.model<IPage, PageModel>('Page');
+    const pagePathsWithDescendantCount = await PageModel.descendantCountByPaths(normalizedPagePathPatterns, user, null, true, true);
+
+    const totalPageCount = pagePathsWithDescendantCount.reduce((total, pagePathWithDescendantCount) => {
+      const descendantCount = pagePathPatterns.includes(pagePathWithDescendantCount.path)
+        ? 0 // Treat as single page when included in "pagePathPatterns"
+        : pagePathWithDescendantCount.descendantCount;
+
+      const pageCount = descendantCount + 1;
+      return total + pageCount;
+    }, 0);
+
+    logger.debug('TotalPageCount: ', totalPageCount);
+
+    const limitLearnablePageCountPerAssistant = configManager.getConfig('openai:limitLearnablePageCountPerAssistant');
+    return totalPageCount > limitLearnablePageCountPerAssistant;
+  }
+
+  async findAiAssistantByPagePath(
+      pagePaths: string[], options?: { shouldPopulateOwner?: boolean, shouldPopulateVectorStore?: boolean },
+  ): Promise<AiAssistantDocument[]> {
+
+    const pagePathsWithGlobPattern = pagePaths.map(pagePath => generateGlobPatterns(pagePath)).flat();
+
+    const query = AiAssistantModel.find({
+      $or: [
+        // Case 1: Exact match
+        { pagePathPatterns: { $in: pagePaths } },
+        // Case 2: Glob pattern match
+        { pagePathPatterns: { $in: pagePathsWithGlobPattern } },
+      ],
+    });
+
+    if (options?.shouldPopulateOwner) {
+      query.populate('owner');
+    }
+
+    if (options?.shouldPopulateVectorStore) {
+      query.populate('vectorStore');
+    }
+
+    const aiAssistants = await query.exec();
+    return aiAssistants;
+  }
+
 }
 
 let instance: OpenaiService;

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

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

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

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

+ 0 - 5
apps/app/src/features/rate-limiter/config/index.ts

@@ -56,11 +56,6 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
     method: 'GET',
     maxRequests: MAX_REQUESTS_TIER_3,
   },
-  '/_api/v3/openai/rebuild-vector-store': {
-    method: 'POST',
-    maxRequests: 1,
-    usersPerIpProspection: 1,
-  },
 };
 
 const isDev = process.env.NODE_ENV === 'development';

+ 5 - 0
apps/app/src/interfaces/page.ts

@@ -76,3 +76,8 @@ export type IOptionsForCreate = {
   origin?: Origin
   wip?: boolean,
 };
+
+export type IPagePathWithDescendantCount = {
+  path: string,
+  descendantCount: number,
+};

+ 8 - 2
apps/app/src/pages/[[...path]].page.tsx

@@ -41,12 +41,12 @@ import {
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
-  useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
+  useIsAllReplyShown, useShowPageSideAuthors, useIsContainerFluid, useIsNotCreatable,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
   useElasticsearchMaxBodyLengthToIndex,
   useIsLocalAccountRegistrationEnabled,
   useIsRomUserAllowedToComment,
-  useIsAiEnabled,
+  useIsAiEnabled, useLimitLearnablePageCountPerAssistant,
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
@@ -177,6 +177,7 @@ type Props = CommonProps & {
   drawioUri: string | null,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
+  showPageSideAuthors: boolean,
   isContainerFluid: boolean,
   isUploadEnabled: boolean,
   isUploadAllFileAllowed: boolean,
@@ -195,6 +196,7 @@ type Props = CommonProps & {
   rendererConfig: RendererConfig,
 
   aiEnabled: boolean,
+  limitLearnablePageCountPerAssistant: number,
 };
 
 const Page: NextPageWithLayout<Props> = (props: Props) => {
@@ -240,6 +242,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
   useIsAllReplyShown(props.isAllReplyShown);
+  useShowPageSideAuthors(props.showPageSideAuthors);
 
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadEnabled(props.isUploadEnabled);
@@ -248,6 +251,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useIsRomUserAllowedToComment(props.isRomUserAllowedToComment);
 
   useIsAiEnabled(props.aiEnabled);
+  useLimitLearnablePageCountPerAssistant(props.limitLearnablePageCountPerAssistant);
 
   const { pageWithMeta } = props;
 
@@ -566,6 +570,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   } = crowi;
 
   props.aiEnabled = configManager.getConfig('app:aiEnabled');
+  props.limitLearnablePageCountPerAssistant = configManager.getConfig('openai:limitLearnablePageCountPerAssistant');
 
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
@@ -581,6 +586,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.drawioUri = configManager.getConfig('app:drawioUri');
   // props.highlightJsStyle = configManager.getConfig('customize:highlightJsStyle');
   props.isAllReplyShown = configManager.getConfig('customize:isAllReplyShown');
+  props.showPageSideAuthors = configManager.getConfig('customize:showPageSideAuthors');
   props.isContainerFluid = configManager.getConfig('customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('customize:isEnabledStaleNotification');
   props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');

+ 5 - 1
apps/app/src/pages/share/[[...path]].page.tsx

@@ -23,7 +23,7 @@ import ShareLink from '~/server/models/share-link';
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
-  useIsLocalAccountRegistrationEnabled,
+  useIsLocalAccountRegistrationEnabled, useShowPageSideAuthors,
 } from '~/stores-universal/context';
 import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
@@ -49,6 +49,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  showPageSideAuthors: boolean,
   isEnabledMarp: boolean,
   isLocalAccountRegistrationEnabled: boolean,
   drawioUri: string | null,
@@ -99,6 +100,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
   useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
+  useShowPageSideAuthors(props.showPageSideAuthors);
   useIsContainerFluid(props.isContainerFluid);
 
   const { trigger: mutateCurrentPage, data: currentPage } = useSWRMUTxCurrentPage();
@@ -164,6 +166,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
   props.drawioUri = configManager.getConfig('app:drawioUri');
 
+  props.showPageSideAuthors = configManager.getConfig('customize:showPageSideAuthors');
+
   props.isLocalAccountRegistrationEnabled = crowi.passportService.isLocalStrategySetup
     && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED;
 

+ 53 - 3
apps/app/src/server/models/page.ts

@@ -7,7 +7,7 @@ import {
   type IPage,
   GroupType, type HasObjectId,
 } from '@growi/core';
-import type { IPagePopulatedToShowRevision } from '@growi/core/dist/interfaces';
+import type { IPagePopulatedToShowRevision, IUserHasId } from '@growi/core/dist/interfaces';
 import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -23,7 +23,7 @@ import uniqueValidator from 'mongoose-unique-validator';
 
 import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import type { IOptionsForCreate } from '~/interfaces/page';
+import type { IOptionsForCreate, IPagePathWithDescendantCount } from '~/interfaces/page';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
@@ -91,7 +91,9 @@ export interface PageModel extends Model<PageDocument> {
   findByPath(path: string, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument>[]>
-  countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
+  descendantCountByPaths(
+    paths: string[], user: IUserHasId, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean
+  ): Promise<IPagePathWithDescendantCount[]>
   findParentByPath(path: string | null): Promise<HydratedDocument<PageDocument> | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findRecentUpdatedPages(path: string, user, option: FindRecentUpdatedPagesOption, includeEmpty?: boolean): Promise<PaginatedPages>
@@ -670,6 +672,54 @@ schema.statics.findByPathAndViewer = async function(
   return queryBuilder.query.exec();
 };
 
+schema.statics.descendantCountByPaths = async function(
+    paths: string[],
+    user: IUserHasId,
+    userGroups = null,
+    includeEmpty = false,
+    includeAnyoneWithTheLink = false,
+): Promise<IPagePathWithDescendantCount[]> {
+  if (paths.length === 0) {
+    throw new Error('paths are required');
+  }
+
+  const baseQuery = this.find({ path: { $in: paths } });
+  const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
+  await queryBuilder.addViewerCondition(user, userGroups, includeAnyoneWithTheLink);
+
+  const conditions = queryBuilder.query._conditions;
+
+  const aggregationPipeline = [
+    {
+      $match: conditions,
+    },
+    {
+      $project: {
+        _id: 0,
+        path: 1,
+        descendantCount: 1,
+      },
+    },
+    {
+      $group: {
+        _id: '$path',
+        descendantCount: { $first: '$descendantCount' },
+      },
+    },
+    {
+      $project: {
+        _id: 0,
+        path: '$_id',
+        descendantCount: 1,
+      },
+    },
+  ];
+
+  const pages = await this.aggregate<IPagePathWithDescendantCount>(aggregationPipeline);
+  return pages;
+};
+
 schema.statics.countByPathAndViewer = async function(path: string | null, user, userGroups = null, includeEmpty = false): Promise<number> {
   if (path == null) {
     throw new Error('path is required.');

+ 4 - 0
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -222,6 +222,7 @@ module.exports = (crowi) => {
       body('isEnabledStaleNotification').isBoolean(),
       body('isAllReplyShown').isBoolean(),
       body('isSearchScopeChildrenAsDefault').isBoolean(),
+      body('showPageSideAuthors').isBoolean(),
     ],
     CustomizePresentation: [
       body('isEnabledMarp').isBoolean(),
@@ -283,6 +284,7 @@ module.exports = (crowi) => {
       pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
       isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
       isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
+      showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
       isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
       isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
       styleName: await configManager.getConfig('customize:highlightJsStyle'),
@@ -601,6 +603,7 @@ module.exports = (crowi) => {
       'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
       'customize:isAllReplyShown': req.body.isAllReplyShown,
       'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
+      'customize:showPageSideAuthors': req.body.showPageSideAuthors,
     };
 
     try {
@@ -615,6 +618,7 @@ module.exports = (crowi) => {
         isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
         isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
         isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
+        showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);

+ 76 - 0
apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts

@@ -0,0 +1,76 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { query } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import type { PageModel } from '~/server/models/page';
+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:page:get-pages-by-page-paths');
+
+type GetPagePathsWithDescendantCountFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqQuery = {
+  paths: string[],
+  userGroups?: string[],
+  isIncludeEmpty?: boolean,
+  includeAnyoneWithTheLink?: boolean,
+}
+
+interface Req extends Request<undefined, ApiV3Response, undefined, ReqQuery> {
+  user: IUserHasId,
+}
+export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    query('paths').isArray().withMessage('paths must be an array of strings'),
+    query('paths').custom((paths: string[]) => {
+      if (paths.length > 300) {
+        throw new Error('paths must be an array of strings with a maximum length of 300');
+      }
+      return true;
+    }),
+    query('paths.*') // each item of paths
+      .isString()
+      .withMessage('paths must be an array of strings'),
+
+    query('userGroups').optional().isArray().withMessage('userGroups must be an array of strings'),
+    query('userGroups.*') // each item of userGroups
+      .isMongoId()
+      .withMessage('userGroups must be an array of strings'),
+
+    query('isIncludeEmpty').optional().isBoolean().withMessage('isIncludeEmpty must be a boolean'),
+    query('isIncludeEmpty').toBoolean(),
+
+    query('includeAnyoneWithTheLink').optional().isBoolean().withMessage('includeAnyoneWithTheLink must be a boolean'),
+    query('includeAnyoneWithTheLink').toBoolean(),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const {
+        paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink,
+      } = req.query;
+
+      try {
+        const pagePathsWithDescendantCount = await Page.descendantCountByPaths(paths, req.user, userGroups, isIncludeEmpty, includeAnyoneWithTheLink);
+        return res.apiv3({ pagePathsWithDescendantCount });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 293 - 8
apps/app/src/server/routes/apiv3/page/index.ts

@@ -35,6 +35,7 @@ import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
+import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
 import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
@@ -189,7 +190,7 @@ module.exports = (crowi) => {
    *      get:
    *        tags: [Page]
    *        operationId: getPage
-   *        summary: /page
+   *        summary: Get page
    *        description: get page by pagePath or pageId
    *        parameters:
    *          - name: pageId
@@ -266,6 +267,33 @@ module.exports = (crowi) => {
     return res.apiv3({ page, pages });
   });
 
+  router.get('/page-paths-with-descendant-count', getPagePathsWithDescendantCountFactory(crowi));
+
+  /**
+   * @swagger
+   *   /page/exist:
+   *     get:
+   *       tags: [Page]
+   *       summary: Check if page exists
+   *       description: Check if a page exists at the specified path
+   *       parameters:
+   *         - name: path
+   *           in: query
+   *           description: The path to check for existence
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully checked page existence.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   isExist:
+   *                     type: boolean
+   */
   router.get('/exist', checkPageExistenceHandlersFactory(crowi));
 
   /**
@@ -274,6 +302,7 @@ module.exports = (crowi) => {
    *    /page:
    *      post:
    *        tags: [Page]
+   *        summary: Create page
    *        operationId: createPage
    *        description: Create page
    *        requestBody:
@@ -397,7 +426,7 @@ module.exports = (crowi) => {
    *    /page/likes:
    *      put:
    *        tags: [Page]
-   *        summary: /page/likes
+   *        summary: Get page likes
    *        description: Update liked status
    *        operationId: updateLikedStatus
    *        requestBody:
@@ -465,7 +494,7 @@ module.exports = (crowi) => {
    *    /page/info:
    *      get:
    *        tags: [Page]
-   *        summary: /page/info
+   *        summary: Get page info
    *        description: Retrieve current page info
    *        operationId: getPageInfo
    *        requestBody:
@@ -509,7 +538,7 @@ module.exports = (crowi) => {
    *    /page/grant-data:
    *      get:
    *        tags: [Page]
-   *        summary: /page/info
+   *        summary: Get page grant data
    *        description: Retrieve current page's grant data
    *        operationId: getPageGrantData
    *        parameters:
@@ -604,6 +633,37 @@ module.exports = (crowi) => {
 
   // Check if non user related groups are granted page access.
   // If specified page does not exist, check the closest ancestor.
+  /**
+   * @swagger
+   *   /page/non-user-related-groups-granted:
+   *     get:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Check if non-user related groups are granted page access
+   *       description: Check if non-user related groups are granted access to a specific page or its closest ancestor
+   *       parameters:
+   *         - name: path
+   *           in: query
+   *           description: Path of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully checked non-user related groups access.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   isNonUserRelatedGroupsGranted:
+   *                     type: boolean
+   *         403:
+   *           description: Forbidden. Cannot access page or ancestor.
+   *         500:
+   *           description: Internal server error.
+   */
   router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
     async(req, res: ApiV3Response) => {
       const { user } = req;
@@ -635,7 +695,45 @@ module.exports = (crowi) => {
         return res.apiv3Err(err, 500);
       }
     });
-
+  /**
+   * @swagger
+   *   /page/applicable-grant:
+   *     get:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Get applicable grant data
+   *       description: Retrieve applicable grant data for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: query
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully retrieved applicable grant data.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   grant:
+   *                     type: number
+   *                   grantedUsers:
+   *                     type: array
+   *                     items:
+   *                       type: string
+   *                   grantedGroups:
+   *                     type: array
+   *                     items:
+   *                       type: string
+   *         400:
+   *           description: Bad request. Page is unreachable or empty.
+   *         500:
+   *           description: Internal server error.
+   */
   router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
 
@@ -659,6 +757,43 @@ module.exports = (crowi) => {
     return res.apiv3(data);
   });
 
+  /**
+   * @swagger
+   *   /:pageId/grant:
+   *     put:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Update page grant
+   *       description: Update the grant of a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 grant:
+   *                   type: number
+   *                   description: Grant level
+   *                 userRelatedGrantedGroups:
+   *                   type: array
+   *                   items:
+   *                     type: string
+   *                   description: Array of user-related granted group IDs
+   *       responses:
+   *         200:
+   *           description: Successfully updated page grant.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
     const { grant, userRelatedGrantedGroups } = req.body;
@@ -692,6 +827,8 @@ module.exports = (crowi) => {
   *    /page/export:
   *      get:
   *        tags: [Page]
+  *        security:
+  *          - cookieAuth: []
   *        description: return page's markdown
   *        responses:
   *          200:
@@ -792,7 +929,9 @@ module.exports = (crowi) => {
    *    /page/exist-paths:
    *      get:
    *        tags: [Page]
-   *        summary: /page/exist-paths
+   *        security:
+   *          - cookieAuth: []
+   *        summary: Get already exist paths
    *        description: Get already exist paths
    *        operationId: getAlreadyExistPaths
    *        parameters:
@@ -853,7 +992,7 @@ module.exports = (crowi) => {
    *    /page/subscribe:
    *      put:
    *        tags: [Page]
-   *        summary: /page/subscribe
+   *        summary: Update subscription status
    *        description: Update subscription status
    *        operationId: updateSubscriptionStatus
    *        requestBody:
@@ -900,6 +1039,39 @@ module.exports = (crowi) => {
   });
 
 
+  /**
+   * @swagger
+   *
+   *   /:pageId/content-width:
+   *     put:
+   *       tags: [Page]
+   *       summary: Update content width
+   *       description: Update the content width setting for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 expandContentWidth:
+   *                   type: boolean
+   *                   description: Whether to expand the content width
+   *       responses:
+   *         200:
+   *           description: Successfully updated content width.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   page:
+   *                     $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
       const { pageId } = req.params;
@@ -921,13 +1093,126 @@ module.exports = (crowi) => {
       }
     });
 
-
+  /**
+   * @swagger
+   *   /:pageId/publish:
+   *     put:
+   *       tags: [Page]
+   *       summary: Publish page
+   *       description: Publish a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully published the page.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/publish', publishPageHandlersFactory(crowi));
 
+  /**
+   * @swagger
+   *   /:pageId/unpublish:
+   *     put:
+   *       tags: [Page]
+   *       summary: Unpublish page
+   *       description: Unpublish a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully unpublished the page.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/unpublish', unpublishPageHandlersFactory(crowi));
 
+  /**
+   * @swagger
+   *   /:pageId/yjs-data:
+   *     get:
+   *       tags: [Page]
+   *       summary: Get Yjs data
+   *       description: Retrieve Yjs data for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully retrieved Yjs data.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   yjsData:
+   *                     type: object
+   *                     description: Yjs data
+   *                     properties:
+   *                       hasYdocsNewerThanLatestRevision:
+   *                         type: boolean
+   *                         description: Whether Yjs documents are newer than the latest revision
+   *                       awarenessStateSize:
+   *                         type: number
+   *                         description: Size of the awareness state
+   */
   router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
 
+  /**
+   * @swagger
+   *   /:pageId/sync-latest-revision-body-to-yjs-draft:
+   *     put:
+   *       tags: [Page]
+   *       summary: Sync latest revision body to Yjs draft
+   *       description: Sync the latest revision body to the Yjs draft for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 editingMarkdownLength:
+   *                   type: integer
+   *                   description: Length of the editing markdown
+   *       responses:
+   *         200:
+   *           description: Successfully synced the latest revision body to Yjs draft.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   synced:
+   *                     type: boolean
+   *                     description: Whether the latest revision body is synced to the Yjs draft
+   *                   isYjsDataBroken:
+   *                     type: boolean
+   *                     description: Whether Yjs data is broken
+   */
   router.put('/:pageId/sync-latest-revision-body-to-yjs-draft', syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi));
 
   return router;

+ 232 - 7
apps/app/src/server/routes/apiv3/pages/index.js

@@ -129,10 +129,27 @@ module.exports = (crowi) => {
    *      get:
    *        tags: [Pages]
    *        description: Get recently updated pages
+   *        parameters:
+   *          - name: limit
+   *            in: query
+   *            description: Limit of acquisitions
+   *            schema:
+   *              type: number
+   *            example: 10
+   *          - name: offset
+   *            in: query
+   *            description: Offset of acquisitions
+   *            schema:
+   *              type: number
+   *            example: 0
+   *          - name: includeWipPage
+   *            in: query
+   *            description: Whether to include WIP pages
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: Return pages recently updated
-   *
    */
   router.get('/recent', accessTokenParser, loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
     const limit = parseInt(req.query.limit) || 20;
@@ -233,6 +250,9 @@ module.exports = (crowi) => {
    *                  isRecursively:
    *                    type: boolean
    *                    description: whether rename page with descendants
+   *                  isMoveMode:
+   *                    type: boolean
+   *                    description: whether rename page with moving
    *                required:
    *                  - pageId
    *                  - revisionId
@@ -328,6 +348,28 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+    * @swagger
+    *    /pages/resume-rename:
+    *      post:
+    *        tags: [Pages]
+    *        operationId: resumeRenamePage
+    *        description: Resume rename page operation
+    *        requestBody:
+    *          content:
+    *            application/json:
+    *              schema:
+    *                properties:
+    *                  pageId:
+    *                    $ref: '#/components/schemas/Page/properties/_id'
+    *                required:
+    *                  - pageId
+    *        responses:
+    *          200:
+    *            description: Succeeded to resume rename page operation.
+    *            content:
+    *              description: Empty response
+    */
   router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
     async(req, res) => {
 
@@ -369,6 +411,14 @@ module.exports = (crowi) => {
    *        responses:
    *          200:
    *            description: Succeeded to remove all trash pages
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    deletablePages:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Page'
    */
   router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
@@ -423,6 +473,59 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
   ];
 
+  /**
+    * @swagger
+    *
+    *    /pages/list:
+    *      get:
+    *        tags: [Pages]
+    *        operationId: getList
+    *        description: Get list of pages
+    *        parameters:
+    *          - name: path
+    *            in: query
+    *            description: Path to search
+    *            schema:
+    *              type: string
+    *          - name: limit
+    *            in: query
+    *            description: Limit of acquisitions
+    *            schema:
+    *              type: number
+    *          - name: page
+    *            in: query
+    *            description: Page number
+    *            schema:
+    *              type: number
+    *        responses:
+    *          200:
+    *            description: Succeeded to retrieve pages.
+    *            content:
+    *              application/json:
+    *                schema:
+    *                  properties:
+    *                    totalCount:
+    *                      type: number
+    *                      description: Total count of pages
+    *                      example: 3
+    *                    offset:
+    *                      type: number
+    *                      description: Offset of pages
+    *                      example: 0
+    *                    limit:
+    *                      type: number
+    *                      description: Limit of pages
+    *                      example: 10
+    *                    pages:
+    *                      type: array
+    *                      items:
+    *                        allOf:
+    *                          - $ref: '#/components/schemas/Page'
+    *                          - type: object
+    *                            properties:
+    *                              lastUpdateUser:
+    *                                $ref: '#/components/schemas/User'
+    */
   router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
 
     const { path } = req.query;
@@ -480,6 +583,9 @@ module.exports = (crowi) => {
    *                  isRecursively:
    *                    type: boolean
    *                    description: whether duplicate page with descendants
+   *                  onlyDuplicateUserRelatedResources:
+   *                    type: boolean
+   *                    description: whether duplicate only user related resources
    *                required:
    *                  - pageId
    *        responses:
@@ -589,11 +695,10 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    subordinatedPaths:
-   *                      type: object
-   *                      description: descendants page
-   *          500:
-   *            description: Internal server error.
+   *                    subordinatedPages:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Page'
    */
   router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
     const { path } = req.query;
@@ -611,6 +716,50 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+    * @swagger
+    *    /pages/delete:
+    *      post:
+    *        tags: [Pages]
+    *        operationId: deletePages
+    *        description: Delete pages
+    *        requestBody:
+    *          content:
+    *            application/json:
+    *              schema:
+    *                properties:
+    *                  pageIdToRevisionIdMap:
+    *                    type: object
+    *                    description: Map of page IDs to revision IDs
+    *                    example: { "5e2d6aede35da4004ef7e0b7": "5e07345972560e001761fa63" }
+    *                  isCompletely:
+    *                    type: boolean
+    *                    description: Whether to delete pages completely
+    *                  isRecursively:
+    *                    type: boolean
+    *                    description: Whether to delete pages recursively
+    *                  isAnyoneWithTheLink:
+    *                    type: boolean
+    *                    description: Whether the page is restricted to anyone with the link
+    *        responses:
+    *          200:
+    *            description: Succeeded to delete pages.
+    *            content:
+    *              application/json:
+    *                schema:
+    *                  properties:
+    *                    paths:
+    *                      type: array
+    *                      items:
+    *                        type: string
+    *                      description: List of deleted page paths
+    *                    isRecursively:
+    *                      type: boolean
+    *                      description: Whether pages were deleted recursively
+    *                    isCompletely:
+    *                      type: boolean
+    *                      description: Whether pages were deleted completely
+    */
   router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
     const {
       pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
@@ -665,7 +814,32 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
 
-
+  /**
+   * @swagger
+   *
+   *    /pages/convert-pages-by-path:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: convertPagesByPath
+   *        description: Convert pages by path
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  convertPath:
+   *                    type: string
+   *                    description: Path to convert
+   *                    example: /user/alice
+   *        responses:
+   *          200:
+   *            description: Succeeded to convert pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  description: Empty object
+   */
   // eslint-disable-next-line max-len
   router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
     const { convertPath } = req.body;
@@ -688,6 +862,36 @@ module.exports = (crowi) => {
     return res.apiv3({});
   });
 
+  /**
+   * @swagger
+   *
+   *    /pages/legacy-pages-migration:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: legacyPagesMigration
+   *        description: Migrate legacy pages
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageIds:
+   *                    type: array
+   *                    items:
+   *                      type: string
+   *                    description: List of page IDs to migrate
+   *                  isRecursively:
+   *                    type: boolean
+   *                    description: Whether to migrate pages recursively
+   *        responses:
+   *          200:
+   *            description: Succeeded to migrate legacy pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  description: Empty object
+  */
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
@@ -717,6 +921,27 @@ module.exports = (crowi) => {
     return res.apiv3({});
   });
 
+  /**
+   * @swagger
+   *
+   *    /pages/v5-migration-status:
+   *      get:
+   *        tags: [Pages]
+   *        description: Get V5 migration status
+   *        responses:
+   *          200:
+   *            description: Return V5 migration status
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    isV5Compatible:
+   *                      type: boolean
+   *                      description: Whether the app is V5 compatible
+   *                    migratablePagesCount:
+   *                      type: number
+   *                      description: Number of pages that can be migrated
+   */
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
       const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');

+ 26 - 4
apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -55,6 +55,8 @@ module.exports = (crowi) => {
    *    /slack-integration-legacy-setting/:
    *      get:
    *        tags: [SlackIntegrationLegacySetting]
+   *        security:
+   *          - cookieAuth: []
    *        description: Get slack configuration setting
    *        responses:
    *          200:
@@ -63,9 +65,15 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    notificationParams:
+   *                    slackIntegrationParams:
    *                      type: object
-   *                      description: slack configuration setting params
+   *                      allOf:
+   *                        - $ref: '#/components/schemas/SlackConfigurationParams'
+   *                        - type: object
+   *                          properties:
+   *                            isSlackbotConfigured:
+   *                              type: boolean
+   *                              description: whether slackbot is configured
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
 
@@ -84,20 +92,34 @@ module.exports = (crowi) => {
    *    /slack-integration-legacy-setting/:
    *      put:
    *        tags: [SlackIntegrationLegacySetting]
+   *        security:
+   *          - cookieAuth: []
    *        description: Update slack configuration setting
    *        requestBody:
    *          required: true
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/SlackConfigurationParams'
+   *                properties:
+   *                  webhookUrl:
+   *                    type: string
+   *                    description: incoming webhooks url
+   *                  isIncomingWebhookPrioritized:
+   *                    type: boolean
+   *                    description: use incoming webhooks even if Slack App settings are enabled
+   *                  slackToken:
+   *                    type: string
+   *                    description: OAuth access token
    *        responses:
    *          200:
    *            description: Succeeded to update slack configuration setting
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/SlackConfigurationParams'
+   *                  properties:
+   *                    responseParams:
+   *                      type: object
+   *                      $ref: '#/components/schemas/SlackConfigurationParams'
    */
   router.put('/', loginRequiredStrictly, adminRequired, addActivity, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
 

+ 9 - 0
apps/app/src/server/service/config-manager/config-definition.ts

@@ -218,6 +218,7 @@ export const CONFIG_KEYS = [
   'customize:isEnabledStaleNotification',
   'customize:isAllReplyShown',
   'customize:isSearchScopeChildrenAsDefault',
+  'customize:showPageSideAuthors',
   'customize:isEnabledMarp',
   'customize:isSidebarCollapsedMode',
   'customize:isSidebarClosedAtDockMode',
@@ -260,6 +261,7 @@ export const CONFIG_KEYS = [
   'openai:vectorStoreFileDeletionCronExpression',
   'openai:vectorStoreFileDeletionBarchSize',
   'openai:vectorStoreFileDeletionApiCallInterval',
+  'openai:limitLearnablePageCountPerAssistant',
 
   // OpenTelemetry Settings
   'otel:enabled',
@@ -970,6 +972,9 @@ export const CONFIG_DEFINITIONS = {
   'customize:isSearchScopeChildrenAsDefault': defineConfig<boolean>({
     defaultValue: false,
   }),
+  'customize:showPageSideAuthors': defineConfig<boolean>({
+    defaultValue: false,
+  }),
   'customize:isEnabledMarp': defineConfig<boolean>({
     defaultValue: false,
   }),
@@ -1125,6 +1130,10 @@ Guideline as a RAG:
     envVarName: 'OPENAI_SEARCH_ASSISTANT_INSTRUCTIONS',
     defaultValue: '',
   }),
+  'openai:limitLearnablePageCountPerAssistant': defineConfig<number>({
+    envVarName: 'OPENAI_LIMIT_LEARNABLE_PAGE_COUNT_PER_ASSISTANT',
+    defaultValue: 3000,
+  }),
 
   // OpenTelemetry Settings
   'otel:enabled': defineConfig<boolean>({

+ 26 - 0
apps/app/src/server/service/normalize-data/delete-legacy-knowledge-assistant-vector-store.ts

@@ -0,0 +1,26 @@
+import AiAssistantModel from '~/features/openai/server/models/ai-assistant';
+import VectorStoreRelationModel from '~/features/openai/server/models/vector-store';
+import { isAiEnabled } from '~/features/openai/server/services/is-ai-enabled';
+import { getOpenaiService } from '~/features/openai/server/services/openai';
+
+export const deleteLegacyKnowledgeAssistantVectorStore = async(): Promise<void> => {
+  if (!isAiEnabled()) {
+    return;
+  }
+
+  // Identify VectorStoreRelation documents not related to existing aiAssistant documents as those used by old knowledge assistant
+  // Retrieve these VectorStoreRelation documents used by old knowledge assistant
+  // Only one active ({isDeleted: false}) VectorStoreRelation document should exist for old knowledge assistant, so only one should be returned
+  const aiAssistantVectorStoreIds = await AiAssistantModel.distinct('vectorStore');
+  const nonDeletedLegacyKnowledgeAssistantVectorStoreRelations = await VectorStoreRelationModel.find({
+    _id: { $nin: aiAssistantVectorStoreIds },
+    isDeleted: false,
+  });
+
+  // Logically delete only the VectorStore entities, leaving related documents to be automatically deleted by cron job
+  const openaiService = getOpenaiService();
+  for await (const vectorStoreRelation of nonDeletedLegacyKnowledgeAssistantVectorStoreRelations) {
+    const vectorStoreFileRelationId = vectorStoreRelation._id;
+    await openaiService?.deleteVectorStore(vectorStoreFileRelationId);
+  }
+};

+ 2 - 0
apps/app/src/server/service/normalize-data/index.ts

@@ -3,6 +3,7 @@ import loggerFactory from '~/utils/logger';
 
 import { convertNullToEmptyGrantedArrays } from './convert-null-to-empty-granted-arrays';
 import { convertRevisionPageIdToObjectId } from './convert-revision-page-id-to-objectid';
+import { deleteLegacyKnowledgeAssistantVectorStore } from './delete-legacy-knowledge-assistant-vector-store';
 import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
 
 const logger = loggerFactory('growi:service:NormalizeData');
@@ -12,6 +13,7 @@ export const normalizeData = async(): Promise<void> => {
   await convertRevisionPageIdToObjectId();
   await normalizeExpiredAtForThreadRelations();
   await convertNullToEmptyGrantedArrays();
+  await deleteLegacyKnowledgeAssistantVectorStore();
 
   logger.info('normalizeData has been executed');
   return;

+ 8 - 0
apps/app/src/stores-universal/context.tsx

@@ -100,6 +100,10 @@ export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRRe
   return useContextSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData, { fallbackData: false });
 };
 
+export const useShowPageSideAuthors = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useContextSWR('showPageSideAuthors', initialData, { fallbackData: false });
+};
+
 export const useIsEnabledMarp = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useContextSWR<boolean, Error>('isEnabledMarp', initialData, { fallbackData: false });
 };
@@ -208,6 +212,10 @@ export const useIsAiEnabled = (initialData?: boolean): SWRResponse<boolean, Erro
   return useContextSWR('isAiEnabled', initialData);
 };
 
+export const useLimitLearnablePageCountPerAssistant = (initialData?: number): SWRResponse<number, Error> => {
+  return useContextSWR('limitLearnablePageCountPerAssistant', initialData);
+};
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */

+ 0 - 1
apps/app/src/stores/page-listing.tsx

@@ -29,7 +29,6 @@ export const useSWRxPagesByPath = (path?: Nullable<string>): SWRResponse<IPageHa
   );
 };
 
-
 type RecentApiResult = {
   pages: IPageHasId[],
   totalCount: number,

+ 15 - 0
apps/app/src/stores/page.tsx

@@ -18,6 +18,7 @@ import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import type { IPagePathWithDescendantCount } from '~/interfaces/page';
 import type { IRecordApplicableGrant, IResCurrentGrantData } from '~/interfaces/page-grant';
 import {
   useCurrentPathname, useShareLinkId, useIsGuestUser, useIsReadOnlyUser,
@@ -362,3 +363,17 @@ export const useIsRevisionOutdated = (): SWRResponse<boolean, Error> => {
     ([, remoteRevisionId, currentRevisionId]) => { return remoteRevisionId !== currentRevisionId },
   );
 };
+
+
+export const useSWRxPagePathsWithDescendantCount = (
+    paths?: string[], userGroups?: string[], isIncludeEmpty?: boolean, includeAnyoneWithTheLink?: boolean,
+): SWRResponse<IPagePathWithDescendantCount[], Error> => {
+  return useSWR(
+    (paths != null && paths.length !== 0) ? ['/page/page-paths-with-descendant-count', paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink] : null,
+    ([endpoint, paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink]) => apiv3Get(
+      endpoint, {
+        paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink,
+      },
+    ).then(result => result.data.pagePathsWithDescendantCount),
+  );
+};

+ 0 - 30
apps/app/src/utils/is-deep-equal.ts

@@ -1,30 +0,0 @@
-export const isDeepEquals = <T extends object>(obj1: T, obj2: T): boolean => {
-  const typedKeys1 = Object.keys(obj1) as (keyof T)[];
-  const typedKeys2 = Object.keys(obj2) as (keyof T)[];
-
-  if (typedKeys1.length !== typedKeys2.length) {
-    return false;
-  }
-
-  return typedKeys1.every((key) => {
-    const val1 = obj1[key];
-    const val2 = obj2[key];
-
-    if (typeof val1 === 'object' && typeof val2 === 'object') {
-      if (val1 === null || val2 === null) {
-        return val1 === val2;
-      }
-
-      // if array
-      if (Array.isArray(val1) && Array.isArray(val2)) {
-        return val1.length === val2.length && val1.every((item, i) => val2[i] === item);
-      }
-
-      // if object
-      return isDeepEquals(val1, val2);
-    }
-
-    // if primitive
-    return val1 === val2;
-  });
-};

+ 3 - 2
bin/data-migrations/README.md

@@ -8,8 +8,9 @@
 git clone https://github.com/weseek/growi
 cd growi/bin/data-migrations
 
-NETWORK=growi_devcontainer_default \
-MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
+NETWORK=growi_devcontainer_default
+MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi
+
 docker run --rm \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \

+ 2 - 1
bin/data-migrations/src/migrations/v60x/index.js

@@ -2,6 +2,7 @@ const bracketlink = require('./bracketlink');
 const csv = require('./csv');
 const drawio = require('./drawio');
 const plantUML = require('./plantuml');
+const remarkGrowiDirective = require('./remark-growi-directive');
 const tsv = require('./tsv');
 
-module.exports = [...bracketlink, ...csv, ...drawio, ...plantUML, ...tsv];
+module.exports = [...bracketlink, ...csv, ...drawio, ...plantUML, ...tsv, ...remarkGrowiDirective];

+ 25 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/README.ja.md

@@ -0,0 +1,25 @@
+# remark-growi-directive
+
+以下の要領で replace する
+
+なお、`$foo()` は一例であり、`$bar()`, `$baz()`, `$foo-2()` など、さまざまな directive に対応する必要がある
+
+## 1. HTMLタグ内で `$foo()` を利用している箇所
+- 置換対象文章の詳細
+  - `$foo()`がHTMLタグ内かつ、当該`$foo()`記述の1行前が空行ではない場合に1行前に空行を挿入する
+  - `$foo()`がHTMLタグ内かつ、`$foo()`記述行の行頭にインデントがついている場合に当該行のインデントを削除する
+  - `$foo()`がHTMLタグ内かつ、当該`$foo()`記述の1行後のHTMLタグ記述行にインデントがついている場合にその行頭のインデントを削除する
+
+## 2. `$foo()` を利用している箇所
+- 置換対象文章の詳細
+  - `$foo()`の引数内で `filter=` あるいは `except=` に対する値に括弧 `()` を使用している場合、括弧を削除する
+    - before: `$foo()`(depth=2, filter=(AAA), except=(BBB))
+    - after: `$foo()`(depth=2, filter=AAA, except=BBB)
+
+## テストについて
+
+以下を満たす
+
+- input が `example.md` のとき、`example-expected.md` を出力する
+- input が `example-expected.md` のとき、`example-expected.md` を出力する (変更が起こらない)
+

+ 43 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/example-expected.md

@@ -0,0 +1,43 @@
+# Should not be replaced
+
+filter=(FOO), except=(word1|word2|word3)
+
+# Should be replaced
+
+<div class="container-fluid">
+    <div class="row">
+        <div>
+            <div>FOO</div>
+
+$foo(depth=2, filter=FOO)
+</div>
+        <div>
+            <div>BAR</div>
+
+$bar(depth=2, filter=BAR)
+</div>
+        <div>
+            <div>BAZ</div>
+
+$baz(depth=2, filter=BAZ)
+</div>
+    </div>
+    <hr>
+    <div class="row">
+        <div>
+            <div>FOO</div>
+
+$foo(depth=2, filter=FOO, except=word1|word2|word3)
+</div>
+        <div>
+            <div>BAR</div>
+
+$bar(depth=2, filter=BAR, except=word1|word2|word3)
+</div>
+        <div>
+                <div>BAZ</div>
+
+$baz(depth=2, filter=BAZ, except=word1|word2|word3)
+</div>
+    </div>
+</div>

+ 37 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/example.md

@@ -0,0 +1,37 @@
+# Should not be replaced
+
+filter=(FOO), except=(word1|word2|word3)
+
+# Should be replaced
+
+<div class="container-fluid">
+    <div class="row">
+        <div>
+            <div>FOO</div>
+            $foo(depth=2, filter=(FOO))
+        </div>
+        <div>
+            <div>BAR</div>
+            $bar(depth=2, filter=(BAR))
+        </div>
+        <div>
+            <div>BAZ</div>
+            $baz(depth=2, filter=(BAZ))
+        </div>
+    </div>
+    <hr>
+    <div class="row">
+        <div>
+            <div>FOO</div>
+            $foo(depth=2, filter=(FOO), except=(word1|word2|word3))
+        </div>
+        <div>
+            <div>BAR</div>
+            $bar(depth=2, filter=(BAR), except=(word1|word2|word3))
+        </div>
+        <div>
+                <div>BAZ</div>
+            $baz(depth=2, filter=(BAZ), except=(word1|word2|word3))
+        </div>
+    </div>
+</div>

+ 1 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/index.js

@@ -0,0 +1 @@
+module.exports = require('./remark-growi-directive');

+ 65 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.js

@@ -0,0 +1,65 @@
+/**
+ * @typedef {import('../../../types').MigrationModule} MigrationModule
+ */
+
+module.exports = [
+  /**
+   * Adjust line breaks and indentation for any directives within HTML tags
+   * @type {MigrationModule}
+   */
+  (body) => {
+    const lines = body.split('\n');
+    const directivePattern = /\$[\w\-_]+\([^)]*\)/;
+    let lastDirectiveLineIndex = -1;
+
+    for (let i = 0; i < lines.length; i++) {
+      if (directivePattern.test(lines[i])) {
+        const currentLine = lines[i];
+        const prevLine = i > 0 ? lines[i - 1] : '';
+        const nextLine = i < lines.length - 1 ? lines[i + 1] : '';
+
+        // Always remove indentation from directive line
+        lines[i] = currentLine.trimStart();
+
+        // Insert empty line only if:
+        // 1. Previous line contains an HTML tag (ends with >)
+        // 2. Previous line is not empty
+        // 3. Previous line is not a directive line
+        const isPrevLineHtmlTag = prevLine.match(/>[^\n]*$/) && !prevLine.match(directivePattern);
+        const isNotAfterDirective = i - 1 !== lastDirectiveLineIndex;
+
+        if (isPrevLineHtmlTag && prevLine.trim() !== '' && isNotAfterDirective) {
+          lines.splice(i, 0, '');
+          i++;
+        }
+
+        // Update the last directive line index
+        lastDirectiveLineIndex = i;
+
+        // Handle next line if it's a closing tag
+        if (nextLine.match(/^\s*<\//)) {
+          lines[i + 1] = nextLine.trimStart();
+        }
+      }
+    }
+
+    return lines.join('\n');
+  },
+
+  /**
+   * Remove unnecessary parentheses in directive arguments
+   * @type {MigrationModule}
+   */
+  (body) => {
+    // Detect and process directive-containing lines in multiline mode
+    return body.replace(/^.*\$[\w\-_]+\([^)]*\).*$/gm, (line) => {
+      // Convert filter=(value) to filter=value
+      let processedLine = line.replace(/filter=\(([^)]+)\)/g, 'filter=$1');
+
+      // Convert except=(value) to except=value
+      processedLine = processedLine.replace(/except=\(([^)]+)\)/g, 'except=$1');
+
+      return processedLine;
+    });
+  },
+];

+ 43 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.spec.js

@@ -0,0 +1,43 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { describe, test, expect } from 'vitest';
+
+import migrations from './remark-growi-directive';
+
+describe('remark-growi-directive migrations', () => {
+  test('should transform example.md to match example-expected.md', () => {
+    const input = fs.readFileSync(path.join(__dirname, 'example.md'), 'utf8');
+    const expected = fs.readFileSync(path.join(__dirname, 'example-expected.md'), 'utf8');
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(expected);
+  });
+
+  test('should not modify example-expected.md', () => {
+    const input = fs.readFileSync(path.join(__dirname, 'example-expected.md'), 'utf8');
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(input);
+  });
+
+  test('should handle various directive patterns', () => {
+    const input = `
+<div>
+    $foo(filter=(AAA))
+    $bar-2(except=(BBB))
+    $baz_3(filter=(CCC), except=(DDD))
+</div>`;
+
+    const expected = `
+<div>
+
+$foo(filter=AAA)
+$bar-2(except=BBB)
+$baz_3(filter=CCC, except=DDD)
+</div>`;
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(expected);
+  });
+});

+ 9 - 0
bin/vitest.config.ts

@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+  },
+});

+ 40 - 13
packages/core/src/utils/is-deep-equals.ts

@@ -1,30 +1,57 @@
-export const isDeepEquals = <T extends object>(obj1: T, obj2: T): boolean => {
-  const typedKeys1 = Object.keys(obj1) as (keyof T)[];
-  const typedKeys2 = Object.keys(obj2) as (keyof T)[];
+const isPrimitiveComparison = (value1: unknown, value2: unknown): boolean => {
+  return value1 === null || value2 === null || typeof value1 !== 'object' || typeof value2 !== 'object';
+};
+
+export const isDeepEquals = <T extends object>(obj1: T, obj2: T, visited = new WeakMap()): boolean => {
+  // If references are identical, return true
+  if (obj1 === obj2) {
+    return true;
+  }
+
+  // Use simple comparison for null or primitive values
+  if (isPrimitiveComparison(obj1, obj2)) {
+    return obj1 === obj2;
+  }
+
+  // Check for circular references
+  if (visited.has(obj1)) {
+    return visited.get(obj1) === obj2;
+  }
+  visited.set(obj1, obj2);
+
+  // Compare number of properties
+  const typedKeys1 = Object.keys(obj1) as (keyof typeof obj1)[];
+  const typedKeys2 = Object.keys(obj2) as (keyof typeof obj2)[];
 
   if (typedKeys1.length !== typedKeys2.length) {
     return false;
   }
 
+  // Compare all properties
   return typedKeys1.every((key) => {
     const val1 = obj1[key];
     const val2 = obj2[key];
 
-    if (typeof val1 === 'object' && typeof val2 === 'object') {
-      if (val1 === null || val2 === null) {
-        return val1 === val2;
+    // Handle arrays comparison
+    if (Array.isArray(val1) && Array.isArray(val2)) {
+      if (val1.length !== val2.length) {
+        return false;
       }
 
-      // if array
-      if (Array.isArray(val1) && Array.isArray(val2)) {
-        return val1.length === val2.length && val1.every((item, i) => val2[i] === item);
-      }
+      return val1.every((item, i) => {
+        if (!isPrimitiveComparison(item, val2[i])) {
+          return isDeepEquals(item, val2[i], visited);
+        }
+        return item === val2[i];
+      });
+    }
 
-      // if object
-      return isDeepEquals(val1, val2);
+    // Recursively compare objects
+    if (!isPrimitiveComparison(val1, val2)) {
+      return isDeepEquals(val1 as object, val2 as object, visited);
     }
 
-    // if primitive
+    // Compare primitive values
     return val1 === val2;
   });
 };

+ 1 - 1
packages/preset-themes/src/styles/classic.scss

@@ -45,7 +45,7 @@
   $body-secondary-bg-dark: $gray-800;
   $body-tertiary-color-dark: rgba($body-color-dark, .5);
   $body-tertiary-bg-dark: color.mix($gray-800, $gray-900, 50%);
-  $border-color-dark: var(--grw-highlight-200);
+  $border-color-dark: var(--grw-highlight-700);
   $link-color-dark: color.mix(#68829D, white, 80%);
 
   @import 'bootstrap/scss/variables';

+ 1 - 1
packages/preset-themes/src/styles/default.scss

@@ -47,7 +47,7 @@
   $body-secondary-bg-dark: $gray-800;
   $body-tertiary-color-dark: rgba($body-color-dark, .5);
   $body-tertiary-bg-dark: color.mix($gray-800, $gray-900, 50%);
-  $border-color-dark: var(--grw-highlight-200);
+  $border-color-dark: var(--grw-highlight-800);
   $link-color-dark: $gray-500;
 
   @import 'bootstrap/scss/variables';

+ 1 - 0
vitest.workspace.mts

@@ -1,6 +1,7 @@
 export default [
   'apps/*/vitest.config.ts',
   'apps/*/vitest.workspace.ts',
+  'bin/vitest.config.ts',
   'packages/*/vitest.config.ts',
   'packages/*/vitest.workspace.ts',
 ];