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

Merge branch 'feat/growi-ai-next' into fix/161676-is-deep-equal-util

Shun Miyazawa 1 год назад
Родитель
Сommit
cde22c9fd5
96 измененных файлов с 3069 добавлено и 905 удалено
  1. 1 1
      .devcontainer/devcontainer.json
  2. 2 0
      .npmrc
  3. 1 1
      apps/app/.env.production
  4. 1 1
      apps/app/docker/Dockerfile
  5. 10 9
      apps/app/public/static/locales/en_US/translation.json
  6. 10 9
      apps/app/public/static/locales/fr_FR/translation.json
  7. 10 9
      apps/app/public/static/locales/ja_JP/translation.json
  8. 10 8
      apps/app/public/static/locales/zh_CN/translation.json
  9. 2 2
      apps/app/src/client/components/PageControls/PageControls.tsx
  10. 9 5
      apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  11. 38 32
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  12. 6 66
      apps/app/src/client/components/ShortcutsModal.module.scss
  13. 213 121
      apps/app/src/client/components/ShortcutsModal.tsx
  14. 41 0
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.module.scss
  15. 27 11
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx
  16. 20 5
      apps/app/src/client/components/Sidebar/Sidebar.tsx
  17. 5 1
      apps/app/src/client/components/SystemVersion.module.scss
  18. 8 2
      apps/app/src/components/Layout/BasicLayout.tsx
  19. 0 369
      apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx
  20. 0 1
      apps/app/src/features/openai/chat/components/AiChatModal/index.ts
  21. 2 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss
  22. 451 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx
  23. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.module.scss
  24. 5 5
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx
  25. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/ResizableTextArea.tsx
  26. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  27. 12 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  28. 85 16
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  29. 100 31
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  30. 5 5
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx
  31. 12 6
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  32. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.tsx
  33. 125 42
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx
  34. 5 1
      apps/app/src/features/openai/client/services/ai-assistant.ts
  35. 7 0
      apps/app/src/features/openai/client/services/thread.ts
  36. 39 5
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  37. 13 0
      apps/app/src/features/openai/client/stores/message.tsx
  38. 26 0
      apps/app/src/features/openai/client/stores/thread.tsx
  39. 2 2
      apps/app/src/features/openai/interfaces/ai-assistant.ts
  40. 13 0
      apps/app/src/features/openai/interfaces/message.ts
  41. 18 0
      apps/app/src/features/openai/interfaces/thread-relation.ts
  42. 4 0
      apps/app/src/features/openai/interfaces/vector-store.ts
  43. 21 4
      apps/app/src/features/openai/server/models/ai-assistant.ts
  44. 11 8
      apps/app/src/features/openai/server/models/thread-relation.ts
  45. 3 6
      apps/app/src/features/openai/server/models/vector-store.ts
  46. 68 0
      apps/app/src/features/openai/server/routes/delete-thread.ts
  47. 73 0
      apps/app/src/features/openai/server/routes/get-messages.ts
  48. 62 0
      apps/app/src/features/openai/server/routes/get-threads.ts
  49. 12 0
      apps/app/src/features/openai/server/routes/index.ts
  50. 25 3
      apps/app/src/features/openai/server/routes/message.ts
  51. 2 2
      apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts
  52. 21 5
      apps/app/src/features/openai/server/routes/thread.ts
  53. 16 0
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  54. 4 0
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  55. 15 1
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  56. 3 0
      apps/app/src/features/openai/server/services/normalize-data/normalize-thread-relation-expired-at/normalize-thread-relation-expired-at.integ.ts
  57. 311 25
      apps/app/src/features/openai/server/services/openai.ts
  58. 5 5
      apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts
  59. 48 0
      apps/app/src/features/openai/server/utils/generate-glob-patterns.spec.ts
  60. 28 0
      apps/app/src/features/openai/server/utils/generate-glob-patterns.ts
  61. 6 0
      apps/app/src/features/openai/utils/determine-share-scope.ts
  62. 5 0
      apps/app/src/interfaces/page.ts
  63. 12 0
      apps/app/src/pages/login/index.page.tsx
  64. 53 3
      apps/app/src/server/models/page.ts
  65. 1 1
      apps/app/src/server/routes/apiv3/import.js
  66. 1 2
      apps/app/src/server/routes/apiv3/page/create-page.ts
  67. 76 0
      apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts
  68. 293 8
      apps/app/src/server/routes/apiv3/page/index.ts
  69. 1 2
      apps/app/src/server/routes/apiv3/page/update-page.ts
  70. 232 7
      apps/app/src/server/routes/apiv3/pages/index.js
  71. 26 4
      apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  72. 7 17
      apps/app/src/server/service/page/index.ts
  73. 0 1
      apps/app/src/stores/page-listing.tsx
  74. 15 0
      apps/app/src/stores/page.tsx
  75. 0 12
      apps/app/src/styles/style-app.scss
  76. 1 1
      apps/slackbot-proxy/docker/Dockerfile
  77. 3 2
      bin/data-migrations/README.md
  78. 1 1
      bin/data-migrations/src/migrations/v60x/csv.js
  79. 2 1
      bin/data-migrations/src/migrations/v60x/index.js
  80. 25 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/README.ja.md
  81. 43 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/example-expected.md
  82. 37 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/example.md
  83. 1 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/index.js
  84. 65 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.js
  85. 43 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.spec.js
  86. 1 1
      bin/data-migrations/src/migrations/v60x/tsv.js
  87. 9 0
      bin/vitest.config.ts
  88. 2 3
      package.json
  89. 25 0
      packages/core-styles/scss/helpers/_modifier-keys.scss
  90. 1 1
      packages/core/src/utils/page-path-utils/index.ts
  91. 7 1
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.module.scss
  92. 1 1
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.tsx
  93. 1 1
      packages/preset-themes/src/styles/classic.scss
  94. 1 1
      packages/preset-themes/src/styles/default.scss
  95. 3 2
      packages/remark-attachment-refs/src/server/routes/refs.ts
  96. 1 0
      vitest.workspace.mts

+ 1 - 1
.devcontainer/devcontainer.json

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

+ 2 - 0
.npmrc

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

+ 1 - 1
apps/app/.env.production

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

+ 1 - 1
apps/app/docker/Dockerfile

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

+ 10 - 9
apps/app/public/static/locales/en_US/translation.json

@@ -456,7 +456,7 @@
   "modal_shortcuts": {
     "global": {
       "title": "Global shortcuts",
-      "Open/Close shortcut help": "Open/Close<br>shortcut help",
+      "Open/Close shortcut help": "Open/Close Shortcut Help",
       "Edit Page": "Edit Page",
       "Create Page": "Create Page",
       "Search": "Search",
@@ -470,11 +470,14 @@
       "Indent": "Indent",
       "Outdent": "Outdent",
       "Save Page": "Save Page",
-      "Delete Line": "Delete Line"
-    },
-    "commentform": {
-      "title": "Comment Form shortcuts",
-      "Post": "Post"
+      "Only Editor": "(Editor Only)",
+      "Delete Line": "Delete Line",
+      "Search in Editor": "Search in Editor",
+      "Move Line": "Move Line",
+      "Copy Line": "Copy Line",
+      "Toggle Line": "Toggle Line Comment",
+      "Insert Line": "Insert Line",
+      "Post Comment": "(Post Comment)"
     }
   },
   "modal_resolve_conflict": {
@@ -488,9 +491,7 @@
     "latest_revision": "theirs",
     "selected_editable_revision": "Selected Page Body (Editable)"
   },
-  "modal_aichat": {
-    "title": "Knowledge Assistant",
-    "title_beta_label": "(Beta)",
+  "sidebar_aichat": {
     "placeholder": "Ask me anything.",
     "summary_mode_label": "Summary mode",
     "summary_mode_help": "Concise answer within 2-3 sentences",

+ 10 - 9
apps/app/public/static/locales/fr_FR/translation.json

@@ -458,18 +458,21 @@
       "Show Contributors": "Voir contributeurs",
       "MirrorMode": "Mode mirroir",
       "Konami Code": "Code Konami",
-      "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
+      "konami_code_url": "https://fr.wikipedia.org/wiki/Code_Konami"
     },
     "editor": {
       "title": "Raccourcis d'édition",
       "Indent": "Indentation",
       "Outdent": "Retrait",
       "Save Page": "Sauvegarder la page",
-      "Delete Line": "Supprimer la ligne"
-    },
-    "commentform": {
-      "title": "Raccourcis de commentaires",
-      "Post": "Poster"
+      "Only Editor": "(Éditeur uniquement)",
+      "Delete Line": "Supprimer la ligne",
+      "Search in Editor": "Rechercher dans l'éditeur",
+      "Move Line": "Déplacer la ligne",
+      "Copy Line": "Copier la ligne",
+      "Toggle Line": "Commenter/Décommenter la ligne",
+      "Insert Line": "Insérer une ligne",
+      "Post Comment": "(Publier le commentaire)"
     }
   },
   "modal_resolve_conflict": {
@@ -483,9 +486,7 @@
     "latest_revision": "les autres",
     "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
   },
