Browse Source

Merge pull request #9617 from weseek/feat/160587-implement-ai-assistant-list-view

feat: Implement AiAssistantTree
Shun Miyazawa 1 year ago
parent
commit
dd905634f6

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

@@ -144,7 +144,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
       <ShareScopeWarningModal
       <ShareScopeWarningModal
         isOpen={isShareScopeWarningModalOpen}
         isOpen={isShareScopeWarningModalOpen}
         closeModal={() => setIsShareScopeWarningModalOpen(false)}
         closeModal={() => setIsShareScopeWarningModalOpen(false)}
-        onSubmit={createAiAssistantHandler}
+        onSubmit={onCreateAiAssistant}
       />
       />
     </>
     </>
   );
   );

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

@@ -12,7 +12,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { SelectedPage } from '../../../../interfaces/selected-page';
 import type { SelectedPage } from '../../../../interfaces/selected-page';
 import { createAiAssistant } from '../../../services/ai-assistant';
 import { createAiAssistant } from '../../../services/ai-assistant';
-import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
+import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode, useSWRxAiAssistants } from '../../../stores/ai-assistant';
 
 
 import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction';
 import { AiAssistantManagementEditInstruction } from './AiAssistantManagementEditInstruction';
 import { AiAssistantManagementEditPages } from './AiAssistantManagementEditPages';
 import { AiAssistantManagementEditPages } from './AiAssistantManagementEditPages';
@@ -36,6 +36,7 @@ const convertToGrantedGroups = (selectedGroups: PopulatedGrantedGroup[]): IGrant
 const AiAssistantManagementModalSubstance = (): JSX.Element => {
 const AiAssistantManagementModalSubstance = (): JSX.Element => {
   // Hooks
   // Hooks
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
   const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
   const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
 
 
   const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
   const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
@@ -83,6 +84,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       });
       });
 
 
       toastSuccess('アシスタントを作成しました');
       toastSuccess('アシスタントを作成しました');
