Przeglądaj źródła

Merge pull request #10183 from weseek/feat/new-assistant-wizard

feat(ai): New Knowledge Assistant Wizard
mergify[bot] 7 miesięcy temu
rodzic
commit
26a3e9e284
28 zmienionych plików z 1216 dodań i 136 usunięć
  1. 11 0
      apps/app/public/static/locales/en_US/translation.json
  2. 11 0
      apps/app/public/static/locales/fr_FR/translation.json
  3. 11 0
      apps/app/public/static/locales/ja_JP/translation.json
  4. 11 0
      apps/app/public/static/locales/zh_CN/translation.json
  5. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditInstruction.tsx
  6. 31 30
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx
  7. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx
  8. 28 7
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx
  9. 23 11
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx
  10. 100 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.module.scss
  11. 228 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx
  12. 49 23
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx
  13. 35 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageSelectionMethod.tsx
  14. 11 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss
  15. 171 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx
  16. 28 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.module.scss
  17. 55 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.tsx
  18. 43 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.module.scss
  19. 249 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.tsx
  20. 0 43
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx
  21. 4 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  22. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx
  23. 72 0
      apps/app/src/features/openai/client/services/use-selected-pages.tsx
  24. 17 2
      apps/app/src/features/openai/client/stores/ai-assistant.tsx
  25. 10 0
      apps/app/src/features/openai/interfaces/selectable-page.ts
  26. 0 6
      apps/app/src/features/openai/interfaces/selected-page.ts
  27. 2 7
      apps/app/src/features/openai/server/routes/middlewares/upsert-ai-assistant-validator.ts
  28. 13 0
      apps/app/src/features/openai/utils/is-creatable-page-path-pattern.ts

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

@@ -600,6 +600,17 @@
       "create_failed": "Failed to create assistant",
       "update_failed": "Failed to update assistant"
     },
+    "select_source_pages": "Select pages for the assistant to reference",
+    "search_reference_pages_by_keyword": "Search for pages the assistant will reference by keyword",
+    "search_by_keyword": "Search by keyword",
+    "enter_keywords": "Enter keywords",
+    "max_items_space_separated_hint": "Enter up to 5 items separated by spaces",
+    "select_assistant_reference_pages": "Select pages for the assistant to reference",
+    "reference_pages": "Reference pages",
+    "no_pages_selected": "No pages selected",
+    "can_add_later": "You can add more later",
+    "next": "Next",
+    "select_from_page_tree": "Select from page tree",
     "edit_page_description": "Edit pages that the assistant can reference.<br> The assistant can reference up to {{limitLearnablePageCountPerAssistant}} pages including child pages.",
     "default_instruction": "You are the knowledge assistant for this Wiki.\n\n## Multilingual Support:\nRespond in the same language the user uses in their input.\n",
     "add_page_button": "Add page",

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

@@ -594,6 +594,17 @@
       "create_failed": "Échec de la création de l'assistant",
       "update_failed": "Échec de la mise à jour de l'assistant"
     },
+    "select_source_pages": "Sélectionnez les pages que l'assistant doit référencer",
+    "search_reference_pages_by_keyword": "Rechercher les pages de référence de l'assistant par mot-clé",
+    "search_by_keyword": "Rechercher par mot-clé",
+    "max_items_space_separated_hint": "Saisissez jusqu'à 5 éléments séparés par des espaces",
+    "select_assistant_reference_pages": "Sélectionnez les pages de référence pour l'assistant",
+    "enter_keywords": "Entrer des mots-clés",
+    "reference_pages": "Pages de référence",
+    "no_pages_selected": "Aucune page sélectionnée",
+    "can_add_later": "Vous pouvez en ajouter plus tard",
+    "next": "Suivant",
+    "select_from_page_tree": "Sélectionner depuis l'arborescence des pages",
     "edit_page_description": "Modifier les pages que l'assistant peut référencer.<br> L'assistant peut référencer jusqu'à {{limitLearnablePageCountPerAssistant}} pages, y compris les pages enfants.",
     "default_instruction": "Vous êtes l'assistant de connaissances pour ce Wiki.\n\n## Support multilingue :\nRépondez dans la même langue que celle utilisée par l'utilisateur dans sa requête.\n",
     "add_page_button": "Ajouter une page",

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

@@ -633,6 +633,17 @@
       "create_failed": "アシスタントの作成に失敗しました",
       "update_failed": "アシスタントの更新に失敗しました"
     },
+    "select_source_pages": "アシスタントが参照するページを選択します",
+    "search_reference_pages_by_keyword": "アシスタントが参照するページをキーワードで検索",
+    "search_by_keyword": "キーワードで検索",
+    "enter_keywords": "キーワードを入力",
+    "max_items_space_separated_hint": "スペース区切りで最大5つまで入力できます",
+    "select_assistant_reference_pages": "アシスタントが参照するページを選択してください",
+    "reference_pages": "参照するページ",
+    "no_pages_selected": "ページが選択されていません",
+    "can_add_later": "あとからでも追加できます",
+    "next": "次へ",
+    "select_from_page_tree": "ページツリーから選択",
     "edit_page_description": " アシスタントが参照するページを編集します。<br> 参照できるページは配下ページも含めて {{limitLearnablePageCountPerAssistant}} ページまでです。",
     "default_instruction": "あなたはこのWikiの知識アシスタントです。\n\n## 多言語サポート:\nユーザーが入力で使用した言語と同じ言語で応答してください。\n",
     "add_page_button": "ページを追加する",

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

@@ -591,6 +591,17 @@
       "create_failed": "创建助手失败",
       "update_failed": "更新助手失败"
     },
+    "select_source_pages": "选择助手要参考的页面",
+    "search_reference_pages_by_keyword": "按关键词搜索助手参考的页面",
+    "search_by_keyword": "按关键词搜索",
+    "max_items_space_separated_hint": "请输入最多5个项目,用空格分隔",
+    "select_assistant_reference_pages": "请选择助手参考的页面",
+    "enter_keywords": "输入关键词",
+    "reference_pages": "参考页面",
+    "no_pages_selected": "未选择任何页面",
+    "can_add_later": "稍后也可以添加",
+    "next": "下一步",
+    "select_from_page_tree": "从页面树选择",
     "edit_page_description": "编辑助手可以参考的页面。<br> 助手可以参考最多 {{limitLearnablePageCountPerAssistant}} 个页面,包括子页面。",
     "default_instruction": "您是这个Wiki的知识助手。\n\n## 多语言支持:\n请使用用户输入中使用的相同语言进行回复。\n",
     "add_page_button": "添加页面",

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

