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

Merge pull request #9652 from weseek/feat/161525-open-update-modal-from-assistant-list-view

feat: Open update modal from assistant list view
Yuki Takei 1 год назад
Родитель
Сommit
6e354daf73

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

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

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

@@ -13,6 +13,7 @@ import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } fro
 import { ShareScopeWarningModal } from './ShareScopeWarningModal';
 
 type Props = {
+  shouldEdit: boolean;
   name: string;
   description: string;
   instruction: string;
@@ -24,6 +25,7 @@ type Props = {
 
 export const AiAssistantManagementHome = (props: Props): JSX.Element => {
   const {
+    shouldEdit,
     name,
     description,
     instruction,
@@ -61,7 +63,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     <>
       <ModalHeader tag="h4" toggle={closeAiAssistantManagementModal} className="pe-4">
         <span className="growi-custom-icons growi-ai-assistant-icon me-3 fs-4">ai_assistant</span>
-        <span className="fw-bold">新規アシスタントの追加</span> {/* TODO i18n */}
+        <span className="fw-bold">{t(shouldEdit ? 'アシスタントの更新' : '新規アシスタントの追加')}</span> {/* TODO i18n */}
       </ModalHeader>
 
       <div className="px-4">
@@ -137,7 +139,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
 
         <ModalFooter>
           <button type="button" className="btn btn-outline-secondary" onClick={closeAiAssistantManagementModal}>キャンセル</button>
-          <button type="button" className="btn btn-primary" onClick={createAiAssistantHandler}>アシスタントを作成する</button>
+          <button type="button" className="btn btn-primary" onClick={createAiAssistantHandler}>{t(shouldEdit ? 'アシスタントを更新する' : 'アシスタントを作成する')}</button>
         </ModalFooter>
       </div>
 

+ 61 - 24
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -1,6 +1,6 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useState, useEffect } from 'react';
 
-import type { IGrantedGroup } from '@growi/core';
+import { type IGrantedGroup, isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 
@@ -11,7 +11,7 @@ import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import loggerFactory from '~/utils/logger';
 
 import type { SelectedPage } from '../../../../interfaces/selected-page';
-import { createAiAssistant } from '../../../services/ai-assistant';
+import { createAiAssistant, updateAiAssistant } from '../../../services/ai-assistant';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode, useSWRxAiAssistants } from '../../../stores/ai-assistant';
 
 import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction';
@@ -33,12 +33,32 @@ const convertToGrantedGroups = (selectedGroups: PopulatedGrantedGroup[]): IGrant
   }));
 };
 