-  "modal_aichat": {
-    "title": "Assistant de Connaissance",
-    "title_beta_label": "(Bêta)",
+  "sidebar_aichat": {
     "placeholder": "Demandez-moi n'importe quoi.",
     "summary_mode_label": "Mode résumé",
     "summary_mode_help": "Réponse concise en 2-3 phrases",

+ 10 - 9
apps/app/public/static/locales/ja_JP/translation.json

@@ -489,7 +489,7 @@
   "modal_shortcuts": {
     "global": {
       "title": "グローバルショートカット",
-      "Open/Close shortcut help": "ショートカットヘルプ<br>表示/非表示",
+      "Open/Close shortcut help": "ショートカットヘルプ<br>表示/非表示",
       "Edit Page": "ページ編集",
       "Create Page": "ページ作成",
       "Search": "検索",
@@ -503,11 +503,14 @@
       "Indent": "インデント",
       "Outdent": "左インデント",
       "Save Page": "保存",
-      "Delete Line": "行削除"
-    },
-    "commentform": {
-      "title": "コメントフォームショートカット",
-      "Post": "投稿"
+      "Only Editor": "(エディターのみ)",
+      "Delete Line": "行削除",
+      "Search in Editor": "エディター内検索",
+      "Move Line": "行の移動",
+      "Copy Line": "行のコピー",
+      "Toggle Line": "行の非表示化",
+      "Insert Line": "行を挿入",
+      "Post Comment": "(コメント投稿)"
     }
   },
   "modal_resolve_conflict": {
@@ -521,9 +524,7 @@
     "latest_revision": "最新の本文",
     "selected_editable_revision": "保存するページ本文(編集可能)"
   },
-  "modal_aichat": {
-    "title": "ナレッジアシスタント",
-    "title_beta_label": "(ベータ)",
+  "sidebar_aichat": {
     "placeholder": "ききたいことを入力してください",
     "summary_mode_label": "要約モード",
     "summary_mode_help": "2~3文以内の簡潔な回答",

+ 10 - 8
apps/app/public/static/locales/zh_CN/translation.json

@@ -451,6 +451,7 @@
       "Create Page": "创建页面",
       "Search": "搜索",
       "Show Contributors": "显示参与者",
+      "MirrorMode": "镜像模式",
       "Konami Code": "Konami Code",
       "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
     },
@@ -459,11 +460,14 @@
       "Indent": "缩进",
       "Outdent": "回退缩进",
       "Save Page": "保存页面",
-      "Delete Line": "删除行"
-    },
-    "commentform": {
-      "title": "注释窗体快捷方式",
-      "Post": "提交"
+      "Only Editor": "(仅编辑器)",
+      "Delete Line": "删除行",
+      "Search in Editor": "编辑器内搜索",
+      "Move Line": "移动行",
+      "Copy Line": "复制行",
+      "Toggle Line": "注释/取消注释行",
+      "Insert Line": "插入行",
+      "Post Comment": "(发表评论)"
     }
   },
   "modal_resolve_conflict": {
@@ -477,9 +481,7 @@
     "latest_revision": "最新页面正文",
     "selected_editable_revision": "选定的可编辑页面正文"
   },
-  "modal_aichat": {
-    "title": "知识助手",
-    "title_beta_label": "(测试版)",
+  "sidebar_aichat": {
     "placeholder": "问我任何问题。",
     "summary_mode_label": "摘要模式",
     "summary_mode_help": "简洁回答在2-3句话内",

+ 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 RagSearchButton from '~/features/openai/client/components/RagSearchButton';
 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 />
+          {/* <RagSearchButton /> */}
         </>
       )}
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 0 - 369
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx

@@ -1,369 +0,0 @@
-import type { KeyboardEvent } from 'react';
-import React, { useCallback, useState } from 'react';
-
-import { useForm, Controller } from 'react-hook-form';
-import { useTranslation } from 'react-i18next';
-import {
-  Collapse,
-  Modal, ModalBody, ModalFooter, ModalHeader,
-  UncontrolledTooltip, Input,
-} from 'reactstrap';
-
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/toastr';
-import { useGrowiCloudUri } from '~/stores-universal/context';
-import loggerFactory from '~/utils/logger';
-
-import { SelectedPageList } from '../../../client/components/Common/SelectedPageList';
-import { useRagSearchModal } from '../../../client/stores/rag-search';
-import { MessageErrorCode, StreamErrorCode } from '../../../interfaces/message-error';
-
-import { MessageCard } from './MessageCard';
-import { ResizableTextarea } from './ResizableTextArea';
-
-import styles from './AiChatModal.module.scss';
-
-const moduleClass = styles['grw-aichat-modal'] ?? '';
-
-const logger = loggerFactory('growi:clinet:components:RagSearchModal');
-
-
-type Message = {
-  id: string,
-  content: string,
-  isUserMessage?: boolean,
-}
-
-type FormData = {
-  input: string;
-  summaryMode?: boolean;
-};
-
-const AiChatModalSubstance = (): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  const form = useForm<FormData>({
-    defaultValues: {
-      input: '',
-      summaryMode: true,
-    },
-  });
-
-  const [threadId, setThreadId] = useState<string | undefined>();
-  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
-  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
-  const [errorMessage, setErrorMessage] = useState<string | undefined>();
-  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
-
-  const { data: growiCloudUri } = useGrowiCloudUri();
-
-  const isGenerating = generatingAnswerMessage != null;
-
-  const submit = useCallback(async(data: FormData) => {
-    // do nothing when the assistant is generating an answer
-    if (isGenerating) {
-      return;
-    }
-
-    // do nothing when the input is empty
-    if (data.input.trim().length === 0) {
-      return;
-    }
-
-    const { length: logLength } = messageLogs;
-
-    // add user message to the logs
-    const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
-    setMessageLogs(msgs => [...msgs, newUserMessage]);
-
-    // reset form
-    form.reset({ input: '', summaryMode: data.summaryMode });
-    setErrorMessage(undefined);
-
-    // add an empty assistant message
-    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
-    setGeneratingAnswerMessage(newAnswerMessage);
-
-    // create thread
-    let currentThreadId = threadId;
-    if (threadId == null) {
-      try {
-        const res = await apiv3Post('/openai/thread');
-        const thread = res.data.thread;
-
-        setThreadId(thread.id);
-        currentThreadId = thread.id;
-      }
-      catch (err) {
-        logger.error(err.toString());
-        toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
-      }
-    }
-
-    // post message
-    try {
-      const response = await fetch('/_api/v3/openai/message', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ userMessage: data.input, threadId: currentThreadId, summaryMode: data.summaryMode }),
-      });
-
-      if (!response.ok) {
-        const resJson = await response.json();
-        if ('errors' in resJson) {
-          // eslint-disable-next-line @typescript-eslint/no-unused-vars
-          const errors = resJson.errors.map(({ message }) => message).join(', ');
-          form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
-
-          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
-          if (hasThreadIdNotSetError) {
-            toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
-          }
-        }
-        setGeneratingAnswerMessage(undefined);
-        return;
-      }
-
-      const reader = response.body?.getReader();
-      const decoder = new TextDecoder('utf-8');
-
-      const read = async() => {
-        if (reader == null) return;
-
-        const { done, value } = await reader.read();
-
-        // add assistant message to the logs
-        if (done) {
-          setGeneratingAnswerMessage((generatingAnswerMessage) => {
-            if (generatingAnswerMessage == null) return;
-            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
-            return undefined;
-          });
-          return;
-        }
-
-        const chunk = decoder.decode(value);
-
-        const textValues: string[] = [];
-        const lines = chunk.split('\n\n');
-        lines.forEach((line) => {
-          const trimedLine = line.trim();
-          if (trimedLine.startsWith('data:')) {
-            const data = JSON.parse(line.replace('data: ', ''));
-            textValues.push(data.content[0].text.value);
-          }
-          else if (trimedLine.startsWith('error:')) {
-            const error = JSON.parse(line.replace('error: ', ''));
-            logger.error(error.errorMessage);
-            form.setError('input', { type: 'manual', message: error.message });
-
-            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
-              setErrorMessage(growiCloudUri != null ? 'modal_aichat.budget_exceeded_for_growi_cloud' : 'modal_aichat.budget_exceeded');
-            }
-          }
-        });
-
-
-        // append text values to the assistant message
-        setGeneratingAnswerMessage((prevMessage) => {
-          if (prevMessage == null) return;
-          return {
-            ...prevMessage,
-            content: prevMessage.content + textValues.join(''),
-          };
-        });
-
-        read();
-      };
-      read();
-    }
-    catch (err) {
-      logger.error(err.toString());
-      form.setError('input', { type: 'manual', message: err.toString() });
-    }
-
-  }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
-
-  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
-    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
-      form.handleSubmit(submit)();
-    }
-  };
-
-  return (
-    <>
-      <ModalBody className="pb-0 pt-3 pt-lg-4 px-3 px-lg-4">
-        <div className="d-flex mb-4">
-          <Input type="select" className="border rounded">
-            <option>
-              GROWI AI の機能について
-            </option>
-          </Input>
-
-          <button type="button" className="btn btn-outline-secondary bg-transparent ms-2">
-            <span className="fs-5 material-symbols-outlined">edit</span>
-          </button>
-
-          <button type="button" className="btn btn-outline-secondary bg-transparent ms-2">
-            <span className="fs-5 material-symbols-outlined">add</span>
-          </button>
-        </div>
-
-        <div className="text-muted mb-4">
-          ここに設定したアシスタントの説明が入ります。ここに設定したアシスタントの説明が入ります。
-        </div>
-
-        <div className="mb-4">
-          <p className="mb-2">アシスタントへの指示</p>
-          <div className="p-3 alert alert-primary">
-            <p className="mb-0 text-break">
-              あなたは生成AIの専門家および、リサーチャーです。ナレッジベースのWikiツールである GROWIのAI機能に関する情報を提示したり、使われている技術に関する説明をしたりします。
-            </p>
-          </div>
-        </div>
-
-        <div className="d-flex align-items-center mb-2">
-          <p className="mb-0">参照するページ</p>
-          <span className="ms-1 fs-5 material-symbols-outlined text-secondary">help</span>
-        </div>
-        <SelectedPageList selectedPages={[
-          { page: { _id: '1', path: '/Project/GROWI/新機能/GROWI AI' }, isIncludeSubPage: true },
-          { page: { _id: '2', path: '/AI導入検討/調査' }, isIncludeSubPage: false },
-        ]}
-        />
-
-        <div className="vstack gap-4 pb-2">
-          { messageLogs.map(message => (
-            <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
-          )) }
-          { generatingAnswerMessage != null && (
-            <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
-          )}
-          { messageLogs.length > 0 && (
-            <div className="d-flex justify-content-center">
-              <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
-                {t('modal_aichat.caution_against_hallucination')}
-              </span>
-            </div>
-          )}
-        </div>
-      </ModalBody>
-
-      <ModalFooter className="flex-column align-items-start pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
-        <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
-          <div className="flex-fill hstack gap-2 align-items-end m-0">
-            <Controller
-              name="input"
-              control={form.control}
-              render={({ field }) => (
-                <ResizableTextarea
-                  {...field}
-                  required
-                  className="form-control textarea-ask"
-                  style={{ resize: 'none' }}
-                  rows={1}
-                  placeholder={!form.formState.isSubmitting ? t('modal_aichat.placeholder') : ''}
-                  onKeyDown={keyDownHandler}
-                  disabled={form.formState.isSubmitting}
-                />
-              )}
-            />
-            <button
-              type="submit"
-              className="btn btn-submit no-border"
-              disabled={form.formState.isSubmitting || isGenerating}
-            >
-              <span className="material-symbols-outlined">send</span>
-            </button>
-          </div>
-          <div className="form-check form-switch">
-            <input
-              id="swSummaryMode"
-              type="checkbox"
-              role="switch"
-              className="form-check-input"
-              {...form.register('summaryMode')}
-              disabled={form.formState.isSubmitting || isGenerating}
-            />
-            <label className="form-check-label" htmlFor="swSummaryMode">
-              {t('modal_aichat.summary_mode_label')}
-            </label>
-
-            {/* Help */}
-            <a
-              id="tooltipForHelpOfSummaryMode"
-              role="button"
-              className="ms-1"
-            >
-              <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
-            </a>
-            <UncontrolledTooltip
-              target="tooltipForHelpOfSummaryMode"
-            >
-              {t('modal_aichat.summary_mode_help')}
-            </UncontrolledTooltip>
-          </div>
-        </form>
-
-
-        {form.formState.errors.input != null && (
-          <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
-            <div>
-              <span className="material-symbols-outlined text-danger me-2">error</span>
-              <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('modal_aichat.error_message') }</span>
-            </div>
-
-            <button
-              type="button"
-              className="btn btn-link text-secondary p-0"
-              aria-expanded={isErrorDetailCollapsed}
-              onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
-            >
-              <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
-                chevron_right
-              </span>
-              <span className="small">{t('modal_aichat.show_error_detail')}</span>
-            </button>
-
-            <Collapse isOpen={isErrorDetailCollapsed}>
-              <div className="ms-2">
-                <div className="">
-                  <div className="text-secondary small">
-                    {form.formState.errors.input?.message}
-                  </div>
-                </div>
-              </div>
-            </Collapse>
-          </div>
-        )}
-      </ModalFooter>
-    </>
-  );
-};
-
-
-export const AiChatModal = (): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
-
-  const isOpened = ragSearchModalData?.isOpened ?? false;
-
-  return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRagSearchModal} className={moduleClass} scrollable>
-
-      <ModalHeader tag="h4" toggle={closeRagSearchModal} className="pe-4">
-        <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
-        <span className="fw-bold">{t('modal_aichat.title')}</span>
-        <span className="fs-5 text-body-secondary ms-3">{t('modal_aichat.title_beta_label')}</span>
-      </ModalHeader>
-
-      { isOpened && (
-        <AiChatModalSubstance />
-      ) }
-
-    </Modal>
-  );
-};

+ 0 - 1
apps/app/src/features/openai/chat/components/AiChatModal/index.ts

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

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