@@ -18,7 +18,7 @@ export const AiAssistantManagementEditInstruction = (props: Props): JSX.Element
 
   return (
     <>
-      <AiAssistantManagementHeader />
+      <AiAssistantManagementHeader labelTranslationKey="modal_ai_assistant.page_mode_title.instruction" />
 
       <ModalBody className="px-4">
         <p className="text-secondary py-1">

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

@@ -2,20 +2,18 @@ import React, { useCallback, type JSX } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { ModalBody } from 'reactstrap';
+import SimpleBar from 'simplebar-react';
 
-import type { IPageForItem } from '~/interfaces/page';
 import { useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
-import { usePageSelectModal } from '~/stores/modal';
 
-import type { SelectedPage } from '../../../../interfaces/selected-page';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
-import { SelectedPageList } from './SelectedPageList';
-
+import { PageSelectionMethodButtons } from './PageSelectionMethodButtons';
+import { SelectablePageList } from './SelectablePageList';
 
 type Props = {
-  selectedPages: SelectedPage[];
-  onSelect: (page: IPageForItem, isIncludeSubPage: boolean) => void;
+  selectedPages: SelectablePage[];
   onRemove: (pageId: string) => void;
 }
 
@@ -23,35 +21,38 @@ export const AiAssistantManagementEditPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { data: limitLearnablePageCountPerAssistant } = useLimitLearnablePageCountPerAssistant();
 
-  const { selectedPages, onSelect, onRemove } = props;
-
-  const { open: openPageSelectModal } = usePageSelectModal();
+  const { selectedPages, onRemove } = props;
 
-  const clickOpenPageSelectModalHandler = useCallback(() => {
-    openPageSelectModal({ onSelected: onSelect, isHierarchicalSelectionMode: true });
-  }, [onSelect, openPageSelectModal]);
+  const removePageHandler = useCallback((page: SelectablePage) => {
+    onRemove(page.path);
+  }, [onRemove]);
 
   return (
     <>
-      <AiAssistantManagementHeader />
+      <AiAssistantManagementHeader labelTranslationKey="modal_ai_assistant.page_mode_title.pages" />
 
       <ModalBody className="px-4">
-        <p
-          className="text-secondary py-1"
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('modal_ai_assistant.edit_page_description', { limitLearnablePageCountPerAssistant }) }}
-        />
-
-        <button
-          type="button"
-          onClick={clickOpenPageSelectModalHandler}
-          className="btn btn-outline-primary w-100 mb-3 d-flex align-items-center justify-content-center"
-        >
-          <span className="material-symbols-outlined me-2">add</span>
-          {t('modal_ai_assistant.add_page_button')}
-        </button>
-
-        <SelectedPageList selectedPages={selectedPages} onRemove={onRemove} />
+        <div className="px-4">
+          <p
+            className="text-secondary"
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: t('modal_ai_assistant.edit_page_description', { limitLearnablePageCountPerAssistant }) }}
+          />
+
+          <div className="mb-3">
+            <PageSelectionMethodButtons />
+          </div>
+
+          <SimpleBar style={{ maxHeight: '300px' }}>
+            <SelectablePageList
+              isEditable
+              method="delete"
+              methodButtonPosition="right"
+              pages={selectedPages}
+              onClickMethodButton={removePageHandler}
+            />
+          </SimpleBar>
+        </div>
       </ModalBody>
     </>
   );

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