+      mutateAiAssistants();
       closeAiAssistantManagementModal();
       closeAiAssistantManagementModal();
     }
     }
     catch (err) {
     catch (err) {
@@ -90,6 +92,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
       logger.error(err);
       logger.error(err);
     }
     }
   }, [
   }, [
+    mutateAiAssistants,
     closeAiAssistantManagementModal,
     closeAiAssistantManagementModal,
     description,
     description,
     instruction,
     instruction,

+ 15 - 3
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistant.tsx

@@ -4,11 +4,13 @@ import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
 import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
+import { useIsGuestUser } from '~/stores-universal/context';
 
 
 const AiAssistantContent = dynamic(() => import('./AiAssistantSubstance').then(mod => mod.AiAssistantContent), { ssr: false });
 const AiAssistantContent = dynamic(() => import('./AiAssistantSubstance').then(mod => mod.AiAssistantContent), { ssr: false });
 
 
 export const AiAssistant = (): JSX.Element => {
 export const AiAssistant = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { data: isGuestUser } = useIsGuestUser();
 
 
   return (
   return (
     <div className="px-3">
     <div className="px-3">
@@ -17,9 +19,19 @@ export const AiAssistant = (): JSX.Element => {
           {t('Knowledge Assistant')}
           {t('Knowledge Assistant')}
         </h3>
         </h3>
       </div>
       </div>
-      <Suspense fallback={<ItemsTreeContentSkeleton />}>
-        <AiAssistantContent />
-      </Suspense>
+
+      { isGuestUser
+        ? (
+          <h4 className="fs-6">
+            { t('Not available for guest') }
+          </h4>
+        )
+        : (
+          <Suspense fallback={<ItemsTreeContentSkeleton />}>
+            <AiAssistantContent />
+          </Suspense>
+        )
+      }
     </div>
     </div>
   );
   );
 };
 };

+ 5 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantSubstance.module.scss

@@ -0,0 +1,5 @@
+.grw-ai-assistant-substance :global {
+  .grw-ai-assistant-substance-header {
+    font-size: 14px;
+  }
+}

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

@@ -1,16 +1,52 @@
 import React from 'react';
 import React from 'react';
 
 
-import { useAiAssistantManagementModal } from '../../../stores/ai-assistant';
+import { useAiAssistantManagementModal, useSWRxAiAssistants } from '../../../stores/ai-assistant';
+
+import { AiAssistantTree } from './AiAssistantTree';
+
+import styles from './AiAssistantSubstance.module.scss';
+
+const moduleClass = styles['grw-ai-assistant-substance'] ?? '';
 
 
 export const AiAssistantContent = (): JSX.Element => {
 export const AiAssistantContent = (): JSX.Element => {
   const { open } = useAiAssistantManagementModal();
   const { open } = useAiAssistantManagementModal();
+  const { data: aiAssistants, mutate: mutateAiAssistants } = useSWRxAiAssistants();
 
 
   return (
   return (
-    <div>
-      <button type="button" className="btn btn-primary" onClick={open}>
-        アシスタントを追加する
-        {/* TODO i18n */}
+    <div className={moduleClass}>
+      <button
+        type="button"
+        className="btn btn-outline-secondary px-3 d-flex align-items-center mb-4"
+        onClick={open}
+      >
+        <span className="material-symbols-outlined fs-5 me-2">add</span>
+        <span className="fw-normal">アシスタントを追加する</span>
       </button>
       </button>
+
+      <div className="d-flex flex-column gap-4">
+        <div>
+          <h3 className="fw-bold grw-ai-assistant-substance-header">
+            マイアシスタント
+          </h3>
+          {aiAssistants?.myAiAssistants != null && aiAssistants.myAiAssistants.length !== 0 && (
+            <AiAssistantTree
+              onDeleted={mutateAiAssistants}
+              aiAssistants={aiAssistants.myAiAssistants}
+            />
+          )}
+        </div>
+
+        <div>
+          <h3 className="fw-bold grw-ai-assistant-substance-header">
+            チームアシスタント
+          </h3>
+          {aiAssistants?.teamAiAssistants != null && aiAssistants.teamAiAssistants.length !== 0 && (
+            <AiAssistantTree
+              aiAssistants={aiAssistants.teamAiAssistants}
+            />
+          )}
+        </div>
+      </div>
     </div>
     </div>
   );
   );
 };
 };

+ 45 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.module.scss

@@ -0,0 +1,45 @@
+// == Colors
+.ai-assistant-tree-item :global {
+  .grw-ai-assistant-actions {
+    .btn-link {
+      &:hover {
+        color: var(--bs-gray-800) !important;
+      }
+    }
+  }
+}
+
+
+.ai-assistant-tree-item :global {
+  .list-group-item {
+    height: 40px;
+    padding-left: 4px;
+
+    .grw-ai-assistant-triangle-btn {
+      border: 0;
+      transition: transform 0.2s ease-out;
+      transform: rotate(0deg);
+
+      &.grw-ai-assistant-open {
+        transform: rotate(90deg);
+      }
+    }
+
+    .grw-ai-assistant-title-anchor {
+      width: 100%;
+      overflow: hidden;
+      font-size: 14px;
+    }
+
+
+    .grw-ai-assistant-actions {
+      transition: opacity 0.2s ease-out;
+    }
+
+    &:hover {
+      .grw-ai-assistant-actions {
+        opacity: 1 !important;
+      }
+    }
+  }
+}

+ 199 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -0,0 +1,199 @@
+import React, { useCallback, useState } from 'react';
+
+import { getIdStringForRef } from '@growi/core';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+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 styles from './AiAssistantTree.module.scss';
+
+const moduleClass = styles['ai-assistant-tree-item'] ?? '';
+
+type Thread = {
+  _id: string;
+  name: string;
+}
+
+const dummyThreads: Thread[] = [
+  { _id: '1', name: 'thread1' },
+  { _id: '2', name: 'thread2' },
+  { _id: '3', name: 'thread3' },
+];
+
+type ThreadItemProps = {
+  thread: Thread;
+};
+
+const ThreadItem: React.FC<ThreadItemProps> = ({
+  thread,
+}) => {
+
+  const deleteThreadHandler = useCallback(() => {
+    // TODO: https://redmine.weseek.co.jp/issues/161490
+  }, []);
+
+  const openChatHandler = useCallback(() => {
+    // TODO: https://redmine.weseek.co.jp/issues/159530
+  }, []);
+
+  return (
+    <li
+      role="button"
+      className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1 ps-5"
+      onClick={openChatHandler}
+    >
+      <div>
+        <span className="material-symbols-outlined fs-5">chat</span>
+      </div>
+
+      <div className="grw-ai-assistant-title-anchor ps-1">
+        <p className="text-truncate m-auto">{thread.name}</p>
+      </div>
+
+      <div className="grw-ai-assistant-actions opacity-0 d-flex justify-content-center ">
+        <button
+          type="button"
+          className="btn btn-link text-secondary p-0"
+          onClick={deleteThreadHandler}
+        >
+          <span className="material-symbols-outlined fs-5">delete</span>
+        </button>
+      </div>
+    </li>
+  );
+};
+
+
+const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => {
+  const determinedSharedScope = shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE ? accessScope : shareScope;
+  switch (determinedSharedScope) {
+    case AiAssistantShareScope.OWNER:
+      return 'lock';
+    case AiAssistantShareScope.GROUPS:
+      return 'account_tree';
+    case AiAssistantShareScope.PUBLIC_ONLY:
+      return 'group';
+  }
+};
+
+type AiAssistantItemProps = {
+  currentUserId?: string;
+  aiAssistant: AiAssistantHasId;
+  threads: Thread[];
+  onDeleted?: () => void;
+};
+
+const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
+  currentUserId,
+  aiAssistant,
+  threads,
+  onDeleted,
+}) => {
+  const [isThreadsOpened, setIsThreadsOpened] = useState(false);
+
+  const openChatHandler = useCallback(() => {
+    // TODO: https://redmine.weseek.co.jp/issues/159530
+  }, []);
+
+  const openThreadsHandler = useCallback(() => {
+    setIsThreadsOpened(toggle => !toggle);
+  }, []);
+
+  const deleteAiAssistantHandler = useCallback(async() => {
+    try {
+      await deleteAiAssistant(aiAssistant._id);
+      onDeleted?.();
+      toastSuccess('アシスタントを削除しました');
+    }
+    catch (err) {
+      toastError('アシスタントの削除に失敗しました');
+    }
+  }, [aiAssistant._id, onDeleted]);
+
+  const isOperable = currentUserId != null && getIdStringForRef(aiAssistant.owner) === currentUserId;
+
+  return (
+    <>
+      <li
+        onClick={openChatHandler}
+        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}
+            className={`grw-ai-assistant-triangle-btn btn px-0 ${isThreadsOpened ? 'grw-ai-assistant-open' : ''}`}
+          >
+            <div className="d-flex justify-content-center">
+              <span className="material-symbols-outlined fs-5">arrow_right</span>
+            </div>
+          </button>
+        </div>
+
+        <div className="d-flex justify-content-center">
+          <span className="material-symbols-outlined fs-5">{getShareScopeIcon(aiAssistant.shareScope, aiAssistant.accessScope)}</span>
+        </div>
+
+        <div className="grw-ai-assistant-title-anchor ps-1">
+          <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"
+            >
+              <span className="material-symbols-outlined fs-5">edit</span>
+            </button>
+            <button
+              type="button"
+              className="btn btn-link text-secondary p-0"
+              onClick={deleteAiAssistantHandler}
+            >
+              <span className="material-symbols-outlined fs-5">delete</span>
+            </button>
+          </div>
+        )}
+      </li>
+
+      {isThreadsOpened && threads.length > 0 && (
+        <div className="grw-ai-assistant-item-children">
+          {threads.map(thread => (
+            <ThreadItem
+              key={thread._id}
+              thread={thread}
+            />
+          ))}
+        </div>
+      )}
+    </>
+  );
+};
+
+type AiAssistantTreeProps = {
+  aiAssistants: AiAssistantHasId[];
+  onDeleted?: () => void;
+};
+
+export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onDeleted }) => {
+  const { data: currentUser } = useCurrentUser();
+  return (
+    <ul className={`list-group ${moduleClass}`}>
+      {aiAssistants.map(assistant => (
+        <AiAssistantItem
+          key={assistant._id}
+          currentUserId={currentUser?._id}
+          aiAssistant={assistant}
+          threads={dummyThreads}
+          onDeleted={onDeleted}
+        />
+      ))}
+    </ul>
+  );
+};