@@ -2,7 +2,7 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 
-.grw-aichat-modal :global {
+.grw-ai-assistant-chat-sidebar :global {
 
   .textarea-ask {
     max-height: 30vh;
@@ -13,9 +13,8 @@
   }
 }
 
-
 // == Colors
-.grw-aichat-modal :global {
+.grw-ai-assistant-chat-sidebar :global {
   .growi-ai-chat-icon {
     color: growi-official-colors.$growi-ai-purple;
   }
@@ -24,4 +23,3 @@
     @include btn-muted.colorize(bs.$purple, bs.$purple);
   }
 }
-

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

@@ -0,0 +1,451 @@
+import type { KeyboardEvent } from 'react';
+import {
+  type FC, memo, useRef, useEffect, useState, useCallback,
+} from 'react';
+
+import { useForm, Controller } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { Collapse, UncontrolledTooltip } from 'reactstrap';
+import SimpleBar from 'simplebar-react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/toastr';
+import { MessageErrorCode, StreamErrorCode } from '~/features/openai/interfaces/message-error';
+import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-relation';
+import { useGrowiCloudUri } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
+
+import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
+import { useSWRMUTxMessages } from '../../../stores/message';
+import { useSWRMUTxThreads } from '../../../stores/thread';
+
+import { MessageCard } from './MessageCard';
+import { ResizableTextarea } from './ResizableTextArea';
+
+import styles from './AiAssistantChatSidebar.module.scss';
+
+const logger = loggerFactory('growi:openai:client:components:AiAssistantChatSidebar');
+
+const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
+
+const RIGHT_SIDEBAR_WIDTH = 500;
+
+type Message = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}
+
+type FormData = {
+  input: string;
+  summaryMode?: boolean;
+};
+
+type AiAssistantChatSidebarSubstanceProps = {
+  aiAssistantData: AiAssistantHasId;
+  threadData?: IThreadRelationHasId;
+  closeAiAssistantChatSidebar: () => void
+}
+
+const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
+  const {
+    aiAssistantData, threadData, closeAiAssistantChatSidebar,
+  } = props;
+
+  const [currentThreadTitle, setCurrentThreadTitle] = useState<string | undefined>(threadData?.title);
+  const [currentThreadId, setCurrentThreadId] = useState<string | undefined>(threadData?.threadId);
+  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+  const [errorMessage, setErrorMessage] = useState<string | undefined>();
+  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
+
+  const { t } = useTranslation();
+  const { data: growiCloudUri } = useGrowiCloudUri();
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData._id);
+  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantData._id, threadData?.threadId);
+
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+      summaryMode: true,
+    },
+  });
+
+  useEffect(() => {
+    const fetchAndSetMessageData = async() => {
+      const messageData = await mutateMessageData();
+      if (messageData != null) {
+        const normalizedMessageData = messageData.data.filter(message => message.metadata?.shouldHideMessage !== 'true');
+
+        setMessageLogs(() => {
+          return normalizedMessageData.map((message, index) => (
+            {
+              id: index.toString(),
+              content: message.content[0].type === 'text' ? message.content[0].text.value : '',
+              isUserMessage: message.role === 'user',
+            }
+          ));
+        });
+      }
+    };
+
+    if (threadData != null) {
+      fetchAndSetMessageData();
+    }
+  }, [mutateMessageData, threadData]);
+
+  const isGenerating = generatingAnswerMessage != null;
+  const submit = useCallback(async(data: FormData) => {
+    // do nothing when the assistant is generating an answer
+    if (isGenerating) {
+      return;
+    }
+
+    // do nothing when the input is empty
+    if (data.input.trim().length === 0) {
+      return;
+    }
+
+    const { length: logLength } = messageLogs;
+
+    // add user message to the logs
+    const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
+    setMessageLogs(msgs => [...msgs, newUserMessage]);
+
+    // reset form
+    form.reset({ input: '', summaryMode: data.summaryMode });
+    setErrorMessage(undefined);
+
+    // add an empty assistant message
+    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
+    setGeneratingAnswerMessage(newAnswerMessage);
+
+    // create thread
+    let currentThreadId_ = currentThreadId;
+    if (currentThreadId_ == null) {
+      try {
+        const res = await apiv3Post<IThreadRelationHasId>('/openai/thread', { aiAssistantId: aiAssistantData._id, initialUserMessage: newUserMessage.content });
+        const thread = res.data;
+
+        setCurrentThreadId(thread.threadId);
+        setCurrentThreadTitle(thread.title);
+        currentThreadId_ = thread.threadId;
+
+        // No need to await because data is not used
+        mutateThreadData();
+      }
+      catch (err) {
+        logger.error(err.toString());
+        toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+      }
+    }
+
+    // post message
+    try {
+      const response = await fetch('/_api/v3/openai/message', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          userMessage: data.input, threadId: currentThreadId_, summaryMode: data.summaryMode, aiAssistantId: aiAssistantData._id,
+        }),
+      });
+
+      if (!response.ok) {
+        const resJson = await response.json();
+        if ('errors' in resJson) {
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          const errors = resJson.errors.map(({ message }) => message).join(', ');
+          form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
+
+          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
+          if (hasThreadIdNotSetError) {
+            toastError(t('sidebar_aichat.failed_to_create_or_retrieve_thread'));
+          }
+        }
+        setGeneratingAnswerMessage(undefined);
+        return;
+      }
+
+      const reader = response.body?.getReader();
+      const decoder = new TextDecoder('utf-8');
+
+      const read = async() => {
+        if (reader == null) return;
+
+        const { done, value } = await reader.read();
+
+        // add assistant message to the logs
+        if (done) {
+          setGeneratingAnswerMessage((generatingAnswerMessage) => {
+            if (generatingAnswerMessage == null) return;
+            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
+            return undefined;
+          });
+          return;
+        }
+
+        const chunk = decoder.decode(value);
+
+        const textValues: string[] = [];
+        const lines = chunk.split('\n\n');
+        lines.forEach((line) => {
+          const trimedLine = line.trim();
+          if (trimedLine.startsWith('data:')) {
+            const data = JSON.parse(line.replace('data: ', ''));
+            textValues.push(data.content[0].text.value);
+          }
+          else if (trimedLine.startsWith('error:')) {
+            const error = JSON.parse(line.replace('error: ', ''));
+            logger.error(error.errorMessage);
+            form.setError('input', { type: 'manual', message: error.message });
+
+            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
+              setErrorMessage(growiCloudUri != null ? 'sidebar_aichat.budget_exceeded_for_growi_cloud' : 'sidebar_aichat.budget_exceeded');
+            }
+          }
+        });
+
+
+        // append text values to the assistant message
+        setGeneratingAnswerMessage((prevMessage) => {
+          if (prevMessage == null) return;
+          return {
+            ...prevMessage,
+            content: prevMessage.content + textValues.join(''),
+          };
+        });
+
+        read();
+      };
+      read();
+    }
+    catch (err) {
+      logger.error(err.toString());
+      form.setError('input', { type: 'manual', message: err.toString() });
+    }
+
+  }, [isGenerating, messageLogs, form, currentThreadId, aiAssistantData._id, mutateThreadData, t, growiCloudUri]);
+
+  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
+    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+      form.handleSubmit(submit)();
+    }
+  };
+
+  return (
+    <>
+      <div className="d-flex flex-column vh-100">
+        <div className="d-flex align-items-center p-3 border-bottom">
+          <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
+          <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{currentThreadTitle ?? aiAssistantData.name}</h5>
+          <button
+            type="button"
+            className="btn btn-link p-0 border-0"
+            onClick={closeAiAssistantChatSidebar}
+          >
+            <span className="material-symbols-outlined">close</span>
+          </button>
+        </div>
+        <div className="p-4 d-flex flex-column gap-4 vh-100">
+
+
+          { currentThreadId != null
+            ? (
+              <div className="vstack gap-4 pb-2">
+                { messageLogs.map(message => (
+                  <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
+                )) }
+                { generatingAnswerMessage != null && (
+                  <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
+                )}
+                { messageLogs.length > 0 && (
+                  <div className="d-flex justify-content-center">
+                    <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
+                      {t('sidebar_aichat.caution_against_hallucination')}
+                    </span>
+                  </div>
+                )}
+              </div>
+            )
+            : (
+              <>
+                <p className="fs-6 text-body-secondary mb-0">
+                  {aiAssistantData.description}
+                </p>
+
+                <div>
+                  <p className="text-body-secondary">アシスタントへの指示</p>
+                  <div className="card bg-body-tertiary border-0">
+                    <div className="card-body p-3">
+                      <p className="fs-6 text-body-secondary mb-0">
+                        {aiAssistantData.additionalInstruction}
+                      </p>
+                    </div>
+                  </div>
+                </div>
+
+                <div>
+                  <div className="d-flex align-items-center">
+                    <p className="text-body-secondary mb-0">参照するページ</p>
+                  </div>
+                  <div className="d-flex flex-column gap-1">
+                    { aiAssistantData.pagePathPatterns.map(pagePathPattern => (
+                      <a
+                        key={pagePathPattern}
+                        href="#"
+                        className="fs-6 text-body-secondary text-decoration-none"
+                      >
+                        {pagePathPattern}
+                      </a>
+                    ))}
+                  </div>
+                </div>
+
+              </>
+            )
+          }
+
+          <div className="mt-auto">
+            <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
+              <div className="flex-fill hstack gap-2 align-items-end m-0">
+                <Controller
+                  name="input"
+                  control={form.control}
+                  render={({ field }) => (
+                    <ResizableTextarea
+                      {...field}
+                      required
+                      className="form-control textarea-ask"
+                      style={{ resize: 'none' }}
+                      rows={1}
+                      placeholder={!form.formState.isSubmitting ? t('sidebar_aichat.placeholder') : ''}
+                      onKeyDown={keyDownHandler}
+                      disabled={form.formState.isSubmitting}
+                    />
+                  )}
+                />
+                <button
+                  type="submit"
+                  className="btn btn-submit no-border"
+                  disabled={form.formState.isSubmitting || isGenerating}
+                >
+                  <span className="material-symbols-outlined">send</span>
+                </button>
+              </div>
+              <div className="form-check form-switch">
+                <input
+                  id="swSummaryMode"
+                  type="checkbox"
+                  role="switch"
+                  className="form-check-input"
+                  {...form.register('summaryMode')}
+                  disabled={form.formState.isSubmitting || isGenerating}
+                />
+                <label className="form-check-label" htmlFor="swSummaryMode">
+                  {t('sidebar_aichat.summary_mode_label')}
+                </label>
+
+                {/* Help */}
+                <a
+                  id="tooltipForHelpOfSummaryMode"
+                  role="button"
+                  className="ms-1"
+                >
+                  <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
+                </a>
+                <UncontrolledTooltip
+                  target="tooltipForHelpOfSummaryMode"
+                >
+                  {t('sidebar_aichat.summary_mode_help')}
+                </UncontrolledTooltip>
+              </div>
+            </form>
+
+            {form.formState.errors.input != null && (
+              <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
+                <div>
+                  <span className="material-symbols-outlined text-danger me-2">error</span>
+                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('sidebar_aichat.error_message') }</span>
+                </div>
+
+                <button
+                  type="button"
+                  className="btn btn-link text-body-secondary p-0"
+                  aria-expanded={isErrorDetailCollapsed}
+                  onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
+                >
+                  <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
+                    chevron_right
+                  </span>
+                  <span className="small">{t('sidebar_aichat.show_error_detail')}</span>
+                </button>
+
+                <Collapse isOpen={isErrorDetailCollapsed}>
+                  <div className="ms-2">
+                    <div className="">
+                      <div className="text-body-secondary small">
+                        {form.formState.errors.input?.message}
+                      </div>
+                    </div>
+                  </div>
+                </Collapse>
+              </div>
+            )}
+
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
+  const sidebarRef = useRef<HTMLDivElement>(null);
+  const sidebarScrollerRef = useRef<HTMLDivElement>(null);
+
+  const { data: aiAssistantChatSidebarData, close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+
+  const aiAssistantData = aiAssistantChatSidebarData?.aiAssistantData;
+  const threadData = aiAssistantChatSidebarData?.threadData;
+  const isOpened = aiAssistantChatSidebarData?.isOpened && aiAssistantData != null;
+
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (isOpened && sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
+        closeAiAssistantChatSidebar();
+      }
+    };
+
+    document.addEventListener('mousedown', handleClickOutside);
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+    };
+  }, [closeAiAssistantChatSidebar, isOpened]);
+
+  if (!isOpened) {
+    return <></>;
+  }
+
+  return (
+    <div
+      ref={sidebarRef}
+      className={`position-fixed top-0 end-0 h-100 border-start bg-body shadow-sm ${moduleClass}`}
+      style={{ zIndex: 1500, width: `${RIGHT_SIDEBAR_WIDTH}px` }}
+      data-testid="grw-right-sidebar"
+    >
+      <SimpleBar
+        scrollableNodeProps={{ ref: sidebarScrollerRef }}
+        className="h-100 position-relative"
+        autoHide
+      >
+        <AiAssistantChatSidebarSubstance
+          threadData={threadData}
+          aiAssistantData={aiAssistantData}
+          closeAiAssistantChatSidebar={closeAiAssistantChatSidebar}
+        />
+      </SimpleBar>
+    </div>
+  );
+});

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


+ 5 - 5
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/MessageCard.tsx

@@ -6,7 +6,7 @@ import ReactMarkdown from 'react-markdown';
 
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 
-import { useRagSearchModal } from '../../../client/stores/rag-search';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 
 import styles from './MessageCard.module.scss';
 
@@ -27,11 +27,11 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
 const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  const { close: closeRagSearchModal } = useRagSearchModal();
+  const { close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
 
   const onClick = useCallback(() => {
-    closeRagSearchModal();
-  }, [closeRagSearchModal]);
+    closeAiAssistantChatSidebar();
+  }, [closeAiAssistantChatSidebar]);
 
   return (
     <NextLink href={props.href} onClick={onClick} className="link-primary">
@@ -55,7 +55,7 @@ const AssistantMessageCard = ({ children }: { children: string }): JSX.Element =
             )
             : (
               <span className="text-thinking">
-                {t('modal_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+                {t('sidebar_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
               </span>
             )
           }

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


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

@@ -6,9 +6,9 @@ import type { IPageForItem } from '~/interfaces/page';
 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 = {

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

@@ -1,4 +1,6 @@
-import React, { useCallback, useState } from 'react';
+import React, {
+  useCallback, useState, useEffect,
+} from 'react';
 
 import {
   ModalBody, Input, Label,
@@ -50,6 +52,15 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
   const [isSelectUserGroupModalOpen, setIsSelectUserGroupModalOpen] = useState(false);
   const [selectedUserGroupType, setSelectedUserGroupType] = useState<ScopeType>(ScopeType.ACCESS);
 
+  useEffect(() => {
+    setIsShared(() => {
+      if (selectedShareScope !== AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) {
+        return true;
+      }
+      return selectedShareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE && selectedAccessScope !== AiAssistantAccessScope.OWNER;
+    });
+  }, [isShared, selectedAccessScope, selectedShareScope]);
+
   const changeShareToggleHandler = useCallback(() => {
     setIsShared((prev) => {
       if (prev) { // if isShared === true

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

@@ -1,36 +1,49 @@
-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 { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant';
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores-universal/context';
 
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
 
 import { ShareScopeWarningModal } from './ShareScopeWarningModal';
 
 type Props = {
+  shouldEdit: boolean;
   name: string;
   description: string;
   instruction: string;
-  shareScope: AiAssistantShareScope
+  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 => {
   const {
+    shouldEdit,
     name,
     description,
     instruction,
     shareScope,
+    accessScope,
+    selectedPages,
+    selectedUserGroupsForAccessScope,
+    selectedUserGroupsForShareScope,
     onNameChange,
     onDescriptionChange,
-    onCreateAiAssistant,
+    onUpsertAiAssistant,
   } = props;
 
   const { t } = useTranslation();
@@ -39,6 +52,18 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
 
   const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] = useState(false);
 
+  const canUpsert = name !== '' && selectedPages.length !== 0;
+
+  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
@@ -46,22 +71,51 @@ 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 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 (
     <>
       <ModalHeader tag="h4" toggle={closeAiAssistantManagementModal} className="pe-4">
         <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">ai_assistant</span>
-        <span className="fw-bold">新規アシスタントの追加</span> {/* TODO i18n */}
+        <span className="fw-bold">{t(shouldEdit ? 'アシスタントの更新' : '新規アシスタントの追加')}</span> {/* TODO i18n */}
       </ModalHeader>
 
       <div className="px-4">
@@ -114,7 +168,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>
@@ -136,15 +190,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}>アシスタントを作成する</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}
       />
     </>
   );

+ 100 - 31
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -1,17 +1,22 @@
-import React, { useCallback, useState } from 'react';
+import React, {
+  useCallback, useState, useEffect,
+} from 'react';
 
-import type { IGrantedGroup } 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 { createAiAssistant } from '../../../services/ai-assistant';
+import { createAiAssistant, updateAiAssistant } from '../../../services/ai-assistant';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode, useSWRxAiAssistants } from '../../../stores/ai-assistant';
 
 import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction';
@@ -33,14 +38,50 @@ const convertToGrantedGroups = (selectedGroups: PopulatedGrantedGroup[]): IGrant
   }));
 };
 
+// IGrantedGroup[] -> PopulatedGrantedGroup[]
+const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): PopulatedGrantedGroup[] => {
+  const populatedGrantedGroups = selectedGroups.filter(group => isPopulated(group.item)) as PopulatedGrantedGroup[];
+  return populatedGrantedGroups;
+};
+
+const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectedPage[] => {
+  return pagePathPatterns.map((pagePathPattern) => {
+    const isIncludeSubPage = pagePathPattern.endsWith('/*');
+    const path = isIncludeSubPage ? pagePathPattern.slice(0, -2) : pagePathPattern;
+    const page = pagePathsWithDescendantCount.find(page => page.path === path);
+    return {
+      page: page ?? { path },
+      isIncludeSubPage,
+    };
+  });
+};
+
+const removeGlobPath = (pagePathPattens?: string[]): string[] => {
+  if (pagePathPattens == null) {
+    return [];
+  }
+  return pagePathPattens.map((pagePathPattern) => {
+    return pagePathPattern.endsWith('/*') ? pagePathPattern.slice(0, -2) : pagePathPattern;
+  });
+};
+
 const AiAssistantManagementModalSubstance = (): JSX.Element => {
   // Hooks
   const { t } = useTranslation();
   const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
   const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
+  const { data: pagePathsWithDescendantCount } = useSWRxPagePathsWithDescendantCount(
+    removeGlobPath(aiAssistantManagementModalData?.aiAssistantData?.pagePathPatterns) ?? null,
+    undefined,
+    true,
+    true,
+  );
 
+  const aiAssistant = aiAssistantManagementModalData?.aiAssistantData;
+  const shouldEdit = aiAssistant != null;
   const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
 
+
   // States
   const [name, setName] = useState<string>('');
   const [description, setDescription] = useState<string>('');
@@ -52,6 +93,27 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
 
 
+  // Effects
+  useEffect(() => {
+    if (shouldEdit) {
+      setName(aiAssistant.name);
+      setDescription(aiAssistant.description);
+      setInstruction(aiAssistant.additionalInstruction);
+      setSelectedShareScope(aiAssistant.shareScope);
+      setSelectedAccessScope(aiAssistant.accessScope);
+      setSelectedUserGroupsForShareScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForShareScope ?? []));
+      setSelectedUserGroupsForAccessScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForAccessScope ?? []));
+    }
+  // eslint-disable-next-line max-len
+  }, [aiAssistant?.accessScope, aiAssistant?.additionalInstruction, aiAssistant?.description, aiAssistant?.grantedGroupsForAccessScope, aiAssistant?.grantedGroupsForShareScope, aiAssistant?.name, aiAssistant?.pagePathPatterns, aiAssistant?.shareScope, shouldEdit]);
+
+  useEffect(() => {
+    if (shouldEdit && pagePathsWithDescendantCount != null) {
+      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns, pagePathsWithDescendantCount));
+    }
+  }, [aiAssistant?.pagePathPatterns, pagePathsWithDescendantCount, shouldEdit]);
+
+
   /*
   *  For AiAssistantManagementHome methods
   */