@@ -98,7 +98,7 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
 
   return (
     <>
-      <AiAssistantManagementHeader />
+      <AiAssistantManagementHeader labelTranslationKey="modal_ai_assistant.page_mode_title.share" />
 
       <ModalBody className="px-4">
         <div className="form-check form-switch mb-4">

+ 28 - 7
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHeader.tsx

@@ -1,17 +1,31 @@
-import type { JSX } from 'react';
+import { type JSX } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { ModalHeader } from 'reactstrap';
 
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
 
+type Props = {
+  labelTranslationKey: string;
+  hideBackButton?: boolean;
+  backButtonColor?: 'primary' | 'secondary';
+  backToPageMode?: AiAssistantManagementModalPageMode;
+}
+
+export const AiAssistantManagementHeader = (props: Props): JSX.Element => {
+  const {
+    labelTranslationKey,
+    hideBackButton,
+    backButtonColor = 'primary',
+    backToPageMode = AiAssistantManagementModalPageMode.HOME,
+  } = props;
 
-export const AiAssistantManagementHeader = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data, close, changePageMode } = useAiAssistantManagementModal();
+  const { close, changePageMode } = useAiAssistantManagementModal();
 
   return (
     <ModalHeader
+      tag="h4"
       close={(
         <button type="button" className="btn p-0" onClick={close}>
           <span className="material-symbols-outlined">close</span>
@@ -19,10 +33,17 @@ export const AiAssistantManagementHeader = (): JSX.Element => {
       )}
     >
       <div className="d-flex align-items-center">
-        <button type="button" className="btn p-0 me-3" onClick={() => changePageMode(AiAssistantManagementModalPageMode.HOME)}>
-          <span className="material-symbols-outlined text-primary">chevron_left</span>
-        </button>
-        <span>{t(`modal_ai_assistant.page_mode_title.${data?.pageMode}`)}</span>
+        { hideBackButton
+          ? (
+            <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">growi_ai</span>
+          )
+          : (
+            <button type="button" className="btn p-0 me-3" onClick={() => changePageMode(backToPageMode)}>
+              <span className={`material-symbols-outlined text-${backButtonColor}`}>chevron_left</span>
+            </button>
+          )
+        }
+        <span className="fw-bold">{t(labelTranslationKey)}</span>
       </div>
     </ModalHeader>
   );

+ 23 - 11
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx

@@ -1,30 +1,32 @@
 import React, {
-  useCallback, useState, useMemo, type JSX,
+  useCallback, useState, useMemo, useRef, useEffect, type JSX,
 } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
-  ModalHeader, ModalBody, ModalFooter, Input,
+  ModalBody, ModalFooter, Input,
 } from 'reactstrap';
 
 import { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { useCurrentUser, useLimitLearnablePageCountPerAssistant } from '~/stores-universal/context';
 
-import type { SelectedPage } from '../../../../interfaces/selected-page';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
 
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 import { ShareScopeWarningModal } from './ShareScopeWarningModal';
 
 type Props = {
+  isActivePane: boolean;
   shouldEdit: boolean;
   name: string;
   description: string;
   instruction: string;
   shareScope: AiAssistantShareScope,
   accessScope: AiAssistantAccessScope,
-  selectedPages: SelectedPage[];
+  selectedPages: SelectablePage[];
   selectedUserGroupsForAccessScope: PopulatedGrantedGroup[],
   selectedUserGroupsForShareScope: PopulatedGrantedGroup[],
   onNameChange: (value: string) => void;
@@ -34,6 +36,7 @@ type Props = {
 
 export const AiAssistantManagementHome = (props: Props): JSX.Element => {
   const {
+    isActivePane,
     shouldEdit,
     name,
     description,
@@ -55,11 +58,11 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
 
   const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] = useState(false);
 
+  const inputRef = useRef<HTMLInputElement>(null);
+
   const totalSelectedPageCount = useMemo(() => {
     return selectedPages.reduce((total, selectedPage) => {
-      const descendantCount = selectedPage.isIncludeSubPage
-        ? selectedPage.page.descendantCount ?? 0
-        : 0;
+      const descendantCount = selectedPage.descendantCount ?? 0;
       const pageCountWithDescendants = descendantCount + 1;
       return total + pageCountWithDescendants;
     }, 0);
@@ -114,12 +117,20 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     await onUpsertAiAssistant();
   }, [accessScope, onUpsertAiAssistant, selectedUserGroupsForAccessScope, selectedUserGroupsForShareScope, shareScope]);
 
+  // Autofocus
+  useEffect(() => {
+    // Only when creating a new assistant
+    if (isActivePane && !shouldEdit) {
+      inputRef.current?.focus();
+    }
+  }, [isActivePane, shouldEdit]);
+
   return (
     <>
-      <ModalHeader tag="h4" toggle={closeAiAssistantManagementModal} className="pe-4">
-        <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">growi_ai</span>
-        <span className="fw-bold">{t(shouldEdit ? 'modal_ai_assistant.header.update_assistant' : 'modal_ai_assistant.header.add_new_assistant')}</span>
-      </ModalHeader>
+      <AiAssistantManagementHeader
+        hideBackButton
+        labelTranslationKey={shouldEdit ? 'modal_ai_assistant.header.update_assistant' : 'modal_ai_assistant.header.add_new_assistant'}
+      />
 
       <div className="px-4">
         <ModalBody>
@@ -131,6 +142,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
               className="border-0 border-bottom border-2 px-0 rounded-0"
               value={name}
               onChange={e => onNameChange(e.target.value)}
+              innerRef={inputRef}
             />
           </div>
 

+ 100 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.module.scss

@@ -0,0 +1,100 @@
+@use '@growi/core-styles/scss/variables/growi-official-colors';
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+
+.grw-ai-assistant-keyword-search :global {
+  .rbt {
+    .rbt-input-multi {
+      font-size: 1.2rem;
+      border: none;
+      border-bottom: 3px solid bs.$gray-200;
+      border-radius: 0;
+
+      &.focus {
+        border-color: var(--grw-primary-500);
+        box-shadow: none;
+      }
+    }
+
+    .rbt-menu {
+       display: none !important;
+    }
+
+    .rbt-token {
+      align-items: center;
+      justify-content: center;
+      border-radius: bs.$border-radius-xxl;
+
+      .rbt-token-label {
+        display: flex;
+        align-items: center;
+        font-weight: lighter;
+        text-align: center;
+      }
+
+      .rbt-token-remove-button {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+    }
+  }
+
+  .next-button {
+    width: 30%;
+  }
+}
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .grw-ai-assistant-keyword-search :global {
+     .rbt {
+        .rbt-token {
+          background-color: var(--grw-primary-100);
+          .rbt-token-label {
+            color: var(--grw-primary-400);
+          }
+          .rbt-token-remove-button {
+            color: var(--grw-primary-400);
+          }
+        }
+
+        .rbt-token-active {
+          background-color: var(--grw-primary-200);
+          .rbt-token-label {
+            color: var(--grw-primary-500);
+          }
+          .rbt-token-remove-button {
+            color: var(--grw-primary-500);
+          }
+        }
+     }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .grw-ai-assistant-keyword-search :global {
+     .rbt {
+        .rbt-token {
+          background-color: var(--grw-primary-800);
+          .rbt-token-label {
+            color: var(--grw-primary-200);
+          }
+          .rbt-token-remove-button {
+            color: var(--grw-primary-200);
+          }
+        }
+
+        .rbt-token-active {
+          background-color: var(--grw-primary-700);
+          .rbt-token-label {
+            color: var(--grw-primary-100);
+          }
+          .rbt-token-remove-button {
+            color: var(--grw-primary-100);
+          }
+        }
+     }
+  }
+}

+ 228 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx

@@ -0,0 +1,228 @@
+import React, {
+  useRef, useMemo, useCallback, useState, useEffect, type KeyboardEvent,
+} from 'react';
+
+import type { IPageHasId } from '@growi/core';
+import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
+import { type TypeaheadRef, Typeahead } from 'react-bootstrap-typeahead';
+import { useTranslation } from 'react-i18next';
+import {
+  ModalBody,
+} from 'reactstrap';
+import SimpleBar from 'simplebar-react';
+
+import { useSWRxSearch } from '~/stores/search';
+
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
+import { useSelectedPages } from '../../../services/use-selected-pages';
+import {
+  useAiAssistantManagementModal, AiAssistantManagementModalPageMode,
+} from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectablePageList } from './SelectablePageList';
+
+import styles from './AiAssistantManagementKeywordSearch.module.scss';
+
+const moduleClass = styles['grw-ai-assistant-keyword-search'] ?? '';
+
+type SelectedSearchKeyword = {
+  id: string
+  label: string
+}
+
+const isSelectedSearchKeyword = (value: unknown): value is SelectedSearchKeyword => {
+  return (value as SelectedSearchKeyword).label != null;
+};
+
+
+type Props = {
+  isActivePane: boolean
+  baseSelectedPages: SelectablePage[],
+  updateBaseSelectedPages: (pages: SelectablePage[]) => void;
+}
+
+export const AiAssistantKeywordSearch = (props: Props): JSX.Element => {
+  const { isActivePane, baseSelectedPages, updateBaseSelectedPages } = props;
+
+  const [selectedSearchKeywords, setSelectedSearchKeywords] = useState<Array<SelectedSearchKeyword>>([]);
+  const {
+    selectedPages, selectedPagesArray, addPage, removePage,
+  } = useSelectedPages(baseSelectedPages);
+
+  const joinedSelectedSearchKeywords = useMemo(() => {
+    return selectedSearchKeywords.map(item => item.label).join(' ');
+  }, [selectedSearchKeywords]);
+
+  const { t } = useTranslation();
+  const { data: searchResult } = useSWRxSearch(joinedSelectedSearchKeywords, null, {
+    limit: 10,
+    offset: 0,
+    includeUserPages: true,
+    includeTrashPages: false,
+  });
+
+  // Search results will include subordinate pages by default
+  const pagesWithGlobPath = useMemo((): IPageHasId[] | undefined => {
+    if (searchResult == null) {
+      return;
+    }
+
+    const pages = searchResult.data.map(item => item.data);
+    return pages.map((page) => {
+      const newPage = { ...page };
+      if (newPage.path === '/') {
+        newPage.path = '/*';
+        return newPage;
+      }
+      if (!isGlobPatternPath(newPage.path)) {
+        newPage.path = `${newPage.path}/*`;
+      }
+      return newPage;
+    });
+  }, [searchResult]);
+
+  const shownSearchResult = useMemo(() => {
+    return selectedSearchKeywords.length > 0 && searchResult != null && searchResult.data.length > 0;
+  }, [searchResult, selectedSearchKeywords.length]);
+
+
+  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
+  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+
+  const typeaheadRef = useRef<TypeaheadRef>(null);
+
+  const changeHandler = useCallback((selected: Array<SelectedSearchKeyword>) => {
+    setSelectedSearchKeywords(selected);
+  }, []);
+
+  const keyDownHandler = useCallback((event: KeyboardEvent<HTMLElement>) => {
+    if (event.code !== 'Space') {
+      return;
+    }
+
+    if (selectedSearchKeywords.length >= 5) {
+      return;
+    }
+
+    event.preventDefault();
+
+    // fix: https://redmine.weseek.co.jp/issues/140689
+    // "event.isComposing" is not supported
+    const isComposing = event.nativeEvent.isComposing;
+    if (isComposing) {
+      return;
+    }
+
+    const initialItem = typeaheadRef?.current?.state?.initialItem;
+    const handleMenuItemSelect = typeaheadRef?.current?._handleMenuItemSelect;
+    if (initialItem == null || handleMenuItemSelect == null) {
+      return;
+    }
+
+    if (!isSelectedSearchKeyword(initialItem)) {
+      return;
+    }
+
+    const allLabels = selectedSearchKeywords.map(item => item.label);
+    if (allLabels.includes(initialItem.label)) {
+      return;
+    }
+
+    handleMenuItemSelect(initialItem, event);
+  }, [selectedSearchKeywords]);
+
+  const nextButtonClickHandler = useCallback(() => {
+    updateBaseSelectedPages(Array.from(selectedPages.values()));
+    changePageMode(isNewAiAssistant ? AiAssistantManagementModalPageMode.HOME : AiAssistantManagementModalPageMode.PAGES);
+  }, [changePageMode, isNewAiAssistant, selectedPages, updateBaseSelectedPages]);
+
+  // Autofocus
+  useEffect(() => {
+    if (isActivePane) {
+      typeaheadRef.current?.focus();
+    }
+  }, [isActivePane]);
+
+  return (
+    <div className={moduleClass}>
+      <AiAssistantManagementHeader
+        backButtonColor="secondary"
+        backToPageMode={baseSelectedPages.length === 0 ? AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD : AiAssistantManagementModalPageMode.PAGES}
+        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+      />
+
+      <ModalBody className="px-4">
+        <h4 className="text-center fw-bold mb-3 mt-2">
+          {t('modal_ai_assistant.search_reference_pages_by_keyword')}
+        </h4>
+
+        <div className="px-4">
+          <Typeahead
+            allowNew
+            multiple
+            options={[]}
+            selected={selectedSearchKeywords}
+            placeholder={t('modal_ai_assistant.enter_keywords')}
+            id="ai-assistant-keyword-search"
+            ref={typeaheadRef}
+            onChange={changeHandler}
+            onKeyDown={keyDownHandler}
+          />
+
+          <label htmlFor="ai-assistant-keyword-search" className="form-text text-muted mt-2">
+            {t('modal_ai_assistant.max_items_space_separated_hint')}
+          </label>
+        </div>
+
+        { shownSearchResult && (
+          <>
+            <h4 className="text-center fw-bold mb-3 mt-4">
+              {t('modal_ai_assistant.select_assistant_reference_pages')}
+            </h4>
+            <div className="px-4">
+              <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+                <SelectablePageList
+                  isEditable
+                  pages={pagesWithGlobPath ?? []}
+                  method="add"
+                  onClickMethodButton={addPage}
+                  disablePagePaths={selectedPagesArray.map(page => page.path)}
+                />
+              </SimpleBar>
+            </div>
+          </>
+        )}
+
+        <h4 className="text-center fw-bold mb-3 mt-4">
+          {t('modal_ai_assistant.reference_pages')}
+        </h4>
+
+        <div className="px-4">
+          <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+            <SelectablePageList
+              pages={selectedPagesArray}
+              method="remove"
+              onClickMethodButton={removePage}
+            />
+          </SimpleBar>
+          <label className="form-text text-muted mt-2">
+            {t('modal_ai_assistant.can_add_later')}
+          </label>
+
+        </div>
+
+        <div className="d-flex justify-content-center mt-4">
+          <button
+            disabled={selectedPages.size === 0}
+            type="button"
+            className="btn btn-primary rounded next-button"
+            onClick={nextButtonClickHandler}
+          >
+            {t('modal_ai_assistant.next')}
+          </button>
+        </div>
+      </ModalBody>
+    </div>
+  );
+};

+ 49 - 23
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -2,21 +2,23 @@ import React, {
   useCallback, useState, useEffect, type JSX,
 } from 'react';
 
+import type { IPageHasId } from '@growi/core';
 import {
   type IGrantedGroup, isPopulated,
 } from '@growi/core';
+import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'react-i18next';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { UpsertAiAssistantData } from '~/features/openai/interfaces/ai-assistant';
 import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
-import type { IPagePathWithDescendantCount, IPageForItem } from '~/interfaces/page';
+import type { IPagePathWithDescendantCount } 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 type { SelectablePage } from '../../../../interfaces/selectable-page';
 import { removeGlobPath } from '../../../../utils/remove-glob-path';
 import { createAiAssistant, updateAiAssistant } from '../../../services/ai-assistant';
 import {
@@ -30,6 +32,9 @@ import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEdi
 import { AiAssistantManagementEditPages } from './AiAssistantManagementEditPages';
 import { AiAssistantManagementEditShare } from './AiAssistantManagementEditShare';
 import { AiAssistantManagementHome } from './AiAssistantManagementHome';
+import { AiAssistantKeywordSearch } from './AiAssistantManagementKeywordSearch';
+import { AiAssistantManagementPageSelectionMethod } from './AiAssistantManagementPageSelectionMethod';
+import { AiAssistantManagementPageTreeSelection } from './AiAssistantManagementPageTreeSelection';
 
 import styles from './AiAssistantManagementModal.module.scss';
 
@@ -51,14 +56,13 @@ const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): Popul
   return populatedGrantedGroups;
 };
 
-const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectedPage[] => {
+const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectablePage[] => {
   return pagePathPatterns.map((pagePathPattern) => {
-    const isIncludeSubPage = pagePathPattern.endsWith('/*');
-    const path = isIncludeSubPage ? pagePathPattern.slice(0, -2) : pagePathPattern;
-    const page = pagePathsWithDescendantCount.find(page => page.path === path);
+    const pathWithoutGlob = isGlobPatternPath(pagePathPattern) ? pagePathPattern.slice(0, -2) : pagePathPattern;
+    const page = pagePathsWithDescendantCount.find(p => p.path === pathWithoutGlob);
     return {
-      page: page ?? { path },
-      isIncludeSubPage,
+      ...page,
+      path: pagePathPattern,
     };
   });
 };
@@ -88,7 +92,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const [selectedAccessScope, setSelectedAccessScope] = useState<AiAssistantAccessScope>(AiAssistantAccessScope.OWNER);
   const [selectedUserGroupsForAccessScope, setSelectedUserGroupsForAccessScope] = useState<PopulatedGrantedGroup[]>([]);
   const [selectedUserGroupsForShareScope, setSelectedUserGroupsForShareScope] = useState<PopulatedGrantedGroup[]>([]);
-  const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
+  const [selectedPages, setSelectedPages] = useState<SelectablePage[]>([]);
   const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
 
 
@@ -113,6 +117,14 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   }, [aiAssistant?.pagePathPatterns, pagePathsWithDescendantCount, shouldEdit]);
 
 
+  /*
+  *  For AiAssistantManagementKeywordSearch & AiAssistantManagementPageTreeSelection methods
+  */
+  const selectPageHandler = useCallback((pages: IPageHasId[]) => {
+    setSelectedPages(pages);
+  }, []);
+
+
   /*
   *  For AiAssistantManagementHome methods
   */
@@ -127,8 +139,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   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);
+        .map(selectedPage => selectedPage.path);
 
       const grantedGroupsForShareScope = selectedShareScope === AiAssistantShareScope.GROUPS
         ? convertToGrantedGroups(selectedUserGroupsForShareScope)
@@ -167,8 +178,11 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       toastError(shouldEdit ? t('modal_ai_assistant.toaster.update_failed') : t('modal_ai_assistant.toaster.create_failed'));
       logger.error(err);
     }
-  // eslint-disable-next-line max-len
-  }, [t, selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, aiAssistant?._id, mutateAiAssistants, closeAiAssistantManagementModal]);
+  }, [
+    selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope,
+    selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, t, mutateAiAssistants,
+    closeAiAssistantManagementModal, aiAssistant?._id, aiAssistantSidebarData?.aiAssistantData?._id, refreshAiAssistantData,
+  ]);
 
 
   /*
@@ -210,15 +224,8 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   /*
   *  For AiAssistantManagementEditPages methods
   */
