Explorar el Código

Merge pull request #9696 from weseek/feat/162529-enable-setting-default-ai-assistant

Shun Miyazawa hace 1 año
padre
commit
bb29ac7d7a

+ 2 - 2
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -16,7 +16,7 @@ import {
   toggleLike, toggleSubscribe,
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
-// import RagSearchButton from '~/features/openai/client/components/RagSearchButton';
+import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/stores-universal/context';
 import {
   EditorMode, useEditorMode,
@@ -285,7 +285,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       { isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <>
           <SearchButton />
-          {/* <RagSearchButton /> */}
+          <OpenDefaultAiAssistantButton />
         </>
       )}
 

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

@@ -148,6 +148,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
         accessScope: selectedAccessScope,
         grantedGroupsForShareScope,
         grantedGroupsForAccessScope,
+        isDefault: shouldEdit ? aiAssistant.isDefault : false,
       };
 
       if (shouldEdit) {
@@ -166,7 +167,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       logger.error(err);
     }
   // eslint-disable-next-line max-len
-  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, mutateAiAssistants, closeAiAssistantManagementModal, aiAssistant?._id]);
+  }, [selectedPages, selectedShareScope, selectedUserGroupsForShareScope, selectedAccessScope, selectedUserGroupsForAccessScope, name, description, instruction, shouldEdit, aiAssistant?.isDefault, aiAssistant?._id, mutateAiAssistants, closeAiAssistantManagementModal]);
 
 
   /*

+ 2 - 2
apps/app/src/features/openai/client/components/RagSearchButton.module.scss → apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.module.scss

@@ -3,11 +3,11 @@
 @use '@growi/ui/scss/atoms/btn-muted';
 @use '~/client/components/PageControls/button-styles';
 
-.btn-rag-search :global {
+.btn-open-default-ai-assistant :global {
   @extend %btn-basis;
 }
 
 // == Colors
-.btn-rag-search {
+.btn-open-default-ai-assistant {
   @include btn-muted.colorize(bs.$purple);
 }

+ 52 - 0
apps/app/src/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton.tsx

@@ -0,0 +1,52 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { NotAvailable } from '~/client/components/NotAvailable';
+import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
+import { useIsAiEnabled } from '~/stores-universal/context';
+
+import { useAiAssistantChatSidebar, useSWRxAiAssistants } from '../../stores/ai-assistant';
+
+import styles from './OpenDefaultAiAssistantButton.module.scss';
+
+const OpenDefaultAiAssistantButton = (): JSX.Element => {
+  const { data: isAiEnabled } = useIsAiEnabled();
+  const { data: aiAssistantData } = useSWRxAiAssistants();
+  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+
+  const defaultAiAssistant = useMemo(() => {
+    if (aiAssistantData == null) {
+      return null;
+    }
+
+    const allAiAssistants = [...aiAssistantData.myAiAssistants, ...aiAssistantData.teamAiAssistants];
+    return allAiAssistants.find(aiAssistant => aiAssistant.isDefault);
+  }, [aiAssistantData]);
+
+  const openDefaultAiAssistantButtonClickHandler = useCallback(() => {
+    if (defaultAiAssistant == null) {
+      return;
+    }
+
+    openAiAssistantChatSidebar(defaultAiAssistant);
+  }, [defaultAiAssistant, openAiAssistantChatSidebar]);
+
+  if (!isAiEnabled) {
+    return <></>;
+  }
+
+  return (
+    <NotAvailableForGuest>
+      <NotAvailable isDisabled={defaultAiAssistant == null} title="デフォルトアシスタントが設定されていません">
+        <button
+          type="button"
+          className={`btn btn-search ${styles['btn-open-default-ai-assistant']}`}
+          onClick={openDefaultAiAssistantButtonClickHandler}
+        >
+          <span className="growi-custom-icons fs-4 align-middle lh-1">ai_assistant</span>
+        </button>
+      </NotAvailable>
+    </NotAvailableForGuest>
+  );
+};
+
+export default OpenDefaultAiAssistantButton;

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

@@ -30,6 +30,7 @@ export const AiAssistantContent = (): JSX.Element => {
           </h3>
           {aiAssistants?.myAiAssistants != null && aiAssistants.myAiAssistants.length !== 0 && (
             <AiAssistantTree
+              onUpdated={mutateAiAssistants}
               onDeleted={mutateAiAssistants}
               aiAssistants={aiAssistants.myAiAssistants}
             />
@@ -42,6 +43,7 @@ export const AiAssistantContent = (): JSX.Element => {
           </h3>
           {aiAssistants?.teamAiAssistants != null && aiAssistants.teamAiAssistants.length !== 0 && (
             <AiAssistantTree
+              onUpdated={mutateAiAssistants}
               aiAssistants={aiAssistants.teamAiAssistants}
             />
           )}

+ 55 - 22
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback, useState } from 'react';
 
+import type { IUserHasId } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
@@ -10,7 +11,7 @@ import loggerFactory from '~/utils/logger';
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { determineShareScope } from '../../../../utils/determine-share-scope';
-import { deleteAiAssistant } from '../../../services/ai-assistant';
+import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-assistant';
 import { deleteThread } from '../../../services/thread';
 import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 import { useSWRMUTxThreads, useSWRxThreads } from '../../../stores/thread';
@@ -136,18 +137,20 @@ const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAss
 };
 
 type AiAssistantItemProps = {
-  currentUserId?: string;
+  currentUser?: IUserHasId | null;
   aiAssistant: AiAssistantHasId;
   onEditClick: (aiAssistantData: AiAssistantHasId) => void;
   onItemClick: (aiAssistantData: AiAssistantHasId, threadData?: IThreadRelationHasId) => void;
+  onUpdated?: () => void;
   onDeleted?: () => void;
 };
 
 const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
-  currentUserId,
+  currentUser,
   aiAssistant,
   onEditClick,
   onItemClick,
+  onUpdated,
   onDeleted,
 }) => {
   const [isThreadsOpened, setIsThreadsOpened] = useState(false);
@@ -167,6 +170,18 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
     setIsThreadsOpened(toggle => !toggle);
   }, [mutateThreadData]);
 
+  const setDefaultAiAssistantHandler = useCallback(async() => {
+    try {
+      await setDefaultAiAssistant(aiAssistant._id, !aiAssistant.isDefault);
+      onUpdated?.();
+      toastSuccess('デフォルトアシスタントを切り替えました');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError('デフォルトアシスタントの切り替えに失敗しました');
+    }
+  }, [aiAssistant._id, aiAssistant.isDefault, onUpdated]);
+
   const deleteAiAssistantHandler = useCallback(async() => {
     try {
       await deleteAiAssistant(aiAssistant._id);
@@ -179,7 +194,9 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
     }
   }, [aiAssistant._id, onDeleted]);
 
-  const isOperable = currentUserId != null && getIdStringForRef(aiAssistant.owner) === currentUserId;
+  const isOperable = currentUser?._id != null && getIdStringForRef(aiAssistant.owner) === currentUser._id;
+  const isPublicAiAssistantOperable = currentUser?.admin
+    && determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) === AiAssistantShareScope.PUBLIC_ONLY;
 
   return (
     <>
@@ -214,30 +231,44 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
           <p className="text-truncate m-auto">{aiAssistant.name}</p>
         </div>
 
-        { isOperable && (
-          <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
-            <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>
+        <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
+          {isPublicAiAssistantOperable && (
             <button
               type="button"
               className="btn btn-link text-secondary p-0"
               onClick={(e) => {
                 e.stopPropagation();
-                deleteAiAssistantHandler();
+                setDefaultAiAssistantHandler();
               }}
             >
-              <span className="material-symbols-outlined fs-5">delete</span>
+              <span className={`material-symbols-outlined fs-5 ${aiAssistant.isDefault ? 'fill' : ''}`}>star</span>
             </button>
-          </div>
-        )}
+          )}
+          {isOperable && (
+            <>
+              <button
+                type="button"
+                className="btn btn-link text-secondary p-0"
+                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={(e) => {
+                  e.stopPropagation();
+                  deleteAiAssistantHandler();
+                }}
+              >
+                <span className="material-symbols-outlined fs-5">delete</span>
+              </button>
+            </>
+          )}
+        </div>
       </li>
 
       { isThreadsOpened && (
@@ -257,10 +288,11 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
 */
 type AiAssistantTreeProps = {
   aiAssistants: AiAssistantHasId[];
+  onUpdated?: () => void;
   onDeleted?: () => void;
 };
 