@@ -63,46 +125,48 @@ 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))
         .filter((path): path is string => path !== undefined && path !== null);
 
-      const grantedGroupsForShareScope = convertToGrantedGroups(selectedUserGroupsForShareScope);
-      const grantedGroupsForAccessScope = convertToGrantedGroups(selectedUserGroupsForAccessScope);
+      const grantedGroupsForShareScope = selectedShareScope === AiAssistantShareScope.GROUPS
+        ? convertToGrantedGroups(selectedUserGroupsForShareScope)
+        : undefined;
+
+      const grantedGroupsForAccessScope = selectedAccessScope === AiAssistantAccessScope.GROUPS
+        ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
+        : undefined;
 
-      await createAiAssistant({
+      const reqBody = {
         name,
         description,
         additionalInstruction: instruction,
         pagePathPatterns,
         shareScope: selectedShareScope,
         accessScope: selectedAccessScope,
-        grantedGroupsForShareScope: selectedShareScope === AiAssistantShareScope.GROUPS ? grantedGroupsForShareScope : undefined,
-        grantedGroupsForAccessScope: selectedAccessScope === AiAssistantAccessScope.GROUPS ? grantedGroupsForAccessScope : undefined,
-      });
-
-      toastSuccess('アシスタントを作成しました');
+        grantedGroupsForShareScope,
+        grantedGroupsForAccessScope,
+      };
+
+      if (shouldEdit) {
+        await updateAiAssistant(aiAssistant._id, reqBody);
+      }
+      else {
+        await createAiAssistant(reqBody);
+      }
+
+      toastSuccess(shouldEdit ? 'アシスタントが更新されました' : 'アシスタントが作成されました');
       mutateAiAssistants();
       closeAiAssistantManagementModal();
     }
     catch (err) {
-      toastError('アシスタントの作成に失敗しました');
+      toastError(shouldEdit ? 'アシスタントの更新に失敗しました' : 'アシスタントの作成に失敗しました');
       logger.error(err);
     }
-  }, [
-    mutateAiAssistants,
-    closeAiAssistantManagementModal,
-    description,
-    instruction,
-    name,
-    selectedAccessScope,
-    selectedPages,
-    selectedShareScope,
-    selectedUserGroupsForAccessScope,
-    selectedUserGroupsForShareScope,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, mutateAiAssistants, closeAiAssistantManagementModal, aiAssistant?._id]);
 
 
   /*
@@ -145,14 +209,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]);
 
 
@@ -172,13 +236,18 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       <TabContent activeTab={pageMode}>
         <TabPane tabId={AiAssistantManagementModalPageMode.HOME}>
           <AiAssistantManagementHome
+            shouldEdit={shouldEdit}
             name={name}
             description={description}
             shareScope={selectedShareScope}
+            accessScope={selectedAccessScope}
             instruction={instruction}
+            selectedPages={selectedPages}
+            selectedUserGroupsForShareScope={selectedUserGroupsForShareScope}
+            selectedUserGroupsForAccessScope={selectedUserGroupsForAccessScope}
             onNameChange={changeNameHandler}
             onDescriptionChange={changeDescriptionHandler}
-            onCreateAiAssistant={createAiAssistantHandler}
+            onUpsertAiAssistant={upsertAiAssistantHandler}
           />
         </TabPane>
 

+ 5 - 5
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,7 +16,7 @@ const SelectedPageListBase: React.FC<SelectedPageListProps> = ({ selectedPages,
     <div className="mb-3">
       {selectedPages.map(({ page, isIncludeSubPage }) => (
         <div
-          key={page._id}
+          key={page.path}
           className="mb-2 d-flex justify-content-between align-items-center bg-light rounded py-2 px-3"
         >
           <div className="d-flex align-items-center overflow-hidden">
@@ -25,11 +25,11 @@ const SelectedPageListBase: React.FC<SelectedPageListProps> = ({ selectedPages,
               : <>{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)}
+              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>

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

@@ -17,7 +17,7 @@ export const AiAssistantContent = (): JSX.Element => {
       <button
         type="button"
         className="btn btn-outline-secondary px-3 d-flex align-items-center mb-4"
-        onClick={open}
+        onClick={() => open()}
       >
         <span className="material-symbols-outlined fs-5 me-2">add</span>
         <span className="fw-normal">アシスタントを追加する</span>

+ 125 - 42
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -3,62 +3,80 @@ import React, { useCallback, useState } from 'react';
 import { getIdStringForRef } from '@growi/core';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { IThreadRelationHasId } from '~/features/openai/interfaces/thread-relation';
 import { useCurrentUser } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
 
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { deleteAiAssistant } from '../../../services/ai-assistant';
+import { deleteThread } from '../../../services/thread';
+import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
 
 import styles from './AiAssistantTree.module.scss';
 
-const moduleClass = styles['ai-assistant-tree-item'] ?? '';
+const logger = loggerFactory('growi:openai:client:components:AiAssistantTree');
 
-type Thread = {
-  _id: string;
-  name: string;
-}
+const moduleClass = styles['ai-assistant-tree-item'] ?? '';
 
-const dummyThreads: Thread[] = [
-  { _id: '1', name: 'thread1' },
-  { _id: '2', name: 'thread2' },
-  { _id: '3', name: 'thread3' },
-];
 
+/*
+*  ThreadItem
+*/
 type ThreadItemProps = {
-  thread: Thread;
+  threadData: IThreadRelationHasId
+  aiAssistantData: AiAssistantHasId;
+  onThreadClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onThreadDelete: () => void;
 };
 
 const ThreadItem: React.FC<ThreadItemProps> = ({
-  thread,
+  threadData, aiAssistantData, onThreadClick, onThreadDelete,
 }) => {
 
-  const deleteThreadHandler = useCallback(() => {
-    // TODO: https://redmine.weseek.co.jp/issues/161490
-  }, []);
+  const deleteThreadHandler = useCallback(async() => {
+    try {
+      await deleteThread({ aiAssistantId: aiAssistantData._id, threadRelationId: threadData._id });
+      toastSuccess('スレッドを削除しました');
+      onThreadDelete();
+    }
+    catch (err) {
+      logger.error(err);
+      toastError('スレッドの削除に失敗しました');
+    }
+  }, [aiAssistantData._id, onThreadDelete, threadData._id]);
 
   const openChatHandler = useCallback(() => {
-    // TODO: https://redmine.weseek.co.jp/issues/159530
-  }, []);
+    onThreadClick(aiAssistantData, threadData);
+  }, [aiAssistantData, onThreadClick, threadData]);
 
   return (
     <li
       role="button"
       className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1 ps-5"
-      onClick={openChatHandler}
+      onClick={(e) => {
+        e.stopPropagation();
+        openChatHandler();
+      }}
     >
       <div>
         <span className="material-symbols-outlined fs-5">chat</span>
       </div>
 
       <div className="grw-ai-assistant-title-anchor ps-1">
-        <p className="text-truncate m-auto">{thread.name}</p>
+        <p className="text-truncate m-auto">{threadData?.title ?? 'Untitled thread'}</p>
       </div>
 
       <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
         <button
           type="button"
           className="btn btn-link text-secondary p-0"
-          onClick={deleteThreadHandler}
+          onClick={(e) => {
+            e.stopPropagation();
+            deleteThreadHandler();
+          }}
         >
           <span className="material-symbols-outlined fs-5">delete</span>
         </button>
@@ -68,8 +86,43 @@ const ThreadItem: React.FC<ThreadItemProps> = ({
 };
 
 
+/*
+*  ThreadItems
+*/
+type ThreadItemsProps = {
+  aiAssistantData: AiAssistantHasId;
+  onThreadClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onThreadDelete: () => void;
+};
+
+const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClick, onThreadDelete }) => {
+  const { data: threads } = useSWRxThreads(aiAssistantData._id);
+
+  if (threads == null || threads.length === 0) {
+    return <p className="text-secondary ms-5">スレッドが存在しません</p>;
+  }
+
+  return (
+    <div className="grw-ai-assistant-item-children">
+      {threads.map(thread => (
+        <ThreadItem
+          key={thread._id}
+          threadData={thread}
+          aiAssistantData={aiAssistantData}
+          onThreadClick={onThreadClick}
+          onThreadDelete={onThreadDelete}
+        />
+      ))}
+    </div>
+  );
+};
+
+
+/*
+*  AiAssistantItem
+*/
 const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => {
-  const determinedSharedScope = shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE ? accessScope : shareScope;
+  const determinedSharedScope = determineShareScope(shareScope, accessScope);
   switch (determinedSharedScope) {
     case AiAssistantShareScope.OWNER:
       return 'lock';
@@ -77,31 +130,42 @@ 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;
   aiAssistant: AiAssistantHasId;
-  threads: Thread[];
+  onEditClick: (aiAssistantData: AiAssistantHasId) => void;
+  onItemClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
   onDeleted?: () => void;
 };
 
 const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   currentUserId,
   aiAssistant,
-  threads,
+  onEditClick,
+  onItemClick,
   onDeleted,
 }) => {
   const [isThreadsOpened, setIsThreadsOpened] = useState(false);
 
-  const openChatHandler = useCallback(() => {
-    // TODO: https://redmine.weseek.co.jp/issues/159530
-  }, []);
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistant._id);
 
-  const openThreadsHandler = useCallback(() => {
+  const openManagementModalHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onEditClick(aiAssistantData);
+  }, [onEditClick]);
+
+  const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onItemClick(aiAssistantData);
+  }, [onItemClick]);
+
+  const openThreadsHandler = useCallback(async() => {
+    mutateThreadData();
     setIsThreadsOpened(toggle => !toggle);
-  }, []);
+  }, [mutateThreadData]);
 
   const deleteAiAssistantHandler = useCallback(async() => {
     try {
@@ -110,6 +174,7 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
       toastSuccess('アシスタントを削除しました');
     }
     catch (err) {
+      logger.error(err);
       toastError('アシスタントの削除に失敗しました');
     }
   }, [aiAssistant._id, onDeleted]);
@@ -119,14 +184,20 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   return (
     <>
       <li
-        onClick={openChatHandler}
+        onClick={(e) => {
+          e.stopPropagation();
+          openChatHandler(aiAssistant);
+        }}
         role="button"
         className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
       >
         <div className="d-flex justify-content-center">
           <button
             type="button"
-            onClick={openThreadsHandler}
+            onClick={(e) => {
+              e.stopPropagation();
+              openThreadsHandler();
+            }}
             className={`grw-ai-assistant-triangle-btn btn px-0 ${isThreadsOpened ? 'grw-ai-assistant-open' : ''}`}
           >
             <div className="d-flex justify-content-center">
@@ -148,13 +219,20 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
             <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>
             <button
               type="button"
               className="btn btn-link text-secondary p-0"
-              onClick={deleteAiAssistantHandler}
+              onClick={(e) => {
+                e.stopPropagation();
+                deleteAiAssistantHandler();
+              }}
             >
               <span className="material-symbols-outlined fs-5">delete</span>
             </button>
@@ -162,20 +240,21 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
         )}
       </li>
 
-      {isThreadsOpened && threads.length > 0 && (
-        <div className="grw-ai-assistant-item-children">
-          {threads.map(thread => (
-            <ThreadItem
-              key={thread._id}
-              thread={thread}
-            />
-          ))}
-        </div>
-      )}
+      { isThreadsOpened && (
+        <ThreadItems
+          aiAssistantData={aiAssistant}
+          onThreadClick={onItemClick}
+          onThreadDelete={mutateThreadData}
+        />
+      ) }
     </>
   );
 };
 