+ 14 - 1
apps/app/src/features/openai/client/stores/ai-assistant.tsx

@@ -1,7 +1,12 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
 import { useSWRStatic } from '@growi/core/dist/swr';
 import { useSWRStatic } from '@growi/core/dist/swr';
-import type { SWRResponse } from 'swr';
+import { type SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import { type AccessibleAiAssistantsHasId } from '../../interfaces/ai-assistant';
 
 
 export const AiAssistantManagementModalPageMode = {
 export const AiAssistantManagementModalPageMode = {
   HOME: 'home',
   HOME: 'home',
@@ -38,3 +43,11 @@ export const useAiAssistantManagementModal = (
     }, [swrResponse]),
     }, [swrResponse]),
   };
   };
 };
 };
+
+
+export const useSWRxAiAssistants = (): SWRResponse<AccessibleAiAssistantsHasId, Error> => {
+  return useSWRImmutable<AccessibleAiAssistantsHasId>(
+    ['/openai/ai-assistants'],
+    ([endpoint]) => apiv3Get(endpoint).then(response => response.data.accessibleAiAssistants),
+  );
+};

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

@@ -1,4 +1,6 @@
-import type { IGrantedGroup, IUser, Ref } from '@growi/core';
+import type {
+  IGrantedGroup, IUser, Ref, HasObjectId,
+} from '@growi/core';
 
 
 import type { VectorStore } from '../server/models/vector-store';
 import type { VectorStore } from '../server/models/vector-store';
 
 
@@ -37,9 +39,16 @@ export interface AiAssistant {
   accessScope: AiAssistantAccessScope
   accessScope: AiAssistantAccessScope
 }
 }
 
 
+export type AiAssistantHasId = AiAssistant & HasObjectId
+
 export type IApiv3AiAssistantCreateParams = Omit<AiAssistant, 'owner' | 'vectorStore'>
 export type IApiv3AiAssistantCreateParams = Omit<AiAssistant, 'owner' | 'vectorStore'>
 
 
 export type AccessibleAiAssistants = {
 export type AccessibleAiAssistants = {
   myAiAssistants: AiAssistant[],
   myAiAssistants: AiAssistant[],
   teamAiAssistants: AiAssistant[],
   teamAiAssistants: AiAssistant[],
 }
 }
+
+export type AccessibleAiAssistantsHasId = {
+  myAiAssistants: AiAssistantHasId[],
+  teamAiAssistants: AiAssistantHasId[],
+}