-export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onDeleted }) => {
+export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onUpdated, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
   const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
   const { open: openAiAssistantManagementModal } = useAiAssistantManagementModal();
@@ -270,10 +302,11 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
       {aiAssistants.map(assistant => (
         <AiAssistantItem
           key={assistant._id}
-          currentUserId={currentUser?._id}
+          currentUser={currentUser}
           aiAssistant={assistant}
           onEditClick={openAiAssistantManagementModal}
           onItemClick={openAiAssistantChatSidebar}
+          onUpdated={onUpdated}
           onDeleted={onDeleted}
         />
       ))}

+ 0 - 36
apps/app/src/features/openai/client/components/RagSearchButton.tsx

@@ -1,36 +0,0 @@
-import React, { useCallback } from 'react';
-
-import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
-import { useIsAiEnabled } from '~/stores-universal/context';
-
-import { useRagSearchModal } from '../stores/rag-search';
-
-import styles from './RagSearchButton.module.scss';
-
-const RagSearchButton = (): JSX.Element => {
-  const { data: isAiEnabled } = useIsAiEnabled();
-  const { open: openRagSearchModal } = useRagSearchModal();
-
-  const ragSearchButtonClickHandler = useCallback(() => {
-    openRagSearchModal();
-  }, [openRagSearchModal]);
-
-  if (!isAiEnabled) {
-    return <></>;
-  }
-
-  return (
-    <NotAvailableForGuest>
-      <button
-        type="button"
-        className={`btn btn-search ${styles['btn-rag-search']}`}
-        onClick={ragSearchButtonClickHandler}
-        data-testid="open-search-modal-button"
-      >
-        <span className="growi-custom-icons fs-4 align-middle lh-1">ai_assistant</span>
-      </button>
-    </NotAvailableForGuest>
-  );
-};
-
-export default RagSearchButton;

+ 4 - 0
apps/app/src/features/openai/client/services/ai-assistant.ts

@@ -10,6 +10,10 @@ export const updateAiAssistant = async(id: string, body: UpsertAiAssistantData):
   await apiv3Put(`/openai/ai-assistant/${id}`, body);
 };
 