+
+/*
+*  AiAssistantTree
+*/
 type AiAssistantTreeProps = {
   aiAssistants: AiAssistantHasId[];
   onDeleted?: () => void;
@@ -183,6 +262,9 @@ type AiAssistantTreeProps = {
 
 export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
+  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
+
   return (
     <ul className={`list-group ${moduleClass}`}>
       {aiAssistants.map(assistant => (
@@ -190,7 +272,8 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           key={assistant._id}
           currentUserId={currentUser?._id}
           aiAssistant={assistant}
-          threads={dummyThreads}
+          onEditClick={openAiAssistantManagementModal}
+          onItemClick={openAiAssistantChatSidebar}
           onDeleted={onDeleted}
         />
       ))}

+ 5 - 1
apps/app/src/features/openai/client/services/ai-assistant.ts

@@ -1,4 +1,4 @@
-import { apiv3Post, apiv3Delete } from '~/client/util/apiv3-client';
+import { apiv3Post, apiv3Put, apiv3Delete } from '~/client/util/apiv3-client';
 
 import type { UpsertAiAssistantData } from '../../interfaces/ai-assistant';
 
@@ -6,6 +6,10 @@ export const createAiAssistant = async(body: UpsertAiAssistantData): Promise<voi
   await apiv3Post('/openai/ai-assistant', body);
 };
 
+export const updateAiAssistant = async(id: string, body: UpsertAiAssistantData): Promise<void> => {
+  await apiv3Put(`/openai/ai-assistant/${id}`, body);
+};
+
 export const deleteAiAssistant = async(id: string): Promise<void> => {
   await apiv3Delete(`/openai/ai-assistant/${id}`);
 };

+ 7 - 0
apps/app/src/features/openai/client/services/thread.ts

@@ -0,0 +1,7 @@
+import { apiv3Delete } from '~/client/util/apiv3-client';
+
+import type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
+
+export const deleteThread = async(params: IApiv3DeleteThreadParams): Promise<void> => {
+  await apiv3Delete(`/openai/thread/${params.aiAssistantId}/${params.threadRelationId}`);
+};

+ 39 - 5
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -6,7 +6,8 @@ import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 
-import { type AccessibleAiAssistantsHasId } from '../../interfaces/ai-assistant';
+import { type AccessibleAiAssistantsHasId, type AiAssistantHasId } from '../../interfaces/ai-assistant';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
 
 export const AiAssistantManagementModalPageMode = {
   HOME: 'home',
@@ -20,10 +21,11 @@ type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageM
 type AiAssistantManagementModalStatus = {
   isOpened: boolean,
   pageMode?: AiAssistantManagementModalPageMode,
+  aiAssistantData?: AiAssistantHasId;
 }
 
 type AiAssistantManagementModalUtils = {
-  open(): void
+  open(aiAssistantData?: AiAssistantHasId): void
   close(): void
   changePageMode(pageType: AiAssistantManagementModalPageMode): void
 }
@@ -36,10 +38,10 @@ export const useAiAssistantManagementModal = (
 
   return {
     ...swrResponse,
-    open: useCallback(() => { swrResponse.mutate({ isOpened: true }) }, [swrResponse]),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+    open: useCallback((aiAssistantData) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false, aiAssistantData: undefined }), [swrResponse]),
     changePageMode: useCallback((pageMode: AiAssistantManagementModalPageMode) => {
-      swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode });
+      swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode, aiAssistantData: swrResponse.data?.aiAssistantData });
     }, [swrResponse]),
   };
 };
@@ -51,3 +53,35 @@ export const useSWRxAiAssistants = (): SWRResponse<AccessibleAiAssistantsHasId,
     ([endpoint]) => apiv3Get(endpoint).then(response => response.data.accessibleAiAssistants),
   );
 };
+
+
+type AiAssistantChatSidebarStatus = {
+  isOpened: boolean,
+  aiAssistantData?: AiAssistantHasId,
+  threadData?: IThreadRelationHasId,
+}
+
+type AiAssistantChatSidebarUtils = {
+  open(
+    aiAssistantData: AiAssistantHasId,
+    threadData?: IThreadRelationHasId,
+  ): void
+  close(): void
+}
+
+export const useAiAssistantChatSidebar = (
+    status?: AiAssistantChatSidebarStatus,
+): SWRResponse<AiAssistantChatSidebarStatus, Error> & AiAssistantChatSidebarUtils => {
+  const initialStatus = { isOpened: false };
+  const swrResponse = useSWRStatic<AiAssistantChatSidebarStatus, Error>('AiAssistantChatSidebar', status, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    open: useCallback(
+      (aiAssistantData: AiAssistantHasId, threadData: IThreadRelationHasId) => {
+        swrResponse.mutate({ isOpened: true, aiAssistantData, threadData });
+      }, [swrResponse],
+    ),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+  };
+};

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

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

+ 26 - 0
apps/app/src/features/openai/client/stores/thread.tsx

@@ -0,0 +1,26 @@
+import { type SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+
+const getKey = (aiAssistantId: string) => [`/openai/threads/${aiAssistantId}`];
+
+export const useSWRxThreads = (aiAssistantId: string): SWRResponse<IThreadRelationHasId[], Error> => {
+  const key = getKey(aiAssistantId);
+  return useSWRImmutable<IThreadRelationHasId[]>(
+    key,
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+  );
+};
+
+
+export const useSWRMUTxThreads = (aiAssistantId: string): SWRMutationResponse<IThreadRelationHasId[], Error> => {
+  const key = getKey(aiAssistantId);
+  return useSWRMutation(
+    key,
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.threads),
+  );
+};

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

@@ -2,7 +2,7 @@ import type {
   IGrantedGroup, IUser, Ref, HasObjectId,
 } from '@growi/core';
 
