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

Merge pull request #9640 from weseek/feat/161510-implement-right-sidebar

feat: Implement AiAssistantChatSidebar
Yuki Takei пре 1 година
родитељ
комит
17d31e3517

+ 8 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -8,6 +8,12 @@ import { RawLayout } from './RawLayout';
 
 
 import styles from './BasicLayout.module.scss';
 import styles from './BasicLayout.module.scss';
 
 
+const AiAssistantChatSidebar = dynamic(
+  () => import('~/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar')
+    .then(mod => mod.AiAssistantChatSidebar), { ssr: false },
+);
+
+
 const moduleClass = styles['grw-basic-layout'] ?? '';
 const moduleClass = styles['grw-basic-layout'] ?? '';
 
 
 
 
@@ -59,6 +65,8 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
           <AlertSiteUrlUndefined />
           <AlertSiteUrlUndefined />
           {children}
           {children}
         </div>
         </div>
+
+        <AiAssistantChatSidebar />
       </div>
       </div>
 
 
       <GrowiNavbarBottom />
       <GrowiNavbarBottom />

+ 14 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.module.scss

@@ -0,0 +1,14 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use '@growi/core-styles/scss/variables/growi-official-colors';
+@use '@growi/ui/scss/atoms/btn-muted';
+
+// == Colors
+.grw-ai-assistant-chat-sidebar :global {
+  .growi-ai-chat-icon {
+    color: growi-official-colors.$growi-ai-purple;
+  }
+
+  .btn-submit {
+    @include btn-muted.colorize(bs.$purple, bs.$purple);
+  }
+}

+ 90 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx

@@ -0,0 +1,90 @@
+import {
+  type FC, memo, useRef, useEffect,
+} from 'react';
+
+import SimpleBar from 'simplebar-react';
+
+import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
+
+import styles from './AiAssistantChatSidebar.module.scss';
+
+const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
+
+const RIGHT_SIDEBAR_WIDTH = 500;
+
+type AiAssistantChatSidebarSubstanceProps = {
+  aiAssistantData?: AiAssistantHasId;
+  closeAiAssistantChatSidebar: () => void
+}
+
+const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
+  const { aiAssistantData, closeAiAssistantChatSidebar } = props;
+
+  return (
+    <>
+      <div className="d-flex align-items-center p-3 border-bottom">
+        <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>
+        <h5 className="mb-0 fw-bold flex-grow-1 text-truncate">{aiAssistantData?.name}</h5>
+        <button
+          type="button"
+          className="btn btn-link p-0 border-0"
+          onClick={closeAiAssistantChatSidebar}
+        >
+          <span className="material-symbols-outlined">close</span>
+        </button>
+      </div>
+
+      <div className="p-3 w-100">
+        {/* AI Chat Screen Implementation */}
+        {/* TODO: https://redmine.weseek.co.jp/issues/161511 */}
+      </div>
+    </>
+  );
+};
+
+
+export const AiAssistantChatSidebar: FC = memo((): JSX.Element => {
+  const sidebarRef = useRef<HTMLDivElement>(null);
+  const sidebarScrollerRef = useRef<HTMLDivElement>(null);
+
+  const { data: aiAssistantChatSidebarData, close: closeAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+  const isOpened = aiAssistantChatSidebarData?.isOpened ?? false;
+
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (isOpened && sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
+        closeAiAssistantChatSidebar();
+      }
+    };
+
+    document.addEventListener('mousedown', handleClickOutside);
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+    };
+  }, [closeAiAssistantChatSidebar, isOpened]);
+
+  return (
+    <>
+      {isOpened && (
+        <div
+          ref={sidebarRef}
+          className={`position-fixed top-0 end-0 h-100 border-start bg-white shadow-sm ${moduleClass}`}
+          style={{ zIndex: 1500, width: `${RIGHT_SIDEBAR_WIDTH}px` }}
+          data-testid="grw-right-sidebar"
+        >
+          <SimpleBar
+            scrollableNodeProps={{ ref: sidebarScrollerRef }}
+            className="h-100 position-relative"
+            autoHide
+          >
+            <AiAssistantChatSidebarSubstance
+              aiAssistantData={aiAssistantChatSidebarData?.aiAssistantData}
+              closeAiAssistantChatSidebar={closeAiAssistantChatSidebar}
+            />
+          </SimpleBar>
+        </div>
+      )}
+    </>
+  );
+});

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

@@ -8,6 +8,7 @@ import { useCurrentUser } from '~/stores-universal/context';
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { deleteAiAssistant } from '../../../services/ai-assistant';
 import { deleteAiAssistant } from '../../../services/ai-assistant';