+export const setDefaultAiAssistant = async(id: string, isDefault: boolean): Promise<void> => {
+  await apiv3Put(`/openai/ai-assistant/${id}/set-default`, { isDefault });
+};
+
 export const deleteAiAssistant = async(id: string): Promise<void> => {
   await apiv3Delete(`/openai/ai-assistant/${id}`);
 };

+ 0 - 26
apps/app/src/features/openai/client/stores/rag-search.ts

@@ -1,26 +0,0 @@
-import { useCallback } from 'react';
-
-import { useSWRStatic } from '@growi/core/dist/swr';
-import type { SWRResponse } from 'swr';
-
-
-type RagSearchMoldalStatus = {
-  isOpened: boolean,
-}
-
-type RagSearchUtils = {
-  open(): void
-  close(): void
-}
-export const useRagSearchModal = (status?: RagSearchMoldalStatus): SWRResponse<RagSearchMoldalStatus, Error> & RagSearchUtils => {
-  const initialStatus = { isOpened: false };
-  const swrResponse = useSWRStatic<RagSearchMoldalStatus, Error>('RagSearchModal', status, { fallbackData: initialStatus });
-
-  return {
-    ...swrResponse,
-    open: useCallback(() => {
-      swrResponse.mutate({ isOpened: true });
-    }, [swrResponse]),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
-  };
-};

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