-import type { VectorStore } from '../server/models/vector-store';
+import type { IVectorStore } from './vector-store';
 
 /*
 *  Objects
@@ -31,7 +31,7 @@ export interface AiAssistant {
   description: string
   additionalInstruction: string
   pagePathPatterns: string[],
-  vectorStore: Ref<VectorStore>
+  vectorStore: Ref<IVectorStore>
   owner: Ref<IUser>
   grantedGroupsForShareScope?: IGrantedGroup[]
   grantedGroupsForAccessScope?: IGrantedGroup[]

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

@@ -0,0 +1,13 @@
+import type OpenAI from 'openai';
+
+export const shouldHideMessageKey = 'shouldHideMessage';
+
+export type MessageWithCustomMetaData = Omit<OpenAI.Beta.Threads.Messages.MessagesPage, 'data'> & {
+  data: Array<OpenAI.Beta.Threads.Message & {
+    metadata?: {
+      shouldHideMessage?: 'true' | 'false',
+    }
+  }>;
+};
+
+export type MessageListParams = OpenAI.Beta.Threads.Messages.MessageListParams;

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

@@ -0,0 +1,18 @@
+import type { IUser, Ref, HasObjectId } from '@growi/core';
+
+import type { IVectorStore } from './vector-store';
+
+export interface IThreadRelation {
+  userId: Ref<IUser>
+  vectorStore: Ref<IVectorStore>
+  threadId: string;
+  title?: string;
+  expiredAt: Date;
+}
+
+export type IThreadRelationHasId = IThreadRelation & HasObjectId;
+
+export type IApiv3DeleteThreadParams = {
+  aiAssistantId: string
+  threadRelationId: string;
+}

+ 4 - 0
apps/app/src/features/openai/interfaces/vector-store.ts

@@ -0,0 +1,4 @@
+export interface IVectorStore {
+  vectorStoreId: string
+  isDeleted: boolean
+}

+ 21 - 4
apps/app/src/features/openai/server/models/ai-assistant.ts

@@ -4,11 +4,13 @@ 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 {}
 
-type AiAssistantModel = Model<AiAssistantDocument>
-
+interface AiAssistantModel extends Model<AiAssistantDocument> {
+  findByPagePaths(pagePaths: string[]): Promise<AiAssistantDocument[]>;
+}
 
 /*
  * Schema Definition
@@ -53,7 +55,7 @@ const schema = new Schema<AiAssistantDocument>(
         },
         item: {
           type: Schema.Types.ObjectId,
-          refPath: 'grantedGroups.type',
+          refPath: 'grantedGroupsForShareScope.type',
           required: true,
           index: true,
         },
@@ -75,7 +77,7 @@ const schema = new Schema<AiAssistantDocument>(
         },
         item: {
           type: Schema.Types.ObjectId,
-          refPath: 'grantedGroups.type',
+          refPath: 'grantedGroupsForAccessScope.type',
           required: true,
           index: true,
         },
@@ -103,4 +105,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');
+
+  return assistants;
+};
+
 export default getOrCreateModel<AiAssistantDocument, AiAssistantModel>('AiAssistant', schema);

+ 11 - 8
apps/app/src/features/openai/server/models/thread-relation.ts

@@ -1,22 +1,17 @@
 import { addDays } from 'date-fns';
-import type mongoose from 'mongoose';
 import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
+import type { IThreadRelation } from '../../interfaces/thread-relation';
+
 const DAYS_UNTIL_EXPIRATION = 3;
 
 const generateExpirationDate = (): Date => {
   return addDays(new Date(), DAYS_UNTIL_EXPIRATION);
 };
 
-interface ThreadRelation {
-  userId: mongoose.Types.ObjectId;
-  threadId: string;
-  expiredAt: Date;
-}
-
-interface ThreadRelationDocument extends ThreadRelation, Document {
+export interface ThreadRelationDocument extends IThreadRelation, Document {
   updateThreadExpiration(): Promise<void>;
 }
 
@@ -30,11 +25,19 @@ const schema = new Schema<ThreadRelationDocument, ThreadRelationModel>({
     ref: 'User',
     required: true,
   },
+  vectorStore: {
+    type: Schema.Types.ObjectId,
+    ref: 'VectorStore',
+    required: true,
+  },
   threadId: {
     type: String,
     required: true,
     unique: true,
   },
+  title: {
+    type: String,
+  },
   expiredAt: {
     type: Date,
     default: generateExpirationDate,

+ 3 - 6
apps/app/src/features/openai/server/models/vector-store.ts

@@ -2,16 +2,13 @@ import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-export interface VectorStore {
-  vectorStoreId: string
-  isDeleted: boolean
-}
+import type { IVectorStore } from '../../interfaces/vector-store';
 
-export interface VectorStoreDocument extends VectorStore, Document {
+export interface VectorStoreDocument extends IVectorStore, Document {
   markAsDeleted(): Promise<void>
 }
 
-type VectorStoreModel = Model<VectorStore>
+type VectorStoreModel = Model<VectorStoreDocument>;
 
 const schema = new Schema<VectorStoreDocument, VectorStoreModel>({
   vectorStoreId: {

+ 68 - 0
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -0,0 +1,68 @@
+import { type IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, param } from 'express-validator';
+import { isHttpError } from 'http-errors';
+
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:delete-thread');
+
+type DeleteThreadFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = IApiv3DeleteThreadParams;
+
+type Req = Request<ReqParams, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('aiAssistantId').isMongoId().withMessage('threadId is required'),
+    param('threadRelationId').isMongoId().withMessage('threadRelationId is required'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { aiAssistantId, threadRelationId } = req.params;
+      const { user } = req;
+
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, user);
+      if (!isAiAssistantUsable) {
+        return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+      }
+
+      try {
+        const deletedThreadRelation = await openaiService.deleteThread(threadRelationId);
+        return res.apiv3({ deletedThreadRelation });
+      }
+      catch (err) {
+        logger.error(err);
+
+        if (isHttpError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message), err.status);
+        }
+
+        return res.apiv3Err(new ErrorV3('Failed to delete thread'));
+      }
+    },
+  ];
+};

+ 73 - 0
apps/app/src/features/openai/server/routes/get-messages.ts

@@ -0,0 +1,73 @@
+import { type IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, param } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:get-message');
+
+type GetMessagesFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParam = {
+  threadId: string,
+  aiAssistantId: string,
+  before?: string,
+  after?: string,
+  limit?: number,
+}
+
+type Req = Request<ReqParam, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const getMessagesFactory: GetMessagesFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('threadId').isString().withMessage('threadId must be string'),
+    param('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
+    param('limit').optional().isInt().withMessage('limit must be integer'),
+    param('before').optional().isString().withMessage('before must be string'),
+    param('after').optional().isString().withMessage('after must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      try {
+        const {
+          threadId, aiAssistantId, limit, before, after,
+        } = req.params;
+
+        const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+
+        const messages = await openaiService.getMessageData(threadId, req.user.lang, {
+          limit, before, after, order: 'asc',
+        });
+
+        return res.apiv3({ messages });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to get messages'));
+      }
+    },
+  ];
+};

+ 62 - 0
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -0,0 +1,62 @@
+import { type IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, param } from 'express-validator';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:get-threads');
+
+type GetThreadsFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  aiAssistantId: string,
+}
+
+type Req = Request<ReqParams, Response, undefined> & {
+  user: IUserHasId,
+}
+
+export const getThreadsFactory: GetThreadsFactory = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      try {
+        const { aiAssistantId } = req.params;
+
+        const isAiAssistantUsable = openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+        if (!isAiAssistantUsable) {
+          return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+        }
+
+        const vectorStoreRelation = await openaiService.getVectorStoreRelation(aiAssistantId);
+        const threads = await openaiService.getThreads(vectorStoreRelation._id);
+
+        return res.apiv3({ threads });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to get threads'));
+      }
+    },
+  ];
+};

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

@@ -27,10 +27,22 @@ export const factory = (crowi: Crowi): express.Router => {
       router.post('/thread', createThreadHandlersFactory(crowi));
     });
 
+    import('./get-threads').then(({ getThreadsFactory }) => {
+      router.get('/threads/:aiAssistantId', getThreadsFactory(crowi));
+    });
+
+    import('./delete-thread').then(({ deleteThreadFactory }) => {
+      router.delete('/thread/:aiAssistantId/:threadRelationId', deleteThreadFactory(crowi));
+    });
+
     import('./message').then(({ postMessageHandlersFactory }) => {
       router.post('/message', postMessageHandlersFactory(crowi));
     });
 
+    import('./get-messages').then(({ getMessagesFactory }) => {
+      router.get('/messages/:aiAssistantId/:threadId', getMessagesFactory(crowi));
+    });
+
     import('./ai-assistant').then(({ createAiAssistantFactory }) => {
       router.post('/ai-assistant', createAiAssistantFactory(crowi));
     });

+ 25 - 3
apps/app/src/features/openai/server/routes/message.ts

@@ -13,9 +13,12 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
+import { shouldHideMessageKey } from '../../interfaces/message';
 import { MessageErrorCode, type StreamErrorCode } from '../../interfaces/message-error';
+import AiAssistantModel from '../models/ai-assistant';
 import { openaiClient } from '../services/client';
 import { getStreamErrorCode } from '../services/getStreamErrorCode';
+import { getOpenaiService } from '../services/openai';
 import { replaceAnnotationWithPageLink } from '../services/replace-annotation-with-page-link';
 
 import { certifyAiService } from './middlewares/certify-ai-service';
@@ -25,6 +28,7 @@ const logger = loggerFactory('growi:routes:apiv3:openai:message');
 
 type ReqBody = {
   userMessage: string,
+  aiAssistantId: string,
   threadId?: string,
   summaryMode?: boolean,
 }
@@ -44,26 +48,40 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
       .withMessage('userMessage must be string')
       .notEmpty()
       .withMessage('userMessage must be set'),
+    body('aiAssistantId').isMongoId().withMessage('aiAssistantId must be string'),
     body('threadId').optional().isString().withMessage('threadId must be string'),
   ];
 
   return [
     accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
-
-      const threadId = req.body.threadId;
+      const { aiAssistantId, threadId } = req.body;
 
       if (threadId == null) {
         return res.apiv3Err(new ErrorV3('threadId is not set', MessageErrorCode.THREAD_ID_IS_NOT_SET), 400);
       }
 
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      const isAiAssistantUsable = await openaiService.isAiAssistantUsable(aiAssistantId, req.user);
+      if (!isAiAssistantUsable) {
+        return res.apiv3Err(new ErrorV3('The specified AI assistant is not usable'), 400);
+      }
+
+      const aiAssistant = await AiAssistantModel.findById(aiAssistantId);
+      if (aiAssistant == null) {
+        return res.apiv3Err(new ErrorV3('AI assistant not found'), 404);
+      }
+
       let stream: AssistantStream;
 
       try {
         const assistant = await getOrCreateChatAssistant();
 
         const thread = await openaiClient.beta.threads.retrieve(threadId);
-
         stream = openaiClient.beta.threads.runs.stream(thread.id, {
           assistant_id: assistant.id,
           additional_messages: [
@@ -72,9 +90,13 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
               content: req.body.summaryMode
                 ? 'Turn on summary mode: I will try to answer concisely, aiming for 1-3 sentences.'
                 : 'I will turn off summary mode and answer.',
+              metadata: {
+                [shouldHideMessageKey]: 'true',
+              },
             },
             { role: 'user', content: req.body.userMessage },
           ],
+          additional_instructions: aiAssistant.additionalInstruction,
         });
 
       }

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

@@ -1,5 +1,5 @@
 import { GroupType } from '@growi/core';
-import { isGrobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
+import { isGlobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { type ValidationChain, body } from 'express-validator';
 
 import { AiAssistantShareScope, AiAssistantAccessScope } from '../../../interfaces/ai-assistant';
@@ -41,7 +41,7 @@ export const upsertAiAssistantValidator: ValidationChain[] = [
 
       // check if the value is a grob pattern path
       if (value.includes('*')) {
-        return isGrobPatternPath(value) && isCreatablePage(value.replace('*', ''));
+        return isGlobPatternPath(value) && isCreatablePage(value.replace('*', ''));
       }
 
       return isCreatablePage(value);

+ 21 - 5
apps/app/src/features/openai/server/routes/thread.ts

@@ -17,7 +17,13 @@ import { certifyAiService } from './middlewares/certify-ai-service';
 
 const logger = loggerFactory('growi:routes:apiv3:openai:thread');
 
-type CreateThreadReq = Request<undefined, ApiV3Response, { threadId?: string }> & { user: IUserHasId };
+type ReqBody = {
+  aiAssistantId: string,
+  threadId?: string,
+  initialUserMessage?: string,
+}
+
+type CreateThreadReq = Request<undefined, ApiV3Response, ReqBody> & { user: IUserHasId };
 
 type CreateThreadFactory = (crowi: Crowi) => RequestHandler[];
 
@@ -25,7 +31,9 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(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'),
   ];
 
   return [
@@ -38,10 +46,18 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
       }
 
       try {
-        const filterdThreadId = req.body.threadId != null ? filterXSS(req.body.threadId) : undefined;
-        // const vectorStore = await openaiService?.getOrCreateVectorStoreForPublicScope();
-        // const thread = await openaiService?.getOrCreateThread(req.user._id, vectorStore?.vectorStoreId, filterdThreadId);
-        return res.apiv3({ });
+        const { aiAssistantId, threadId, 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.getOrCreateThread(req.user._id, vectorStoreRelation, filteredThreadId, initialUserMessage);
+        return res.apiv3(thread);
       }
       catch (err) {
         logger.error(err);

+ 16 - 0
apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts

@@ -3,6 +3,9 @@ import type OpenAI from 'openai';
 import { AzureOpenAI } from 'openai';
 import { type Uploadable } from 'openai/uploads';
 
+import type { MessageListParams } from '../../../interfaces/message';
+
+
 import type { IOpenaiClientDelegator } from './interfaces';
 
 
@@ -38,6 +41,15 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.threads.del(threadId);
   }
 
+  async getMessages(threadId: string, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    return this.client.beta.threads.messages.list(threadId, {
+      order: options?.order,
+      limit: options?.limit,
+      before: options?.before,
+      after: options?.after,
+    });
+  }
+
   async createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore> {
     return this.client.beta.vectorStores.create({ name: `growi-vector-store-for-${name}` });
   }
@@ -66,4 +78,8 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.vectorStores.fileBatches.uploadAndPoll(vectorStoreId, { files });
   }
 
+  async chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
+    return this.client.chat.completions.create(body);
+  }
+
 }

+ 4 - 0
apps/app/src/features/openai/server/services/client-delegator/interfaces.ts

@@ -1,14 +1,18 @@
 import type OpenAI from 'openai';
 import type { Uploadable } from 'openai/uploads';
 
+import type { MessageListParams } from '../../../interfaces/message';
+
 export interface IOpenaiClientDelegator {
   createThread(vectorStoreId: string): Promise<OpenAI.Beta.Threads.Thread>
   retrieveThread(threadId: string): Promise<OpenAI.Beta.Threads.Thread>
   deleteThread(threadId: string): Promise<OpenAI.Beta.Threads.ThreadDeleted>
+  getMessages(threadId: string, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>
   retrieveVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore>
   deleteVectorStore(vectorStoreId: string): Promise<OpenAI.Beta.VectorStores.VectorStoreDeleted>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>
   createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.Beta.VectorStores.FileBatches.VectorStoreFileBatch>
   deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;
+  chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>
 }

+ 15 - 1
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -3,8 +3,9 @@ import { type Uploadable } from 'openai/uploads';
 
 import { configManager } from '~/server/service/config-manager';
 
-import type { IOpenaiClientDelegator } from './interfaces';
+import type { MessageListParams } from '../../../interfaces/message';
 
+import type { IOpenaiClientDelegator } from './interfaces';
 
 export class OpenaiClientDelegator implements IOpenaiClientDelegator {
 
@@ -41,6 +42,15 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.threads.del(threadId);
   }
 
+  async getMessages(threadId: string, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    return this.client.beta.threads.messages.list(threadId, {
+      order: options?.order,
+      limit: options?.limit,
+      before: options?.before,
+      after: options?.after,
+    });
+  }
+
   async createVectorStore(name: string): Promise<OpenAI.Beta.VectorStores.VectorStore> {
     return this.client.beta.vectorStores.create({ name: `growi-vector-store-for-${name}` });
   }
@@ -69,4 +79,8 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.beta.vectorStores.fileBatches.uploadAndPoll(vectorStoreId, { files });
   }
 
+  async chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
+    return this.client.chat.completions.create(body);
+  }
+
 }

+ 3 - 0
apps/app/src/features/openai/server/services/normalize-data/normalize-thread-relation-expired-at/normalize-thread-relation-expired-at.integ.ts

@@ -15,6 +15,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread',
+      vectorStore: new Types.ObjectId(),
       expiredAt: expiredDate,
     });
     await threadRelation.save();
@@ -36,6 +37,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread-2',
+      vectorStore: new Types.ObjectId(),
       expiredAt: nonExpiredDate,
     });
     await threadRelation.save();
@@ -55,6 +57,7 @@ describe('normalizeExpiredAtForThreadRelations', () => {
     const threadRelation = new ThreadRelation({
       userId: new Types.ObjectId(),
       threadId: 'test-thread-3',
+      vectorStore: new Types.ObjectId(),
       expiredAt: nonExpiredDate,
     });
     await threadRelation.save();

+ 311 - 25
apps/app/src/features/openai/server/services/openai.ts

@@ -2,18 +2,19 @@ import assert from 'node:assert';
 import { Readable, Transform } from 'stream';
 import { pipeline } from 'stream/promises';
 
+import type { IUser, Ref, Lang } from '@growi/core';
 import {
   PageGrant, getIdForRef, getIdStringForRef, isPopulated, type IUserHasId,
 } from '@growi/core';
 import { deepEquals } from '@growi/core/dist/utils';
-import { isGrobPatternPath } from '@growi/core/dist/utils/page-path-utils';
+import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import createError from 'http-errors';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
 import { type OpenAI, toFile } from 'openai';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import ThreadRelationModel from '~/features/openai/server/models/thread-relation';
+import ThreadRelationModel, { type ThreadRelationDocument } from '~/features/openai/server/models/thread-relation';
 import VectorStoreModel, { type VectorStoreDocument } from '~/features/openai/server/models/vector-store';
 import VectorStoreFileRelationModel, {
   type VectorStoreFileRelation,
@@ -29,12 +30,14 @@ import { OpenaiServiceTypes } from '../../interfaces/ai';
 import {
   type AccessibleAiAssistants, type AiAssistant, AiAssistantAccessScope, AiAssistantShareScope,
 } from '../../interfaces/ai-assistant';
+import type { MessageListParams } from '../../interfaces/message';
 import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 
 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';
 
 const { isDeepEquals } = deepEquals;
 
@@ -49,27 +52,37 @@ type VectorStoreFileRelationsMap = Map<string, VectorStoreFileRelation>
 
 const convertPathPatternsToRegExp = (pagePathPatterns: string[]): Array<string | RegExp> => {
   return pagePathPatterns.map((pagePathPattern) => {
-    if (isGrobPatternPath(pagePathPattern)) {
+    if (isGlobPatternPath(pagePathPattern)) {
       const trimedPagePathPattern = pagePathPattern.replace('/*', '');
       const escapedPagePathPattern = escapeStringRegexp(trimedPagePathPattern);
-      return new RegExp(`^${escapedPagePathPattern}`);
+      // https://regex101.com/r/x5KIZL/1
+      return new RegExp(`^${escapedPagePathPattern}($|/)`);
     }
-
     return pagePathPattern;
   });
 };
 
-
 export interface IOpenaiService {
-  getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread | undefined>;
+  getOrCreateThread(
+    userId: string, vectorStoreRelation: VectorStoreDocument, threadId?: string, 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
+  getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
+  getVectorStoreRelation(aiAssistantId: string): Promise<VectorStoreDocument>
+  getVectorStoreRelationsByPageIds(pageId: Types.ObjectId[]): Promise<VectorStoreDocument[]>;
   createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: PageDocument[]): Promise<void>;
+  createVectorStoreFileOnPageCreate(pages: PageDocument[]): Promise<void>;
+  updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>): Promise<void>;
   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>
@@ -82,12 +95,55 @@ class OpenaiService implements IOpenaiService {
     return getClient({ openaiServiceType });
   }
 
-  public async getOrCreateThread(userId: string, vectorStoreId?: string, threadId?: string): Promise<OpenAI.Beta.Threads.Thread> {
-    if (vectorStoreId != null && threadId == null) {
+  async generateThreadTitle(message: string): Promise<string | null> {
+    const model = configManager.getConfig('openai:assistantModel:chat');
+    const systemMessage = [
+      'Create a brief title (max 5 words) from your message.',
+      'Respond in the same language the user uses in their input.',
+      'Response should only contain the title.',
+    ].join('');
+
+    const threadTitleCompletion = await this.client.chatCompletion({
+      model,
+      messages: [
+        {
+          role: 'system',
+          content: systemMessage,
+        },
+        {
+          role: 'user',
+          content: message,
+        },
+      ],
+    });
+
+    const threadTitle = threadTitleCompletion.choices[0].message.content;
+    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);
+        }
+      }
+
       try {
-        const thread = await this.client.createThread(vectorStoreId);
-        await ThreadRelationModel.create({ userId, threadId: thread.id });
-        return thread;
+        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) {
         throw new Error(err);
@@ -107,7 +163,7 @@ class OpenaiService implements IOpenaiService {
       // Update expiration date if thread entity exists
       await threadRelation.updateThreadExpiration();
 
-      return thread;
+      return threadRelation;
     }
     catch (err) {
       await openaiApiErrorHandler(err, { notFoundError: async() => { await threadRelation.remove() } });
@@ -115,6 +171,29 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
+  async getThreads(vectorStoreRelationId: string): Promise<ThreadRelationDocument[]> {
+    const threadRelations = await ThreadRelationModel.find({ vectorStore: vectorStoreRelationId });
+    return threadRelations;
+  }
+
+  async deleteThread(threadRelationId: string): Promise<ThreadRelationDocument> {
+    const threadRelation = await ThreadRelationModel.findById(threadRelationId);
+    if (threadRelation == null) {
+      throw createError(404, 'ThreadRelation document does not exist');
+    }
+
+    try {
+      const deletedThreadResponse = await this.client.deleteThread(threadRelation.threadId);
+      logger.debug('Delete thread', deletedThreadResponse);
+      await threadRelation.remove();
+    }
+    catch (err) {
+      throw err;
+    }
+
+    return threadRelation;
+  }
+
   public async deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void> {
     const expiredThreadRelations = await ThreadRelationModel.getExpiredThreadRelations(limit);
     if (expiredThreadRelations == null) {
@@ -139,6 +218,20 @@ class OpenaiService implements IOpenaiService {
     await ThreadRelationModel.deleteMany({ threadId: { $in: deletedThreadIds } });
   }
 
+  async getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage> {
+    const messages = await this.client.getMessages(threadId, options);
+
+    for await (const message of messages.data) {
+      for await (const content of message.content) {
+        if (content.type === 'text') {
+          await replaceAnnotationWithPageLink(content, lang);
+        }
+      }
+    }
+
+    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 });
@@ -172,6 +265,69 @@ class OpenaiService implements IOpenaiService {
   //   return newVectorStoreDocument;
   // }
 
+  async getVectorStoreRelation(aiAssistantId: string): Promise<VectorStoreDocument> {
+    const aiAssistant = await AiAssistantModel.findById({ _id: aiAssistantId }).populate('vectorStore');
+    if (aiAssistant == null) {
+      throw createError(404, 'AiAssistant document does not exist');
+    }
+
+    return aiAssistant.vectorStore as VectorStoreDocument;
+  }
+
+  async getVectorStoreRelationsByPageIds(pageIds: Types.ObjectId[]): Promise<VectorStoreDocument[]> {
+    const pipeline = [
+      // Stage 1: Match documents with the given pageId
+      {
+        $match: {
+          page: {
+            $in: pageIds,
+          },
+        },
+      },
+      // Stage 2: Lookup VectorStore documents
+      {
+        $lookup: {
+          from: 'vectorstores',
+          localField: 'vectorStoreRelationId',
+          foreignField: '_id',
+          as: 'vectorStore',
+        },
+      },
+      // Stage 3: Unwind the vectorStore array
+      {
+        $unwind: '$vectorStore',
+      },
+      // Stage 4: Match non-deleted vector stores
+      {
+        $match: {
+          'vectorStore.isDeleted': false,
+        },
+      },
+      // Stage 5: Replace the root with vectorStore document
+      {
+        $replaceRoot: {
+          newRoot: '$vectorStore',
+        },
+      },
+      // Stage 6: Group by _id to remove duplicates
+      {
+        $group: {
+          _id: '$_id',
+          doc: { $first: '$$ROOT' },
+        },
+      },
+      // Stage 7: Restore the document structure
+      {
+        $replaceRoot: {
+          newRoot: '$doc',
+        },
+      },
+    ];
+
+    const vectorStoreRelations = await VectorStoreFileRelationModel.aggregate<VectorStoreDocument>(pipeline);
+    return vectorStoreRelations;
+  }
+
   private async createVectorStore(name: string): Promise<VectorStoreDocument> {
     try {
       const newVectorStore = await this.client.createVectorStore(name);
@@ -230,7 +386,7 @@ class OpenaiService implements IOpenaiService {
     // const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
     const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
     const processUploadFile = async(page: HydratedDocument<PageDocument>) => {
-      if (page._id != null && page.grant === PageGrant.GRANT_PUBLIC && page.revision != null) {
+      if (page._id != null && page.revision != null) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
           const uploadedFile = await this.uploadFile(page._id, page.path, page.revision.body);
           prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
@@ -348,6 +504,16 @@ class OpenaiService implements IOpenaiService {
     await vectorStoreFileRelation.save();
   }
 
+  async deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void> {
+    const vectorStoreRelations = await this.getVectorStoreRelationsByPageIds(pageIds);
+    if (vectorStoreRelations != null && vectorStoreRelations.length !== 0) {
+      for await (const pageId of pageIds) {
+        const deleteVectorStoreFilePromises = vectorStoreRelations.map(vectorStoreRelation => this.deleteVectorStoreFile(vectorStoreRelation._id, pageId));
+        await Promise.allSettled(deleteVectorStoreFilePromises);
+      }
+    }
+  }
+
   async deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void> {
     // Retrieves all VectorStore documents that are marked as deleted
     const deletedVectorStoreRelations = await VectorStoreModel.find({ isDeleted: true });
@@ -396,11 +562,93 @@ class OpenaiService implements IOpenaiService {
   //   await pipeline(pagesStream, batchStrem, createVectorStoreFileStream);
   // }
 
-  // async rebuildVectorStore(page: HydratedDocument<PageDocument>) {
-  //   const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
-  //   await this.deleteVectorStoreFile(vectorStore._id, page._id);
-  //   await this.createVectorStoreFile([page]);
-  // }
+  async filterPagesByAccessScope(aiAssistant: AiAssistantDocument, pages: HydratedDocument<PageDocument>[]) {
+    const isPublicPage = (page :HydratedDocument<PageDocument>) => page.grant === PageGrant.GRANT_PUBLIC;
+
+    const isUserGroupAccessible = (page :HydratedDocument<PageDocument>, ownerUserGroupIds: string[]) => {
+      if (page.grant !== PageGrant.GRANT_USER_GROUP) return false;
+      return page.grantedGroups.some(group => ownerUserGroupIds.includes(getIdStringForRef(group.item)));
+    };
+
+    const isOwnerAccessible = (page: HydratedDocument<PageDocument>, ownerId: Ref<IUser>) => {
+      if (page.grant !== PageGrant.GRANT_OWNER) return false;
+      return page.grantedUsers.some(user => getIdStringForRef(user) === getIdStringForRef(ownerId));
+    };
+
+    const getOwnerUserGroupIds = async(owner: Ref<IUser>) => {
+      const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(owner);
+      const externalGroups = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(owner);
+      return [...userGroups, ...externalGroups].map(group => getIdStringForRef(group));
+    };
+
+    switch (aiAssistant.accessScope) {
+      case AiAssistantAccessScope.PUBLIC_ONLY:
+        return pages.filter(isPublicPage);
+
+      case AiAssistantAccessScope.GROUPS: {
+        const ownerUserGroupIds = await getOwnerUserGroupIds(aiAssistant.owner);
+        return pages.filter(page => isPublicPage(page) || isUserGroupAccessible(page, ownerUserGroupIds));
+      }
+
+      case AiAssistantAccessScope.OWNER: {
+        const ownerUserGroupIds = await getOwnerUserGroupIds(aiAssistant.owner);
+        return pages.filter(page => isPublicPage(page) || isOwnerAccessible(page, aiAssistant.owner) || isUserGroupAccessible(page, ownerUserGroupIds));
+      }
+
+      default:
+        return [];
+    }
+  }
+
+  async createVectorStoreFileOnPageCreate(pages: HydratedDocument<PageDocument>[]): Promise<void> {
+    const pagePaths = pages.map(page => page.path);
+    const aiAssistants = await AiAssistantModel.findByPagePaths(pagePaths);
+
+    if (aiAssistants.length === 0) {
+      return;
+    }
+
+    for await (const aiAssistant of aiAssistants) {
+      const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, pages);
+      const vectorStoreRelation = aiAssistant.vectorStore;
+      if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
+        continue;
+      }
+
+      logger.debug('--------- createVectorStoreFileOnPageCreate ---------');
+      logger.debug('AccessScopeType of aiAssistant: ', aiAssistant.accessScope);
+      logger.debug('VectorStoreFile pagePath to be created: ', pagesToVectorize.map(page => page.path));
+      logger.debug('-----------------------------------------------------');
+
+      await this.createVectorStoreFile(vectorStoreRelation as VectorStoreDocument, pagesToVectorize);
+    }
+  }
+
+  async updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>) {
+    const aiAssistants = await AiAssistantModel.findByPagePaths([page.path]);
+
+    if (aiAssistants.length === 0) {
+      return;
+    }
+
+    for await (const aiAssistant of aiAssistants) {
+      const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, [page]);
+      const vectorStoreRelation = aiAssistant.vectorStore;
+      if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
+        continue;
+      }
+
+      logger.debug('---------- updateVectorStoreOnPageUpdate ------------');
+      logger.debug('AccessScopeType of aiAssistant: ', aiAssistant.accessScope);
+      logger.debug('PagePath of VectorStoreFile to be deleted: ', page.path);
+      logger.debug('pagePath of VectorStoreFile to be created: ', pagesToVectorize.map(page => page.path));
+      logger.debug('-----------------------------------------------------');
+
+      // Do not create a new VectorStoreFile if page is changed to a permission that AiAssistant does not have access to
+      await this.createVectorStoreFile(vectorStoreRelation as VectorStoreDocument, pagesToVectorize);
+      await this.deleteVectorStoreFile((vectorStoreRelation as VectorStoreDocument)._id, page._id);
+    }
+  }
 
   private async createVectorStoreFileWithStream(vectorStoreRelation: VectorStoreDocument, conditions: mongoose.FilterQuery<PageDocument>): Promise<void> {
     const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
@@ -436,10 +684,10 @@ class OpenaiService implements IOpenaiService {
       pagePathPatterns: AiAssistant['pagePathPatterns'],
   ): Promise<mongoose.FilterQuery<PageDocument>> {
 
-    const converterdPagePathPatterns = convertPathPatternsToRegExp(pagePathPatterns);
+    const convertedPagePathPatterns = convertPathPatternsToRegExp(pagePathPatterns);
 
     // Include pages in search targets when their paths with 'Anyone with the link' permission are directly specified instead of using glob pattern
-    const nonGrabPagePathPatterns = pagePathPatterns.filter(pagePathPattern => !isGrobPatternPath(pagePathPattern));
+    const nonGrabPagePathPatterns = pagePathPatterns.filter(pagePathPattern => !isGlobPatternPath(pagePathPattern));
     const baseCondition: mongoose.FilterQuery<PageDocument> = {
       grant: PageGrant.GRANT_RESTRICTED,
       path: { $in: nonGrabPagePathPatterns },
@@ -451,7 +699,7 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: PageGrant.GRANT_PUBLIC,
-            path: { $in: converterdPagePathPatterns },
+            path: { $in: convertedPagePathPatterns },
           },
         ],
       };
@@ -469,7 +717,7 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP] },
-            path: { $in: converterdPagePathPatterns },
+            path: { $in: convertedPagePathPatterns },
             $or: [
               { 'grantedGroups.item': { $in: extractedGrantedGroupIdsForAccessScope } },
               { grant: PageGrant.GRANT_PUBLIC },
@@ -490,7 +738,7 @@ class OpenaiService implements IOpenaiService {
           baseCondition,
           {
             grant: { $in: [PageGrant.GRANT_PUBLIC, PageGrant.GRANT_USER_GROUP, PageGrant.GRANT_OWNER] },
-            path: { $in: converterdPagePathPatterns },
+            path: { $in: convertedPagePathPatterns },
             $or: [
               { 'grantedGroups.item': { $in: ownerUserGroups } },
               { grantedUsers: { $in: [getIdForRef(owner)] } },
@@ -546,6 +794,42 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
+  async isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean> {
+    const aiAssistant = await AiAssistantModel.findById(aiAssistantId);
+
+    if (aiAssistant == null) {
+      throw createError(404, 'AiAssistant document does not exist');
+    }
+
+    const isOwner = getIdStringForRef(aiAssistant.owner) === getIdStringForRef(user._id);
+
+    if (aiAssistant.shareScope === AiAssistantShareScope.PUBLIC_ONLY) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.OWNER) && isOwner) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) && (aiAssistant.accessScope === AiAssistantAccessScope.OWNER) && isOwner) {
+      return true;
+    }
+
+    if ((aiAssistant.shareScope === AiAssistantShareScope.GROUPS)
+      || ((aiAssistant.shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE) && (aiAssistant.accessScope === AiAssistantAccessScope.GROUPS))) {
+      const userGroupIds = [
+        ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+        ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ].map(group => group.toString());
+
+      const grantedGroupIdsForShareScope = aiAssistant.grantedGroupsForShareScope?.map(group => getIdStringForRef(group.item)) ?? [];
+      const isShared = userGroupIds.some(userGroupId => grantedGroupIdsForShareScope.includes(userGroupId));
+      return isShared;
+    }
+
+    return false;
+  }
+
   async createAiAssistant(data: Omit<AiAssistant, 'vectorStore'>): Promise<AiAssistantDocument> {
     await this.validateGrantedUserGroupsForAiAssistant(
       data.owner,
@@ -653,7 +937,9 @@ class OpenaiService implements IOpenaiService {
           ],
         },
       ],
-    });
+    })
+      .populate('grantedGroupsForShareScope.item')
+      .populate('grantedGroupsForAccessScope.item');
 
     return {
       myAiAssistants: assistants.filter(assistant => assistant.owner.toString() === user._id.toString()) ?? [],

+ 5 - 5
apps/app/src/features/openai/server/services/replace-annotation-with-page-link.ts

@@ -1,14 +1,14 @@
 // See: https://platform.openai.com/docs/assistants/tools/file-search#step-5-create-a-run-and-check-the-output
 
 import type { IPageHasId, Lang } from '@growi/core/dist/interfaces';
-import type { MessageContentDelta } from 'openai/resources/beta/threads/messages.mjs';
+import type { MessageContentDelta, MessageContent } from 'openai/resources/beta/threads/messages.mjs';
 
 import VectorStoreFileRelationModel from '~/features/openai/server/models/vector-store-file-relation';
 import { getTranslation } from '~/server/service/i18next';
 
-export const replaceAnnotationWithPageLink = async(messageContentDelta: MessageContentDelta, lang?: Lang): Promise<void> => {
-  if (messageContentDelta?.type === 'text' && messageContentDelta?.text?.annotations != null) {
-    const annotations = messageContentDelta?.text?.annotations;
+export const replaceAnnotationWithPageLink = async(messageContent: MessageContentDelta | MessageContent, lang?: Lang): Promise<void> => {
+  if (messageContent?.type === 'text' && messageContent?.text?.annotations != null) {
+    const annotations = messageContent?.text?.annotations;
     for await (const annotation of annotations) {
       if (annotation.type === 'file_citation' && annotation.text != null) {
 
@@ -18,7 +18,7 @@ export const replaceAnnotationWithPageLink = async(messageContentDelta: MessageC
 
         if (vectorStoreFileRelation != null) {
           const { t } = await getTranslation({ lang });
-          messageContentDelta.text.value = messageContentDelta.text.value?.replace(
+          messageContent.text.value = messageContent.text.value?.replace(
             annotation.text,
             ` [${t('source')}: [${vectorStoreFileRelation.page.path}](/${vectorStoreFileRelation.page._id})]`,
           );

+ 48 - 0
apps/app/src/features/openai/server/utils/generate-glob-patterns.spec.ts

@@ -0,0 +1,48 @@
+import { describe, test, expect } from 'vitest';
+
+import { generateGlobPatterns } from './generate-glob-patterns';
+
+describe('generateGlobPatterns', () => {
+  test('generates glob patterns for basic path with trailing slash', () => {
+    const path = '/Sandbox/Bootstrap5/';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/Sandbox/*',
+      '/Sandbox/Bootstrap5/*',
+    ]);
+  });
+
+  test('generates glob patterns for multi-level path with trailing slash', () => {
+    const path = '/user/admin/memo/';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/user/*',
+      '/user/admin/*',
+      '/user/admin/memo/*',
+    ]);
+  });
+
+  test('generates glob patterns for path without trailing slash', () => {
+    const path = '/path/to/directory';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/path/*',
+      '/path/to/*',
+      '/path/to/directory/*',
+    ]);
+  });
+
+  test('handles path with empty segments correctly', () => {
+    const path = '/path//to///dir';
+    const patterns = generateGlobPatterns(path);
+
+    expect(patterns).toEqual([
+      '/path/*',
+      '/path/to/*',
+      '/path/to/dir/*',
+    ]);
+  });
+});

+ 28 - 0
apps/app/src/features/openai/server/utils/generate-glob-patterns.ts

@@ -0,0 +1,28 @@
+import { pathUtils } from '@growi/core/dist/utils';
+
+/**
+  * @example
+  * // Input: '/Sandbox/Bootstrap5/'
+  * // Output: ['/Sandbox/*', '/Sandbox/Bootstrap5/*']
+  *
+  * // Input: '/user/admin/memo/'
+  * // Output: ['/user/*', '/user/admin/*', '/user/admin/memo/*']
+  */
+export const generateGlobPatterns = (path: string): string[] => {
+  // Remove trailing slash if exists
+  const normalizedPath = pathUtils.removeTrailingSlash(path);
+
+  // Split path into segments
+  const segments = normalizedPath.split('/').filter(Boolean);
+
+  // Generate patterns
+  const patterns: string[] = [];
+  let currentPath = '';
+
+  for (let i = 0; i < segments.length; i++) {
+    currentPath += `/${segments[i]}`;
+    patterns.push(`${currentPath}/*`);
+  }
+
+  return patterns;
+};

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

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