-  const selectPageHandler = useCallback((page: IPageForItem, isIncludeSubPage: boolean) => {
-    const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page.path);
-    if (page.path != null && !selectedPageIds.includes(page.path)) {
-      setSelectedPages([...selectedPages, { page, isIncludeSubPage }]);
-    }
-  }, [selectedPages]);
-
   const removePageHandler = useCallback((pagePath: string) => {
-    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page.path !== pagePath));
+    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.path !== pagePath));
   }, [selectedPages]);
 
 
@@ -236,8 +243,28 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   return (
     <>
       <TabContent activeTab={pageMode}>
+        <TabPane tabId={AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD}>
+          <AiAssistantManagementPageSelectionMethod />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.KEYWORD_SEARCH}>
+          <AiAssistantKeywordSearch
+            isActivePane={pageMode === AiAssistantManagementModalPageMode.KEYWORD_SEARCH}
+            baseSelectedPages={selectedPages}
+            updateBaseSelectedPages={selectPageHandler}
+          />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.PAGE_TREE_SELECTION}>
+          <AiAssistantManagementPageTreeSelection
+            baseSelectedPages={selectedPages}
+            updateBaseSelectedPages={selectPageHandler}
+          />
+        </TabPane>
+
         <TabPane tabId={AiAssistantManagementModalPageMode.HOME}>
           <AiAssistantManagementHome
