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

Merge pull request #10204 from weseek/feat/169633-implementation-of-ui-to-select-pages-to-use-from-search-results

Shun Miyazawa 8 месяцев назад
Родитель
Сommit
7addf54cf8

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

@@ -606,6 +606,10 @@
     "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",

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

@@ -600,6 +600,10 @@
     "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",

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

@@ -639,6 +639,10 @@
     "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",

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

@@ -597,6 +597,10 @@
     "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",

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

@@ -1,11 +1,11 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
-@use '~/styles/variables' as var;
 
 
 .grw-ai-assistant-keyword-search :global {
   .rbt {
     .rbt-input-multi {
+      font-size: 1.4rem;
       border: none;
       border-bottom: 3px solid bs.$gray-200;
       border-radius: 0;
@@ -19,32 +19,82 @@
     .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;
+      }
+    }
   }
 
-  .list-group-item {
-    border: none;
+  .next-button {
+    width: 30%;
   }
 }
 
+
 // == Colors
 @include bs.color-mode(light) {
   .grw-ai-assistant-keyword-search :global {
-    .list-group-item {
-      background-color: #{bs.$gray-100};
-        &:hover {
+     .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 {
-    .list-group-item {
-      background-color: #{bs.$gray-900};
-      &:hover {
-        background-color: var(--grw-primary-800);
-      }
-    }
+     .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);
+          }
+        }
+     }
   }
 }

+ 86 - 38
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementKeywordSearch.tsx

@@ -2,6 +2,8 @@ import React, {
   useRef, useMemo, useCallback, useState, 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 {
@@ -15,6 +17,7 @@ import {
 } from '../../../stores/ai-assistant';
 
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectablePagePageList } from './SelectablePagePageList';
 
 import styles from './AiAssistantManagementKeywordSearch.module.scss';
 
@@ -29,8 +32,12 @@ const isSelectedSearchKeyword = (value: unknown): value is SelectedSearchKeyword
   return (value as SelectedSearchKeyword).label != null;
 };
 
-export const AiAssistantKeywordSearch = (): JSX.Element => {
+
+export const AiAssistantKeywordSearch = (props: { updateBaseSelectedPages: (pages: IPageHasId[]) => void}): JSX.Element => {
+  const { updateBaseSelectedPages } = props;
+
   const [selectedSearchKeywords, setSelectedSearchKeywords] = useState<Array<SelectedSearchKeyword>>([]);
+  const [selectedPages, setSelectedPages] = useState<Map<string, IPageHasId>>(new Map());
 
   const joinedSelectedSearchKeywords = useMemo(() => {
     return selectedSearchKeywords.map(item => item.label).join(' ');
@@ -44,11 +51,33 @@ export const AiAssistantKeywordSearch = (): JSX.Element => {
     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) => {
+      if (page.path === '/') {
+        page.path = '/*';
+      }
+
+      if (!isGlobPatternPath(page.path)) {
+        page.path = `${page.path}/*`;
+      }
+
+      return page;
+    });
+
+  }, [searchResult]);
+
   const shownSearchResult = useMemo(() => {
     return selectedSearchKeywords.length > 0 && searchResult != null && searchResult.data.length > 0;
   }, [searchResult, selectedSearchKeywords.length]);
 
-  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+
+  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
   const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
 
   const typeaheadRef = useRef<TypeaheadRef>(null);
@@ -93,6 +122,27 @@ export const AiAssistantKeywordSearch = (): JSX.Element => {
     handleMenuItemSelect(initialItem, event);
   }, [selectedSearchKeywords]);
 