+ 12 - 0
apps/app/src/pages/login/index.page.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { pagePathUtils } from '@growi/core/dist/utils';
 import type {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -24,6 +25,8 @@ import {
 import styles from './index.module.scss';
 
 
+const { isPermalink, isUserPage, isUsersTopPage } = pagePathUtils;
+
 const LoginForm = dynamic(() => import('~/client/components/LoginForm').then(mod => mod.LoginForm), { ssr: false });
 
 
@@ -127,6 +130,15 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const result = await getServerSideCommonProps(context);
 
+
+  // redirect to the page the user was on before moving to the Login Page
+  if (context.req.headers.referer != null) {
+    const urlBeforeLogin = new URL(context.req.headers.referer);
+    if (isPermalink(urlBeforeLogin.pathname) || isUserPage(urlBeforeLogin.pathname) || isUsersTopPage(urlBeforeLogin.pathname)) {
+      (context.req as CrowiRequest).session.redirectTo = urlBeforeLogin.href;
+    }
+  }
+
   // check for presence
   // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
   if (!('props' in result)) {

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

+ 1 - 1
apps/app/src/server/routes/apiv3/import.js

@@ -319,7 +319,7 @@ export default function route(crowi) {
    *                      type: object
    *                      description: the property of each extracted file
    */
-  router.post('/upload', uploads.single('file'), accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/upload', accessTokenParser, loginRequired, adminRequired, uploads.single('file'), addActivity, async(req, res) => {
     const { file } = req;
     const zipFile = importService.getFile(file.filename);
     let data = null;

+ 1 - 2
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -205,9 +205,8 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     if (isAiEnabled()) {
       const { getOpenaiService } = await import('~/features/openai/server/services/openai');
       try {
-        // TODO: https://redmine.weseek.co.jp/issues/160334
         const openaiService = getOpenaiService();
-        // await openaiService?.rebuildVectorStore(createdPage);
+        await openaiService?.createVectorStoreFileOnPageCreate([createdPage]);
       }
       catch (err) {
         logger.error('Rebuild vector store failed', err);

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

+ 1 - 2
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -121,9 +121,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     if (isAiEnabled()) {
       const { getOpenaiService } = await import('~/features/openai/server/services/openai');
       try {
-        // TODO: https://redmine.weseek.co.jp/issues/160335
         const openaiService = getOpenaiService();
-        // await openaiService?.rebuildVectorStore(updatedPage);
+        await openaiService?.updateVectorStoreFileOnPageUpdate(updatedPage);
       }
       catch (err) {
         logger.error('Rebuild vector store failed', err);

+ 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) => {
 

+ 7 - 17
apps/app/src/server/service/page/index.ts

@@ -1171,12 +1171,10 @@ class PageService implements IPageService {
       );
 
       if (isAiEnabled()) {
-        // TODO: https://redmine.weseek.co.jp/issues/160336
         const { getOpenaiService } = await import('~/features/openai/server/services/openai');
-
-        // Do not await because communication with OpenAI takes time
         const openaiService = getOpenaiService();
-        // openaiService?.createVectorStoreFile([duplicatedTarget]);
+        // Do not await because communication with OpenAI takes time
+        openaiService?.createVectorStoreFileOnPageCreate([duplicatedTarget]);
       }
     }
     this.pageEvent.emit('duplicate', page, user);
@@ -1409,16 +1407,14 @@ class PageService implements IPageService {
     await Revision.insertMany(newRevisions, { ordered: false });
     await this.duplicateTags(pageIdMapping);
 
-    const duplicatedPagesWithPopulatedToShowRevison = await Page
-      .find({ _id: { $in: duplicatedPageIds }, grant: PageGrant.GRANT_PUBLIC }).populate('revision') as PageDocument[];
+    const duplicatedPagesWithPopulatedToShowRevision: HydratedDocument<PageDocument>[] = await Page
+      .find({ _id: { $in: duplicatedPageIds }, grant: PageGrant.GRANT_PUBLIC }).populate('revision');
 
     if (isAiEnabled()) {
-      // TODO: https://redmine.weseek.co.jp/issues/160336
       const { getOpenaiService } = await import('~/features/openai/server/services/openai');
-
-      // Do not await because communication with OpenAI takes time
       const openaiService = getOpenaiService();
-      // openaiService?.createVectorStoreFile(duplicatedPagesWithPopulatedToShowRevison);
+      // Do not await because communication with OpenAI takes time
+      openaiService?.createVectorStoreFileOnPageCreate(duplicatedPagesWithPopulatedToShowRevision);
     }
   }
 
@@ -1899,14 +1895,8 @@ class PageService implements IPageService {
 
     if (isAiEnabled()) {
       const { getOpenaiService } = await import('~/features/openai/server/services/openai');
-
-      // TODO: https://redmine.weseek.co.jp/issues/160337
       const openaiService = getOpenaiService();
-      if (openaiService != null) {
-        // const vectorStore = await openaiService.getOrCreateVectorStoreForPublicScope();
-        // const deleteVectorStoreFilePromises = pageIds.map(pageId => openaiService.deleteVectorStoreFile(vectorStore._id, pageId));
-        // await Promise.allSettled(deleteVectorStoreFilePromises);
-      }
+      await openaiService?.deleteVectorStoreFilesByPageIds(pageIds);
     }
   }
 

+ 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 - 12
apps/app/src/styles/style-app.scss

@@ -82,18 +82,6 @@
   }
 }
 
-.cmd-key.mac {
-  &::after {
-    content: '⌘';
-  }
-}
-
-.cmd-key.win {
-  &::after {
-    content: 'Ctrl';
-  }
-}
-
 .grw-page-control-dropdown-item {
   display: flex !important;
   align-items: center;

+ 1 - 1
apps/slackbot-proxy/docker/Dockerfile

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

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

+ 1 - 1
bin/data-migrations/src/migrations/v60x/csv.js

@@ -7,7 +7,7 @@ module.exports = [
    * @type {MigrationModule}
    */
   (body) => {
-    const oldCsvTableRegExp = /::: csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
+    const oldCsvTableRegExp = /:::\s?csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
     return body.replace(oldCsvTableRegExp, '``` csv$1\n$2\n```');
   },
 ];

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

+ 1 - 1
bin/data-migrations/src/migrations/v60x/tsv.js

@@ -7,7 +7,7 @@ module.exports = [
    * @type {MigrationModule}
    */
   (body) => {
-    const oldTsvTableRegExp = /::: tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
+    const oldTsvTableRegExp = /:::\s?tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
     return body.replace(oldTsvTableRegExp, '``` tsv$1\n$2\n```');
   },
 ];

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

+ 2 - 3
package.json

@@ -20,7 +20,7 @@
   "bugs": {
     "url": "https://github.com/weseek/growi/issues"
   },
-  "packageManager": "pnpm@9.4.0",
+  "packageManager": "pnpm@10.4.1",
   "scripts": {
     "bootstrap": "pnpm install",
     "start": "pnpm run app:server",
@@ -114,7 +114,6 @@
     }
   },
   "engines": {
-    "node": "^18 || ^20",
-    "pnpm": ">=9.4 <10"
+    "node": "^18 || ^20"
   }
 }

+ 25 - 0
packages/core-styles/scss/helpers/_modifier-keys.scss

@@ -0,0 +1,25 @@
+@mixin modifier-key {
+  .cmd-key.mac {
+    &::after {
+      content: '⌘';
+    }
+  }
+
+  .cmd-key.win {
+    &::after {
+      content: 'Ctrl';
+    }
+  }
+
+  .alt-key.mac {
+    &::after {
+      content: '⌥';
+    }
+  }
+
+  .alt-key.win {
+    &::after {
+      content: 'Alt';
+    }
+  }
+}

+ 1 - 1
packages/core/src/utils/page-path-utils/index.ts

@@ -293,7 +293,7 @@ export const getUsernameByPath = (path: string): string | null => {
   return username;
 };
 
-export const isGrobPatternPath = (path: string): boolean => {
+export const isGlobPatternPath = (path: string): boolean => {
   // https://regex101.com/r/IBy7HS/1
   const globPattern = /^(?:\/[^/*?[\]{}]+)*\/\*$/;
   return globPattern.test(path);

+ 7 - 1
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.module.scss

@@ -1,3 +1,9 @@
 .codemirror-editor-toolbar :global {
-  @import './scss/toolbar-button.scss';
+  @import './scss/toolbar-button';
+
+  // center the toolbar vertically
+  .simplebar-offset {
+    display: flex;
+    align-items: center;
+  }
 }

+ 1 - 1
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/Toolbar.tsx

@@ -32,7 +32,7 @@ export const Toolbar = memo((props: Props): JSX.Element => {
 
   return (
     <>
-      <div className={`d-flex gap-2 py-1 px-2 px-md-3 border-top ${styles['codemirror-editor-toolbar']}`}>
+      <div className={`d-flex gap-2 py-1 px-2 px-md-3 border-top ${styles['codemirror-editor-toolbar']} align-items-center`}>
         <AttachmentsDropup editorKey={editorKey} onUpload={onUpload} acceptedUploadFileType={acceptedUploadFileType} />
         <div className="flex-grow-1">
           <SimpleBar ref={simpleBarRef} autoHide style={{ overflowY: 'hidden' }}>

+ 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';

+ 3 - 2
packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -86,6 +86,7 @@ export const routesFactory = (crowi): any => {
   router.get('/ref', accessTokenParser, loginRequired, async(req: RequestWithUser, res) => {
     const user = req.user;
     const { pagePath, fileNameOrId } = req.query;
+    const filterXSS = new FilterXSS();
 
     if (pagePath == null) {
       res.status(400).send('the param \'pagePath\' must be set.');
@@ -96,7 +97,7 @@ export const routesFactory = (crowi): any => {
 
     // not found
     if (page == null) {
-      res.status(404).send(`pagePath: '${pagePath}' is not found or forbidden.`);
+      res.status(404).send(filterXSS.process(`pagePath: '${pagePath}' is not found or forbidden.`));
       return;
     }
 
@@ -117,7 +118,7 @@ export const routesFactory = (crowi): any => {
 
     // not found
     if (attachment == null) {
-      res.status(404).send(`attachment '${fileNameOrId}' is not found.`);
+      res.status(404).send(filterXSS.process(`attachment '${fileNameOrId}' is not found.`));
       return;
     }
 

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