Преглед изворни кода

Merge pull request #10193 from weseek/feat/169237-implementation-of-keyword-search-modal

feat(ai): Implementation of keyword search modal
Yuki Takei пре 8 месеци
родитељ
комит
01a38be9f3

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

@@ -572,7 +572,10 @@
       "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_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",

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

@@ -566,7 +566,10 @@
       "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",
+    "enter_keywords": "Entrer des mots-clés",
     "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",

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

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

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

@@ -562,7 +562,10 @@
       "update_failed": "更新助手失败"
     },
     "select_source_pages": "选择助手要参考的页面",
+    "search_reference_pages_by_keyword": "按关键词搜索助手参考的页面",
     "search_by_keyword": "按关键词搜索",
+    "max_items_space_separated_hint": "请输入最多5个项目,用空格分隔",
+    "enter_keywords": "输入关键词",
     "select_from_page_tree": "从页面树选择",
     "edit_page_description": "编辑助手可以参考的页面。<br> 助手可以参考最多 {{limitLearnablePageCountPerAssistant}} 个页面,包括子页面。",
     "default_instruction": "您是这个Wiki的知识助手。\n\n## 多语言支持:\n请使用用户输入中使用的相同语言进行回复。\n",

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

@@ -0,0 +1,23 @@
+@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 {
+      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;
+    }
+  }
+}

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

@@ -0,0 +1,110 @@
+import React, {
+  useRef, useCallback, useState, type KeyboardEvent,
+} from 'react';
+
+import { type TypeaheadRef, Typeahead } from 'react-bootstrap-typeahead';
+import { useTranslation } from 'react-i18next';
+import {
+  ModalBody,
+} from 'reactstrap';
+
+import {
+  useAiAssistantManagementModal, AiAssistantManagementModalPageMode,
+} from '../../../stores/ai-assistant';
+
+import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+
+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;
+};
+
+export const AiAssistantKeywordSearch = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+  const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
+
+  const [selectedSearchKeywords, setSelectedSearchKeywords] = useState<Array<SelectedSearchKeyword>>([]);
+
+  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]);
+
+  return (
+    <div className={moduleClass}>
+      <AiAssistantManagementHeader
+        backButtonColor="secondary"
+        backToPageMode={AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD}
+        labelTranslationKey={isNewAiAssistant ? 'modal_ai_assistant.header.add_new_assistant' : 'modal_ai_assistant.header.update_assistant'}
+      />
+
+      <ModalBody className="px-4">
+        <h4 className="text-center mb-4 fw-bold">
+          {t('modal_ai_assistant.search_reference_pages_by_keyword')}
+        </h4>
+
+        <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>
+      </ModalBody>
+    </div>
+  );
+};

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

@@ -30,6 +30,7 @@ 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 styles from './AiAssistantManagementModal.module.scss';
@@ -240,6 +241,14 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   return (
     <>
       <TabContent activeTab={pageMode}>
+        <TabPane tabId={AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD}>
+          <AiAssistantManagementPageSelectionMethod />
+        </TabPane>
+
+        <TabPane tabId={AiAssistantManagementModalPageMode.KEYWORD_SEARCH}>
+          <AiAssistantKeywordSearch />
+        </TabPane>
+
         <TabPane tabId={AiAssistantManagementModalPageMode.HOME}>
           <AiAssistantManagementHome
             shouldEdit={shouldEdit}
@@ -278,10 +287,6 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
           />
         </TabPane>
 
-        <TabPane tabId={AiAssistantManagementModalPageMode.PAGE_SELECTION_METHOD}>
-          <AiAssistantManagementPageSelectionMethod />
-        </TabPane>
-
         <TabPane tabId={AiAssistantManagementModalPageMode.INSTRUCTION}>
           <AiAssistantManagementEditInstruction
             instruction={instruction}

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

@@ -5,7 +5,7 @@ import {
   ModalBody,
 } from 'reactstrap';
 
-import { useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
 
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 
@@ -35,7 +35,7 @@ const SelectionButton = (props: { icon: string, label: string, onClick: () => vo
 
 export const AiAssistantManagementPageSelectionMethod = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data: aiAssistantManagementModalData } = useAiAssistantManagementModal();
+  const { data: aiAssistantManagementModalData, changePageMode } = useAiAssistantManagementModal();
   const isNewAiAssistant = aiAssistantManagementModalData?.aiAssistantData == null;
 
   return (
@@ -51,11 +51,18 @@ export const AiAssistantManagementPageSelectionMethod = (): JSX.Element => {
         </h4>
         <div className="row justify-content-center">
           <div className="col-auto">
-            <SelectionButton icon="manage_search" label={t('modal_ai_assistant.search_by_keyword')} onClick={() => {}} />
+            <SelectionButton
+              icon="manage_search"
+              label={t('modal_ai_assistant.search_by_keyword')}
+              onClick={() => changePageMode(AiAssistantManagementModalPageMode.KEYWORD_SEARCH)}
+            />
           </div>
-
           <div className="col-auto">
-            <SelectionButton icon="account_tree" label={t('modal_ai_assistant.select_from_page_tree')} onClick={() => {}} />
+            <SelectionButton
+              icon="account_tree"
+              label={t('modal_ai_assistant.select_from_page_tree')}
+              onClick={() => changePageMode(AiAssistantManagementModalPageMode.PAGE_TREE_SELECTION)}
+            />
           </div>
         </div>
       </ModalBody>

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

@@ -19,6 +19,8 @@ export const AiAssistantManagementModalPageMode = {
   PAGES: 'pages',
   INSTRUCTION: 'instruction',
   PAGE_SELECTION_METHOD: 'page-selection-method',
+  KEYWORD_SEARCH: 'keyword-search',
+  PAGE_TREE_SELECTION: 'page-tree-selection',
 } as const;
 
 export type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageMode[keyof typeof AiAssistantManagementModalPageMode];