+            isActivePane={pageMode === AiAssistantManagementModalPageMode.HOME}
             shouldEdit={shouldEdit}
             name={name}
             description={description}
@@ -269,7 +296,6 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         <TabPane tabId={AiAssistantManagementModalPageMode.PAGES}>
           <AiAssistantManagementEditPages
             selectedPages={selectedPages}
-            onSelect={selectPageHandler}
             onRemove={removePageHandler}
           />
         </TabPane>
@@ -293,7 +319,7 @@ export const AiAssistantManagementModal = (): JSX.Element => {
   const isOpened = aiAssistantManagementModalData?.isOpened ?? false;
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManagementModal} className={moduleClass} scrollable>
+    <Modal size="lg" isOpen={isOpened} toggle={closeAiAssistantManagementModal} className={moduleClass}>
       { isOpened && (
         <AiAssistantManagementModalSubstance />
       ) }

+ 35 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageSelectionMethod.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  ModalBody,
+} from 'reactstrap';
+
+import { useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { PageSelectionMethodButtons } from './PageSelectionMethodButtons';
+
+export const AiAssistantManagementPageSelectionMethod = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+
+  return (
+    <>
+      <AiAssistantManagementHeader
+        hideBackButton={isNewAiAssistant}
+        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+      />
+
+      <ModalBody className="px-4">
+        <h4 className="text-center fw-bold mb-4 mt-2">
+          {t('modal_ai_assistant.select_source_pages')}
+        </h4>
+
+        <PageSelectionMethodButtons />
+
+      </ModalBody>
+    </>
+  );
+};

+ 11 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss

@@ -0,0 +1,11 @@
+.grw-ai-assistant-management-page-tree-selection :global {
+  .next-button {
+    width: 30%;
+  }
+
+  .page-tree-item {
+    .list-group-item {
+      padding: 0.4rem 1rem !important;
+    }
+  }
+}

+ 171 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx

@@ -0,0 +1,171 @@
+import React, {
+  Suspense, useCallback, memo,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  ModalBody,
+} from 'reactstrap';
+import SimpleBar from 'simplebar-react';
+
+import { ItemsTree } from '~/client/components/ItemsTree';
+import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
+import type { TreeItemProps } from '~/client/components/TreeItem';
+import { TreeItemLayout } from '~/client/components/TreeItem';
+import type { IPageForItem } from '~/interfaces/page';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+
+import { type SelectablePage, isSelectablePage } from '../../../../interfaces/selectable-page';
+import { useSelectedPages } from '../../../services/use-selected-pages';
+import { AiAssistantManagementModalPageMode, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectablePageList } from './SelectablePageList';
+
+import styles from './AiAssistantManagementPageTreeSelection.module.scss';
+
+const moduleClass = styles['grw-ai-assistant-management-page-tree-selection'] ?? '';
+
+const SelectablePageTree = memo((props: { onClickAddPageButton: (page: SelectablePage) => void }) => {
+  const { onClickAddPageButton } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+  const pageTreeItemClickHandler = useCallback((page: IPageForItem) => {
+    if (!isSelectablePage(page)) {
+      return;
+    }
+
+    onClickAddPageButton(page);
+  }, [onClickAddPageButton]);
+
+  const PageTreeItem = (props: TreeItemProps) => {
+    const { itemNode } = props;
+    const { page } = itemNode;
+
+    const SelectPageButton = () => {
+      return (
+        <button
+          type="button"
+          className="border-0 rounded btn p-0"
+          onClick={(e) => {
+            e.stopPropagation();
+            pageTreeItemClickHandler(page);
+          }}
+        >
+          <span className="material-symbols-outlined p-0 me-2 text-primary">add_circle</span>
+        </button>
+      );
+    };
+
+    return (
+      <TreeItemLayout
+        {...props}
+        itemClass={PageTreeItem}
+        className="text-muted"
+        customHoveredEndComponents={[SelectPageButton]}
+      />
+    );
+  };
+
+  return (
+    <div className="page-tree-item">
+      <ItemsTree
+        targetPath="/"
+        isEnableActions={!isGuestUser}
+        isReadOnlyUser={!!isReadOnlyUser}
+        CustomTreeItem={PageTreeItem}
+      />
+    </div>
+  );
+});
+
+type Props = {
+  baseSelectedPages: SelectablePage[],
+  updateBaseSelectedPages: (pages: SelectablePage[]) => void;
+}
+
+export const AiAssistantManagementPageTreeSelection = (props: Props): JSX.Element => {
+  const { baseSelectedPages, updateBaseSelectedPages } = props;
+
+  const { t } = useTranslation();
+  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
+  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+
+  const {
+    selectedPages, selectedPagesRef, selectedPagesArray, addPage, removePage,
+  } = useSelectedPages(baseSelectedPages);
+
+
+  const addPageButtonClickHandler = useCallback((page: SelectablePage) => {
+    const pagePathWithGlob = `${page.path}/*`;
+    if (selectedPagesRef.current == null || selectedPagesRef.current.has(pagePathWithGlob)) {
+      return;
+    }
+
+    const clonedPage = { ...page };
+    clonedPage.path = pagePathWithGlob;
+
+    addPage(clonedPage);
+  }, [
+    addPage,
+    selectedPagesRef, // Prevent flickering (use ref to avoid method recreation)
+  ]);
+
+  const nextButtonClickHandler = useCallback(() => {
+    updateBaseSelectedPages(Array.from(selectedPages.values()));
+    changePageMode(isNewAiAssistant ? AiAssistantManagementModalPageMode.HOME : AiAssistantManagementModalPageMode.PAGES);
+  }, [changePageMode, isNewAiAssistant, selectedPages, updateBaseSelectedPages]);
+
+  return (
+    <div className={moduleClass}>
+      <AiAssistantManagementHeader
+        backButtonColor="secondary"
+        backToPageMode={baseSelectedPages.length === 0 ? AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD : AiAssistantManagementModalPageMode.PAGES}
+        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+      />
+
+      <ModalBody className="px-4">
+        <h4 className="text-center fw-bold mb-3 mt-2">
+          {t('modal_ai_assistant.search_reference_pages_by_keyword')}
+        </h4>
+
+        <Suspense fallback={<ItemsTreeContentSkeleton />}>
+          <div className="px-4">
+            <SelectablePageTree onClickAddPageButton={addPageButtonClickHandler} />
+          </div>
+        </Suspense>
+
+        <h4 className="text-center fw-bold mb-3 mt-4">
+          {t('modal_ai_assistant.reference_pages')}
+        </h4>
+
+        <div className="px-4">
+          <SimpleBar className="page-list-container" style={{ maxHeight: '300px' }}>
+            <SelectablePageList
+              method="remove"
+              methodButtonPosition="right"
+              pages={selectedPagesArray}
+              onClickMethodButton={removePage}
+            />
+          </SimpleBar>
+          <label className="form-text text-muted mt-2">
+            {t('modal_ai_assistant.can_add_later')}
+          </label>
+        </div>
+
+        <div className="d-flex justify-content-center mt-4">
+          <button
+            type="button"
+            className="btn btn-primary rounded next-button"
+            disabled={selectedPages.size === 0}
+            onClick={nextButtonClickHandler}
+          >
+            {t('modal_ai_assistant.next')}
+          </button>
+        </div>
+      </ModalBody>
+    </div>
+  );
+};

+ 28 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.module.scss