@@ -37,6 +37,7 @@ export interface AiAssistant {
   grantedGroupsForAccessScope?: IGrantedGroup[]
   shareScope: AiAssistantShareScope
   accessScope: AiAssistantAccessScope
+  isDefault: boolean
 }
 
 export type AiAssistantHasId = AiAssistant & HasObjectId

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

@@ -1,4 +1,5 @@
 import { type IGrantedGroup, GroupType } from '@growi/core';
+import createError from 'http-errors';
 import { type Model, type Document, Schema } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
@@ -10,6 +11,7 @@ export interface AiAssistantDocument extends AiAssistant, Document {}
 
 interface AiAssistantModel extends Model<AiAssistantDocument> {
   findByPagePaths(pagePaths: string[]): Promise<AiAssistantDocument[]>;
+  setDefault(id: string, isDefault: boolean): Promise<AiAssistantDocument>;
 }
 
 /*
@@ -99,6 +101,11 @@ const schema = new Schema<AiAssistantDocument>(
       enum: Object.values(AiAssistantAccessScope),
       required: true,
     },
+    isDefault: {
+      type: Boolean,
+      required: true,
+      default: false,
+    },
   },
   {
     timestamps: true,
@@ -120,4 +127,19 @@ schema.statics.findByPagePaths = async function(pagePaths: string[]): Promise<Ai
   return assistants;
 };
 
+schema.statics.setDefault = async function(id: string, isDefault: boolean): Promise<AiAssistantDocument> {
+  const aiAssistant = await this.findOne({ _id: id, shareScope: AiAssistantAccessScope.PUBLIC_ONLY });
+  if (aiAssistant == null) {
+    throw createError(404, 'AiAssistant document does not exist');
+  }
+
+  await this.updateMany({ isDefault: true }, { isDefault: false });
+
+  aiAssistant.isDefault = isDefault;
+  const updatedAiAssistant = await aiAssistant.save();
+
+  return updatedAiAssistant;
+};
+
+
 export default getOrCreateModel<AiAssistantDocument, AiAssistantModel>('AiAssistant', schema);

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

@@ -55,6 +55,10 @@ export const factory = (crowi: Crowi): express.Router => {
       router.put('/ai-assistant/:id', updateAiAssistantsFactory(crowi));
     });
 
+    import('./set-default-ai-assistant').then(({ setDefaultAiAssistantFactory }) => {
+      router.put('/ai-assistant/:id/set-default', setDefaultAiAssistantFactory(crowi));
+    });
+
     import('./delete-ai-assistant').then(({ deleteAiAssistantsFactory }) => {
       router.delete('/ai-assistant/:id', deleteAiAssistantsFactory(crowi));
     });

+ 66 - 0
apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts

@@ -0,0 +1,66 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { type ValidationChain, param, body } from 'express-validator';
+import { isHttpError } from 'http-errors';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+
+import AiAssistantModel from '../models/ai-assistant';
+import { getOpenaiService } from '../services/openai';
+
+import { certifyAiService } from './middlewares/certify-ai-service';
+
+const logger = loggerFactory('growi:routes:apiv3:openai:set-default-ai-assistants');
+
+type setDefaultAiAssistantFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  id: string,
+}
+
+type ReqBody = {
+  isDefault: boolean,
+}
+
+type Req = Request<ReqParams, Response, ReqBody>
+
+export const setDefaultAiAssistantFactory: setDefaultAiAssistantFactory = (crowi) => {
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    param('id').isMongoId().withMessage('aiAssistant id is required'),
+    body('isDefault').isBoolean().withMessage('isDefault is required'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const openaiService = getOpenaiService();
+      if (openaiService == null) {
+        return res.apiv3Err(new ErrorV3('GROWI AI is not enabled'), 501);
+      }
+
+      try {
+        const { id } = req.params;
+        const { isDefault } = req.body;
+
+        const updatedAiAssistant = await AiAssistantModel.setDefault(id, isDefault);
+        return res.apiv3({ updatedAiAssistant });
+      }
+      catch (err) {
+        logger.error(err);
+
+        if (isHttpError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message), err.status);
+        }
+
+        return res.apiv3Err(new ErrorV3('Failed to update AiAssistant'));
+      }
+    },
+  ];
+};