+  const addPageHandler = useCallback((page: IPageHasId) => {
+    setSelectedPages((prev) => {
+      const newMap = new Map(prev);
+      newMap.set(page._id, page);
+      return newMap;
+    });
+  }, []);
+
+  const removePageHandler = useCallback((page: IPageHasId) => {
+    setSelectedPages((prev) => {
+      const newMap = new Map(prev);
+      newMap.delete(page._id);
+      return newMap;
+    });
+  }, []);
+
+  const nextButtonClickHandler = useCallback(() => {
+    updateBaseSelectedPages(Array.from(selectedPages.values()));
+    changePageMode(AiAssistantManagementModalPageMode.HOME);
+  }, [changePageMode, selectedPages, updateBaseSelectedPages]);
+
   return (
     <div className={moduleClass}>
       <AiAssistantManagementHeader
@@ -126,47 +176,45 @@ export const AiAssistantKeywordSearch = (): JSX.Element => {
 
         { shownSearchResult && (
           <>
-            <h4 className="text-center fw-bold mb-4 mt-5">
+            <h4 className="text-center fw-bold mb-3 mt-4">
               {t('modal_ai_assistant.select_assistant_reference_pages')}
             </h4>
-
-            <div className="px-4 list-group">
-              {searchResult?.data.map((page) => {
-                return (
-                  <button
-                    type="button"
-                    className="list-group-item list-group-item-action d-flex align-items-center p-1 mb-2 rounded"
-                    onClick={(e) => {
-                      e.stopPropagation();
-                    }}
-                  >
-                    <button
-                      type="button"
-                      className="btn text-primary"
-                      onClick={(e) => {
-                        e.stopPropagation();
-                      }}
-                    >
-                      <span className="material-symbols-outlined">
-                        add_circle
-                      </span>
-                    </button>
-                    <div className="flex-grow-1">
-                      <span>
-                        {page.data.path}
-                      </span>
-                    </div>
-                    <span className="badge bg-body-secondary rounded-pill me-2">
-                      <span className="text-body-tertiary">
-                        {page.data.descendantCount}
-                      </span>
-                    </span>
-                  </button>
-                );
-              })}
+            <div className="px-4">
+              <SelectablePagePageList
+                pages={pagesWithGlobPath ?? []}
+                method="add"
+                onClickMethodButton={addPageHandler}
+                disablePageIds={Array.from(selectedPages.keys())}
+              />
             </div>
           </>
         )}
+
+        <h4 className="text-center fw-bold mb-3 mt-4">
+          {t('modal_ai_assistant.reference_pages')}
+        </h4>
+
+        <div className="px-4">
+          <SelectablePagePageList
+            pages={Array.from(selectedPages.values())}
+            method="remove"
+            onClickMethodButton={removePageHandler}
+          />
+          <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>
   );

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

@@ -2,9 +2,11 @@ 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';
 
@@ -115,6 +117,29 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   }, [aiAssistant?.pagePathPatterns, pagePathsWithDescendantCount, shouldEdit]);
 
 
+  /*
+  *  For AiAssistantManagementKeywordSearch methods
+  */
+  const selectPageHandlerByKeywordSearch = useCallback((pages: IPageHasId[]) => {
+    if (pages.length === 0) {
+      return;
+    }
+
+    const convertedSelectedPages = pages.map((page) => {
+      const pagePath = page.path;
+      page.path = removeGlobPath([pagePath])[0];
+
+      return {
+        page,
+        // Determine whether to include subordinate pages from path
+        isIncludeSubPage: isGlobPatternPath(pagePath),
+      };
+    });
+
+    setSelectedPages(convertedSelectedPages);
+  }, []);
+
+
   /*
   *  For AiAssistantManagementHome methods
   */
@@ -246,7 +271,9 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         </TabPane>
 
         <TabPane tabId={AiAssistantManagementModalPageMode.KEYWORD_SEARCH}>
-          <AiAssistantKeywordSearch />
+          <AiAssistantKeywordSearch
+            updateBaseSelectedPages={selectPageHandlerByKeywordSearch}
+          />
         </TabPane>
 
         <TabPane tabId={AiAssistantManagementModalPageMode.HOME}>

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

@@ -46,7 +46,7 @@ export const AiAssistantManagementPageSelectionMethod = (): JSX.Element => {
       />
 
       <ModalBody className="px-4">
-        <h4 className="text-center mb-4 fw-bold">
+        <h4 className="text-center fw-bold mb-4 mt-2">
           {t('modal_ai_assistant.select_source_pages')}
         </h4>
         <div className="row justify-content-center">

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

@@ -0,0 +1,32 @@
+@use '~/styles/variables' as var;
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .selectable-page-page-list :global {
+    .page-list-item {
+      background-color: #{bs.$gray-100};
+    }
+
+    .list-group-item {
+      &:hover {
+        background-color: var(--grw-primary-100);
+      }
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .selectable-page-page-list :global {
+    .page-list-item {
+      background-color: #{bs.$gray-900};
+    }
+
+    .list-group-item {
+      &:hover {
+        background-color: var(--grw-primary-800)
+      }
+    }
+  }
+}

+ 72 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectablePagePageList.tsx

@@ -0,0 +1,72 @@
+import type { IPageHasId } from '@growi/core';
+import { useTranslation } from 'react-i18next';
+
+import styles from './SelectablePagePageList.module.scss';
+
+const moduleClass = styles['selectable-page-page-list'] ?? '';
+
+type Props = {
+  pages: IPageHasId[],
+  method: 'add' | 'remove',
+  disablePageIds?: string[],
+  onClickMethodButton: (page: IPageHasId) => void,
+}
+
+export const SelectablePagePageList = (props: Props): JSX.Element => {
+  const {
+    pages, method, disablePageIds, 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 (
+          <button
+            key={page._id}
+            type="button"
+            className="list-group-item border-0 list-group-item-action page-list-item d-flex align-items-center p-1 mb-2 rounded"
+            onClick={(e) => {
+              e.stopPropagation();
+            }}
+          >
+            <button
+              type="button"
+              className={`btn border-0 ${method === 'add' ? 'text-primary' : 'text-secondary'}`}
+              disabled={disablePageIds?.includes(page._id)}
+              onClick={(e) => {
+                e.stopPropagation();
+                onClickMethodButton(page);
+              }}
+            >
+              <span className="material-symbols-outlined">
+                { method === 'add' ? 'add_circle' : 'do_not_disturb_on' }
+              </span>
+            </button>
+            <div className="flex-grow-1">
+              <span>
+                {page.path}
+              </span>
+            </div>
+            <span className="badge bg-body-secondary rounded-pill me-2">
+              <span className="text-body-tertiary">
+                {page.descendantCount}
+              </span>
+            </span>
+          </button>
+        );
+      })}
+    </div>
+  );
+};

+ 3 - 1
apps/app/src/features/openai/interfaces/selected-page.ts

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