+import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 
 
 import styles from './AiAssistantTree.module.scss';
 import styles from './AiAssistantTree.module.scss';
 
 
@@ -84,6 +85,7 @@ type AiAssistantItemProps = {
   currentUserId?: string;
   currentUserId?: string;
   aiAssistant: AiAssistantHasId;
   aiAssistant: AiAssistantHasId;
   threads: Thread[];
   threads: Thread[];
+  onItemClicked?: (aiAssistantData: AiAssistantHasId) => void;
   onDeleted?: () => void;
   onDeleted?: () => void;
 };
 };
 
 
@@ -91,13 +93,14 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   currentUserId,
   currentUserId,
   aiAssistant,
   aiAssistant,
   threads,
   threads,
+  onItemClicked,
   onDeleted,
   onDeleted,
 }) => {
 }) => {
   const [isThreadsOpened, setIsThreadsOpened] = useState(false);
   const [isThreadsOpened, setIsThreadsOpened] = useState(false);
 
 
-  const openChatHandler = useCallback(() => {
-    // TODO: https://redmine.weseek.co.jp/issues/159530
-  }, []);
+  const openChatHandler = useCallback((aiAssistantData: AiAssistantHasId) => {
+    onItemClicked?.(aiAssistantData);
+  }, [onItemClicked]);
 
 
   const openThreadsHandler = useCallback(() => {
   const openThreadsHandler = useCallback(() => {
     setIsThreadsOpened(toggle => !toggle);
     setIsThreadsOpened(toggle => !toggle);
@@ -119,7 +122,7 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   return (
   return (
     <>
     <>
       <li
       <li
-        onClick={openChatHandler}
+        onClick={() => openChatHandler(aiAssistant)}
         role="button"
         role="button"
         className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
         className="list-group-item list-group-item-action border-0 d-flex align-items-center rounded-1"
       >
       >
@@ -183,6 +186,8 @@ type AiAssistantTreeProps = {
 
 
 export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onDeleted }) => {
 export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants, onDeleted }) => {
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
+  const { open: openAiAssistantChatSidebar } = useAiAssistantChatSidebar();
+
   return (
   return (
     <ul className={`list-group ${moduleClass}`}>
     <ul className={`list-group ${moduleClass}`}>
       {aiAssistants.map(assistant => (
       {aiAssistants.map(assistant => (
@@ -191,6 +196,7 @@ export const AiAssistantTree: React.FC<AiAssistantTreeProps> = ({ aiAssistants,
           currentUserId={currentUser?._id}
           currentUserId={currentUser?._id}
           aiAssistant={assistant}
           aiAssistant={assistant}
           threads={dummyThreads}
           threads={dummyThreads}
+          onItemClicked={openAiAssistantChatSidebar}
           onDeleted={onDeleted}
           onDeleted={onDeleted}
         />
         />
       ))}
       ))}

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

@@ -6,7 +6,7 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 
 
-import { type AccessibleAiAssistantsHasId } from '../../interfaces/ai-assistant';
+import { type AccessibleAiAssistantsHasId, type AiAssistantHasId } from '../../interfaces/ai-assistant';
 
 
 export const AiAssistantManagementModalPageMode = {
 export const AiAssistantManagementModalPageMode = {
   HOME: 'home',
   HOME: 'home',
@@ -51,3 +51,27 @@ export const useSWRxAiAssistants = (): SWRResponse<AccessibleAiAssistantsHasId,
     ([endpoint]) => apiv3Get(endpoint).then(response => response.data.accessibleAiAssistants),
     ([endpoint]) => apiv3Get(endpoint).then(response => response.data.accessibleAiAssistants),
   );
   );
 };
 };
+
+
+type AiAssistantChatSidebarStatus = {
+  isOpened: boolean,
+  aiAssistantData?: AiAssistantHasId;
+}
+
+type AiAssistantChatSidebarUtils = {
+  open(aiAssistantData: AiAssistantHasId): void
+  close(): void
+}
+
+export const useAiAssistantChatSidebar = (
+    status?: AiAssistantChatSidebarStatus,
+): SWRResponse<AiAssistantChatSidebarStatus, Error> & AiAssistantChatSidebarUtils => {
+  const initialStatus = { isOpened: false };
+  const swrResponse = useSWRStatic<AiAssistantChatSidebarStatus, Error>('AiAssistantChatSidebar', status, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    open: useCallback((aiAssistantData: AiAssistantHasId) => { swrResponse.mutate({ isOpened: true, aiAssistantData }) }, [swrResponse]),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+  };
+};