+// IGrantedGroup[] -> PopulatedGrantedGroup[]
+const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): PopulatedGrantedGroup[] => {
+  const populatedGrantedGroups = selectedGroups.filter(group => isPopulated(group.item)) as PopulatedGrantedGroup[];
+  return populatedGrantedGroups;
+};
+
+// string[] -> SelectedPage[]
+const convertToSelectedPages = (pagePathPatterns: string[]): SelectedPage[] => {
+  return pagePathPatterns.map((pagePathPattern) => {
+    const isIncludeSubPage = pagePathPattern.endsWith('/*');
+    const path = isIncludeSubPage ? pagePathPattern.slice(0, -2) : pagePathPattern;
+    return {
+      page: { path },
+      isIncludeSubPage,
+    };
+  });
+};
+
 const AiAssistantManagementModalSubstance = (): JSX.Element => {
   // Hooks
   const { t } = useTranslation();
   const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
   const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
 
+  const aiAssistant = aiAssistantManagementModalData?.aiAssistantData;
+  const shouldEdit = aiAssistant != null;
   const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
 
   // States
@@ -51,6 +71,20 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
   const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
 
+  // Effects
+  useEffect(() => {
+    if (shouldEdit) {
+      setName(aiAssistant.name);
+      setDescription(aiAssistant.description);
+      setInstruction(aiAssistant.additionalInstruction);
+      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns));
+      setSelectedShareScope(aiAssistant.shareScope);
+      setSelectedAccessScope(aiAssistant.accessScope);
+      setSelectedUserGroupsForShareScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForShareScope ?? []));
+      setSelectedUserGroupsForAccessScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForAccessScope ?? []));
+    }
+  // eslint-disable-next-line max-len
+  }, [aiAssistant?.accessScope, aiAssistant?.additionalInstruction, aiAssistant?.description, aiAssistant?.grantedGroupsForAccessScope, aiAssistant?.grantedGroupsForShareScope, aiAssistant?.name, aiAssistant?.pagePathPatterns, aiAssistant?.shareScope, shouldEdit]);
 
   /*
   *  For AiAssistantManagementHome methods
@@ -69,40 +103,42 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         .map(selectedPage => (selectedPage.isIncludeSubPage ? `${selectedPage.page.path}/*` : selectedPage.page.path))
         .filter((path): path is string => path !== undefined && path !== null);
 
-      const grantedGroupsForShareScope = convertToGrantedGroups(selectedUserGroupsForShareScope);
-      const grantedGroupsForAccessScope = convertToGrantedGroups(selectedUserGroupsForAccessScope);
+      const grantedGroupsForShareScope = selectedShareScope === AiAssistantShareScope.GROUPS
+        ? convertToGrantedGroups(selectedUserGroupsForShareScope)
+        : undefined;
+
+      const grantedGroupsForAccessScope = selectedAccessScope === AiAssistantAccessScope.GROUPS
+        ? convertToGrantedGroups(selectedUserGroupsForAccessScope)
+        : undefined;
 
-      await createAiAssistant({
+      const reqBody = {
         name,
         description,
         additionalInstruction: instruction,
         pagePathPatterns,
         shareScope: selectedShareScope,
         accessScope: selectedAccessScope,
-        grantedGroupsForShareScope: selectedShareScope === AiAssistantShareScope.GROUPS ? grantedGroupsForShareScope : undefined,
-        grantedGroupsForAccessScope: selectedAccessScope === AiAssistantAccessScope.GROUPS ? grantedGroupsForAccessScope : undefined,
-      });
-
-      toastSuccess('アシスタントを作成しました');
+        grantedGroupsForShareScope,
+        grantedGroupsForAccessScope,
+      };
+
+      if (shouldEdit) {
+        await updateAiAssistant(aiAssistant._id, reqBody);
+      }
+      else {
+        await createAiAssistant(reqBody);
+      }
+
+      toastSuccess(shouldEdit ? 'アシスタントが更新されました' : 'アシスタントが作成されました');
       mutateAiAssistants();
       closeAiAssistantManagementModal();
     }
     catch (err) {
-      toastError('アシスタントの作成に失敗しました');
+      toastError(shouldEdit ? 'アシスタントの更新に失敗しました' : 'アシスタントの作成に失敗しました');
       logger.error(err);
     }
-  }, [
-    mutateAiAssistants,
-    closeAiAssistantManagementModal,
-    description,
-    instruction,
-    name,
-    selectedAccessScope,
-    selectedPages,
-    selectedShareScope,
-    selectedUserGroupsForAccessScope,
-    selectedUserGroupsForShareScope,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, mutateAiAssistants, closeAiAssistantManagementModal, aiAssistant?._id]);
 
 
   /*
@@ -172,6 +208,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       <TabContent activeTab={pageMode}>
         <TabPane tabId={AiAssistantManagementModalPageMode.HOME}>
           <AiAssistantManagementHome
+            shouldEdit={shouldEdit}
             name={name}
             description={description}
             shareScope={selectedShareScope}

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

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

+ 26 - 4
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -8,7 +8,7 @@ import { useCurrentUser } from '~/stores-universal/context';
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { deleteAiAssistant } from '../../../services/ai-assistant';
-import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
+import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 
 import styles from './AiAssistantTree.module.scss';
 
@@ -85,6 +85,7 @@ type AiAssistantItemProps = {
   currentUserId?: string;
   aiAssistant: AiAssistantHasId;
   threads: Thread[];
+  onEditClicked?: (aiAssistantData: AiAssistantHasId) => void;
   onItemClicked?: (aiAssistantData: AiAssistantHasId) => void;
   onDeleted?: () => void;
 };
@@ -93,15 +94,21 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   currentUserId,
   aiAssistant,
   threads,
+  onEditClicked,
   onItemClicked,
   onDeleted,
 }) => {
   const [isThreadsOpened, setIsThreadsOpened] = useState(false);
 
+  const openManagementModalHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onEditClicked?.(aiAssistantData);
+  }, [onEditClicked]);
+
   const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
     onItemClicked?.(aiAssistantData);
   }, [onItemClicked]);
 
+
   const openThreadsHandler = useCallback(() => {
     setIsThreadsOpened(toggle => !toggle);
   }, []);
@@ -122,14 +129,20 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   return (
     <>
       <li
-        onClick={() => openChatHandler(aiAssistant)}
+        onClick={(e) => {
+          e.stopPropagation();
+          openChatHandler(aiAssistant);
+        }}
         role="button"
         className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
       >
         <div className="d-flex justify-content-center">
           <button
             type="button"
-            onClick={openThreadsHandler}
+            onClick={(e) => {
+              e.stopPropagation();
+              openThreadsHandler();
+            }}
             className={`grw-ai-assistant-triangle-btn btn px-0 ${isThreadsOpened ? 'grw-ai-assistant-open' : ''}`}
           >
             <div className="d-flex justify-content-center">
@@ -151,13 +164,20 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
             <button
               type="button"
               className="btn btn-link text-secondary p-0 ms-2"
+              onClick={(e) => {
+                e.stopPropagation();
+                openManagementModalHandler(aiAssistant);
+              }}
             >
               <span className="material-symbols-outlined fs-5">edit</span>
             </button>
             <button
               type="button"
               className="btn btn-link text-secondary p-0"
-              onClick={deleteAiAssistantHandler}
+              onClick={(e) => {
+                e.stopPropagation();
+                deleteAiAssistantHandler();
+              }}
             >
               <span className="material-symbols-outlined fs-5">delete</span>
             </button>
@@ -187,6 +207,7 @@ type AiAssistantTreeProps = {
 export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
   const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
 
   return (
     <ul className={`list-group ${moduleClass}`}>
@@ -196,6 +217,7 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           currentUserId={currentUser?._id}
           aiAssistant={assistant}
           threads={dummyThreads}
+          onEditClicked={openAiAssistantManagementModal}
           onItemClicked={openAiAssistantChatSidebar}
           onDeleted={onDeleted}
         />

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

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

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

@@ -20,10 +20,11 @@ type AiAssistantManagementModalPageMode = typeof AiAssistantManagementModalPageM
 type AiAssistantManagementModalStatus = {
   isOpened: boolean,
   pageMode?: AiAssistantManagementModalPageMode,
+  aiAssistantData?: AiAssistantHasId;
 }
 
 type AiAssistantManagementModalUtils = {
-  open(): void
+  open(aiAssistantData?: AiAssistantHasId): void
   close(): void
   changePageMode(pageType: AiAssistantManagementModalPageMode): void
 }
@@ -36,10 +37,10 @@ export const useAiAssistantManagementModal = (
 
   return {
     ...swrResponse,
-    open: useCallback(() => { swrResponse.mutate({ isOpened: true }) }, [swrResponse]),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+    open: useCallback((aiAssistantData) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false, aiAssistantData: undefined }), [swrResponse]),
     changePageMode: useCallback((pageMode: AiAssistantManagementModalPageMode) => {
-      swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode });
+      swrResponse.mutate({ isOpened: swrResponse.data?.isOpened ?? false, pageMode, aiAssistantData: swrResponse.data?.aiAssistantData });
     }, [swrResponse]),
   };
 };

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

@@ -53,7 +53,7 @@ const schema = new Schema<AiAssistantDocument>(
         },
         item: {
           type: Schema.Types.ObjectId,
-          refPath: 'grantedGroups.type',
+          refPath: 'grantedGroupsForShareScope.type',
           required: true,
           index: true,
         },
@@ -75,7 +75,7 @@ const schema = new Schema<AiAssistantDocument>(
         },
         item: {
           type: Schema.Types.ObjectId,
-          refPath: 'grantedGroups.type',
+          refPath: 'grantedGroupsForAccessScope.type',
           required: true,
           index: true,
         },

+ 3 - 1
apps/app/src/features/openai/server/services/openai.ts

@@ -653,7 +653,9 @@ class OpenaiService implements IOpenaiService {
           ],
         },
       ],
-    });
+    })
+      .populate('grantedGroupsForShareScope.item')
+      .populate('grantedGroupsForAccessScope.item');
 
     return {
       myAiAssistants: assistants.filter(assistant => assistant.owner.toString() === user._id.toString()) ?? [],