@@ -0,0 +1,28 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+// == Colors
+.page-selection-method-buttons :global {
+  .page-selection-method-btn {
+      &:hover {
+        color: var(--bs-primary);
+        background-color: rgba(var(--bs-primary-rgb), 0.1);
+        border-color: var(--bs-primary) !important;
+      }
+    }
+}
+
+@include bs.color-mode(light) {
+  .page-selection-method-buttons :global {
+    .page-selection-method-btn {
+      border: 2px solid bs.$gray-300;
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .page-selection-method-buttons :global {
+    .page-selection-method-btn {
+      border: 2px solid bs.$gray-700;
+    }
+  }
+}

+ 55 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageSelectionMethodButtons.tsx

@@ -0,0 +1,55 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
+
+import styles from './PageSelectionMethodButtons.module.scss';
+
+const moduleClass = styles['page-selection-method-buttons'] ?? '';
+
+const SelectionButton = (props: { icon: string, label: string, onClick: () => void }): JSX.Element => {
+  const { icon, label, onClick } = props;
+
+  return (
+    <button
+      type="button"
+      className="btn text-center py-4 w-100 page-selection-method-btn"
+      onClick={onClick}
+    >
+      <span
+        className="material-symbols-outlined d-block mb-3 fs-1"
+      >
+        {icon}
+      </span>
+      <div>{label}</div>
+    </button>
+  );
+};
+
+
+export const PageSelectionMethodButtons = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { changePageMode } = useAiAssistantManagementModal();
+
+  return (
+    <div className={moduleClass}>
+      <div className="row g-3">
+        <div className="col">
+          <SelectionButton
+            icon="manage_search"
+            label={t('modal_ai_assistant.search_by_keyword')}
+            onClick={() => changePageMode(AiAssistantManagementModalPageMode.KEYWORD_SEARCH)}
+          />
+        </div>
+        <div className="col">
+          <SelectionButton
+            icon="account_tree"
+            label={t('modal_ai_assistant.select_from_page_tree')}
+            onClick={() => changePageMode(AiAssistantManagementModalPageMode.PAGE_TREE_SELECTION)}
+          />
+        </div>
+      </div>
+    </div>
+  );
+};

+ 43 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.module.scss

@@ -0,0 +1,43 @@
+@use '~/styles/variables' as var;
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+ .selectable-page-list :global {
+    .page-path {
+      display: inline-block;
+      max-width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      vertical-align: middle;
+      border: 2px solid transparent;
+    }
+
+    .page-path-editable {
+      cursor: pointer;
+      &:hover {
+        border-color: var(--bs-primary-border-subtle);
+      }
+    }
+
+    .page-path-input {
+      border: 2px solid var(--bs-border-color);
+    }
+}
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .selectable-page-list :global {
+    .page-list-item {
+      background-color: #{bs.$gray-100};
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .selectable-page-page-list :global {
+    .page-list-item {
+      background-color: #{bs.$gray-900};
+    }
+  }
+}

+ 249 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePageList.tsx

@@ -0,0 +1,249 @@
+import React, {
+  useMemo, memo, useState, useCallback, useRef, useEffect,
+} from 'react';
+
+import { pathUtils } from '@growi/core/dist/utils';
+import { useRect } from '@growi/ui/dist/utils';
+import { useTranslation } from 'react-i18next';
+import AutosizeInput from 'react-input-autosize';
+
+import { type SelectablePage } from '../../../../interfaces/selectable-page';
+import { isCreatablePagePathPattern } from '../../../../utils/is-creatable-page-path-pattern';
+
+import styles from './SelectablePageList.module.scss';
+
+const moduleClass = styles['selectable-page-list'] ?? '';
+
+type MethodButtonProps = {
+  page: SelectablePage;
+  disablePagePaths: string[];
+  method: 'add' | 'remove' | 'delete'
+  onClickMethodButton: (page: SelectablePage) => void;
+}
+
+const MethodButton = memo((props: MethodButtonProps) => {
+  const {
+    page,
+    disablePagePaths,
+    method,
+    onClickMethodButton,
+  } = props;
+
+  const iconName = useMemo(() => {
+    switch (method) {
+      case 'add':
+        return 'add_circle';
+      case 'remove':
+        return 'do_not_disturb_on';
+      case 'delete':
+        return 'delete';
+      default:
+        return '';
+    }
+  }, [method]);
+
+  const color = useMemo(() => {
+    switch (method) {
+      case 'add':
+        return 'text-primary';
+      case 'remove':
+        return 'text-secondary';
+      case 'delete':
+        return 'text-secondary';
+      default:
+        return '';
+    }
+  }, [method]);
+
+  return (
+    <button
+      type="button"
+      className={`btn border-0 ${color}`}
+      disabled={disablePagePaths.includes(page.path)}
+      onClick={(e) => {
+        e.stopPropagation();
+        onClickMethodButton(page);
+      }}
+    >
+      <span className="material-symbols-outlined">
+        {iconName}
+      </span>
+    </button>
+  );
+});
+
+
+type EditablePagePathProps = {
+  isEditable?: boolean;
+  page: SelectablePage;
+  disablePagePaths: string[];
+  methodButtonPosition?: 'left' | 'right';
+}
+
+const EditablePagePath = memo((props: EditablePagePathProps): JSX.Element => {
+  const {
+    page,
+    isEditable,
+    disablePagePaths = [],
+    methodButtonPosition = 'left',
+  } = props;
+
+  const [editingPagePath, setEditingPagePath] = useState<string | null>(null);
+  const [inputValue, setInputValue] = useState('');
+
+  const inputRef = useRef<HTMLInputElement & AutosizeInput | null>(null);
+  const editingContainerRef = useRef<HTMLDivElement>(null);
+  const [editingContainerRect] = useRect(editingContainerRef);
+
+  const isEditing = isEditable && editingPagePath === page.path;
+
+  const handlePagePathClick = useCallback((page: SelectablePage) => {
+    if (!isEditable || disablePagePaths.includes(page.path)) {
+      return;
+    }
+    setEditingPagePath(page.path);
+    setInputValue(page.path);
+  }, [disablePagePaths, isEditable]);
+
+  const handleInputBlur = useCallback(() => {
+    setEditingPagePath(null);
+  }, []);
+
+  const handleInputKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'Enter') {
+
+      // Validate page path
+      const pagePathWithSlash = pathUtils.addHeadingSlash(inputValue);
+      if (inputValue === '' || disablePagePaths.includes(pagePathWithSlash) || !isCreatablePagePathPattern(pagePathWithSlash)) {
+        handleInputBlur();
+        return;
+      }
+
+      // Update page path
+      page.path = pagePathWithSlash;
+
+      handleInputBlur();
+    }
+  }, [disablePagePaths, handleInputBlur, inputValue, page]);
+
+  // Autofocus
+  useEffect(() => {
+    if (editingPagePath != null && inputRef.current != null) {
+      inputRef.current.focus();
+    }
+  }, [editingPagePath]);
+
+  return (
+    <div
+      ref={editingContainerRef}
+      className={`flex-grow-1 ${methodButtonPosition === 'left' ? 'me-2' : 'mx-2'}`}
+      style={{ minWidth: 0 }}
+    >
+      {isEditing
+        ? (
+          <AutosizeInput
+            id="page-path-input"
+            inputClassName="page-path-input"
+            type="text"
+            ref={inputRef}
+            value={inputValue}
+            onBlur={handleInputBlur}
+            onChange={e => setInputValue(e.target.value)}
+            onKeyDown={handleInputKeyDown}
+            inputStyle={{ maxWidth: (editingContainerRect?.width ?? 0) - 10 }}
+          />
+        )
+        : (
+          <span
+            className={`page-path ${isEditable && !disablePagePaths.includes(page.path) ? 'page-path-editable' : ''}`}
+            onClick={() => handlePagePathClick(page)}
+            title={page.path}
+          >
+            {page.path}
+          </span>
+        )}
+    </div>
+  );
+});
+
+
+type SelectablePageListProps = {
+  pages: SelectablePage[],
+  method: 'add' | 'remove' | 'delete'
+  methodButtonPosition?: 'left' | 'right',
+  disablePagePaths?: string[],
+  isEditable?: boolean,
+  onClickMethodButton: (page: SelectablePage) => void,
+}
+
+export const SelectablePageList = (props: SelectablePageListProps): JSX.Element => {
+  const {
+    pages,
+    method,
+    methodButtonPosition = 'left',
+    disablePagePaths = [],
+    isEditable,
+    onClickMethodButton,
+  } = props;
+
+  const { t } = useTranslation();
+
+  if (pages.length === 0) {
+    return (
+      <div className={moduleClass}>
+        <div className="border-0 text-center page-list-item rounded py-3">
+          <p className="text-muted mb-0">{t('modal_ai_assistant.no_pages_selected')}</p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className={`list-group ${moduleClass}`}>
+      {pages.map((page) => {
+        return (
+          <div
+            key={page.path}
+            className="list-group-item border-0 page-list-item d-flex align-items-center p-1 mb-2 rounded"
+          >
+
+            {methodButtonPosition === 'left'
+              && (
+                <MethodButton
+                  page={page}
+                  method={method}
+                  disablePagePaths={disablePagePaths}
+                  onClickMethodButton={onClickMethodButton}
+                />
+              )
+            }
+
+            <EditablePagePath
+              page={page}
+              isEditable={isEditable}
+              disablePagePaths={disablePagePaths}
+              methodButtonPosition={methodButtonPosition}
+            />
+
+            <span className={`badge bg-body-secondary rounded-pill ${methodButtonPosition === 'left' ? 'me-2' : ''}`}>
+              <span className="text-body-tertiary">
+                {page.descendantCount}
+              </span>
+            </span>
+
+            {methodButtonPosition === 'right'
+              && (
+                <MethodButton
+                  page={page}
+                  method={method}
+                  disablePagePaths={disablePagePaths}
+                  onClickMethodButton={onClickMethodButton}
+                />
+              )
+            }
+          </div>
+        );
+      })}
+    </div>
+  );
+};

+ 0 - 43
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx

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

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

@@ -5,11 +5,11 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import type { SelectedPage } from '../../../../interfaces/selected-page';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 
 type Props = {
   isOpen: boolean,
-  selectedPages: SelectedPage[],
+  selectedPages: SelectablePage[],
   closeModal: () => void,
   onSubmit: () => Promise<void>,
 }
@@ -48,8 +48,8 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
         <div className="mb-4">
           <p className="mb-2 text-secondary">{t('share_scope_warning_modal.selected_pages_label')}</p>
           {selectedPages.map(selectedPage => (
-            <code key={selectedPage.page.path}>
-              {selectedPage.page.path}
+            <code key={selectedPage.path}>
+              {selectedPage.path}
             </code>
           ))}
         </div>

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

@@ -183,7 +183,7 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
         </h3>
         <span
           className="material-symbols-outlined"
-        >{`keyboard_arrow_${isCollapsed ? 'up' : 'down'}`}
+        >{`keyboard_arrow_${isCollapsed ? 'down' : 'right'}`}
         </span>
       </button>
 

+ 72 - 0
apps/app/src/features/openai/client/services/use-selected-pages.tsx

@@ -0,0 +1,72 @@
+import {
+  useState, useCallback, useEffect, useMemo, useRef,
+} from 'react';
+
+import type { SelectablePage } from '../../interfaces/selectable-page';
+import { useAiAssistantManagementModal } from '../stores/ai-assistant';
+
+
+type UseSelectedPages = {
+  selectedPages: Map<string, SelectablePage>,
+  selectedPagesRef: React.RefObject<Map<string, SelectablePage>>,
+  selectedPagesArray: SelectablePage[],
+  addPage: (page: SelectablePage) => void,
+  removePage: (page: SelectablePage) => void,
+}
+
+export const useSelectedPages = (initialPages?: SelectablePage[]): UseSelectedPages => {
+  const [selectedPages, setSelectedPages] = useState<Map<string, SelectablePage>>(new Map());
+  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+
+  const selectedPagesRef = useRef(selectedPages);
+
+  const selectedPagesArray = useMemo(() => {
+    return Array.from(selectedPages.values());
+  }, [selectedPages]);
+
+  useEffect(() => {
+    selectedPagesRef.current = selectedPages;
+  }, [selectedPages]);
+
+  useEffect(() => {
+    // Initialize each time PageMode is changed
+    if (initialPages != null && aiAssistantManagementModalData?.pageMode != null) {
+      const initialMap = new Map<string, SelectablePage>();
+      initialPages.forEach((page) => {
+        if (page.path != null) {
+          initialMap.set(page.path, page);
+        }
+      });
+      setSelectedPages(initialMap);
+    }
+  }, [aiAssistantManagementModalData?.pageMode, initialPages]);
+
+  const addPage = useCallback((page: SelectablePage) => {
+    setSelectedPages((prev) => {
+      const newMap = new Map(prev);
+      if (page.path != null) {
+        newMap.set(page.path, page);
+      }
+      return newMap;
+    });
+  }, []);
+
+  const removePage = useCallback((page: SelectablePage) => {
+    setSelectedPages((prev) => {
+      const newMap = new Map(prev);
+      if (page.path != null) {
+        newMap.delete(page.path);
+      }
+      return newMap;
+    });
+  }, []);
+
+
+  return {
+    selectedPages,
+    selectedPagesRef,
+    selectedPagesArray,
+    addPage,
+    removePage,
+  };
+};

+ 17 - 2
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -9,14 +9,21 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { type AccessibleAiAssistantsHasId, type AiAssistantHasId } from '../../interfaces/ai-assistant';
 import type { IThreadRelationHasId } from '../../interfaces/thread-relation'; // IThreadHasId を削除
 
+
+/*
+*  useAiAssistantManagementModal
+*/
 export const AiAssistantManagementModalPageMode = {
   HOME: 'home',
   SHARE: 'share',
   PAGES: 'pages',
   INSTRUCTION: 'instruction',
+  PAGE_SELECTION_METHOD: 'page-selection-method',
+  KEYWORD_SEARCH: 'keyword-search',
+  PAGE_TREE_SELECTION: 'page-tree-selection',
 } as const;
 
-type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageMode[keyof typeof AiAssistantManagementModalPageMode];
+export type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageMode[keyof typeof AiAssistantManagementModalPageMode];
 
 type AiAssistantManagementModalStatus = {
   isOpened: boolean,
@@ -38,7 +45,15 @@ export const useAiAssistantManagementModal = (
 
   return {
     ...swrResponse,
-    open: useCallback((aiAssistantData) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]),
+    open: useCallback((aiAssistantData) => {
+      swrResponse.mutate({
+        isOpened: true,
+        aiAssistantData,
+        pageMode: aiAssistantData != null
+          ? AiAssistantManagementModalPageMode.HOME
+          : AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD,
+      });
+    }, [swrResponse]),
     close: useCallback(() => swrResponse.mutate({ isOpened: false, aiAssistantData: undefined }), [swrResponse]),
     changePageMode: useCallback((pageMode: AiAssistantManagementModalPageMode) => {
       swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode, aiAssistantData: swrResponse.data?.aiAssistantData });

+ 10 - 0
apps/app/src/features/openai/interfaces/selectable-page.ts

@@ -0,0 +1,10 @@
+import type { IPageHasId } from '@growi/core';
+
+import type { IPageForItem } from '~/interfaces/page';
+
+export type SelectablePage = Partial<IPageHasId> & { path: string }
+
+// type guard
+export const isSelectablePage = (page: IPageForItem): page is SelectablePage => {
+  return page.path != null;
+};

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

@@ -1,6 +0,0 @@
-import type { IPageForItem } from '~/interfaces/page';
-
-export type SelectedPage = {
-  page: IPageForItem,
-  isIncludeSubPage: boolean,
-}

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

@@ -1,6 +1,6 @@
 import { GroupType } from '@growi/core';
-import { isGlobPatternPath, isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { type ValidationChain, body } from 'express-validator';
+import { isCreatablePagePathPattern } from '../../../utils/is-creatable-page-path-pattern';
 
 import { AiAssistantShareScope, AiAssistantAccessScope } from '../../../interfaces/ai-assistant';
 
@@ -42,12 +42,7 @@ export const upsertAiAssistantValidator: ValidationChain[] = [
     .notEmpty()
     .withMessage('pagePathPatterns must not be empty')
     .custom((value: string) => {
-      // check if the value is a glob pattern path
-      if (value.includes('*')) {
-        return isGlobPatternPath(value) && isCreatablePage(value.replaceAll('*', ''));
-      }
-
-      return isCreatablePage(value);
+      return isCreatablePagePathPattern(value);
     }),
 
   body('grantedGroupsForShareScope')

+ 13 - 0
apps/app/src/features/openai/utils/is-creatable-page-path-pattern.ts

@@ -0,0 +1,13 @@
+import { pagePathUtils } from '@growi/core/dist/utils';
+import { removeGlobPath } from './remove-glob-path';
+
+export const isCreatablePagePathPattern = (pagePath: string): boolean => {
+  const isGlobPattern = pagePathUtils.isGlobPatternPath(pagePath);
+  if (isGlobPattern) {
+    // Remove glob pattern since glob paths are non-creatable in GROWI
+    const pathWithoutGlob = removeGlobPath([pagePath])[0];
+    return pagePathUtils.isCreatablePage(pathWithoutGlob);
+  }
+
+  return pagePathUtils.isCreatablePage(pagePath);
+};