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

Merge pull request #9895 from weseek/imprv/163216-reorganize-ai-assistant-sidebar

imprv: Reorganize AiAssistantSidebar
Yuki Takei 11 месяцев назад
Родитель
Сommit
86a8dd1819

+ 109 - 173
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -8,27 +8,26 @@ import { useTranslation } from 'react-i18next';
 import { Collapse, UncontrolledTooltip } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
 
-
-import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { useGrowiCloudUri, useIsEnableUnifiedMergeView } from '~/stores-universal/context';
-import { useEditorMode, EditorMode } from '~/stores-universal/ui';
 import loggerFactory from '~/utils/logger';
 
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import type { MessageLog } from '../../../../interfaces/message';
 import { MessageErrorCode, StreamErrorCode } from '../../../../interfaces/message-error';
-import { ThreadType } from '../../../../interfaces/thread-relation';
 import type { IThreadRelationHasId } from '../../../../interfaces/thread-relation';
-import { useEditorAssistant } from '../../../services/editor-assistant';
-import { useKnowledgeAssistant } from '../../../services/knowledge-assistant';
+import {
+  useEditorAssistant,
+  useAiAssistantSidebarCloseEffect as useAiAssistantSidebarCloseEffectForEditorAssistant,
+} from '../../../services/editor-assistant';
+import {
+  useKnowledgeAssistant,
+  useFetchAndSetMessageDataEffect,
+  useAiAssistantSidebarCloseEffect as useAiAssistantSidebarCloseEffectForKnowledgeAssistant,
+} from '../../../services/knowledge-assistant';
 import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
-import { useSWRMUTxMessages } from '../../../stores/message';
-import { useSWRMUTxThreads } from '../../../stores/thread';
 
-import { AiAssistantChatInitialView } from './AiAssistantChatInitialView';
-import { AiAssistantDropdown } from './AiAssistantDropdown';
-import { MessageCard } from './MessageCard';
-import { QuickMenuList } from './QuickMenuList';
+import { MessageCard, type MessageCardRole } from './MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 
 import styles from './AiAssistantSidebar.module.scss';
@@ -37,13 +36,7 @@ const logger = loggerFactory('growi:openai:client:components:AiAssistantSidebar'
 
 const moduleClass = styles['grw-ai-assistant-sidebar'] ?? '';
 
-type Message = {
-  id: string,
-  content: string,
-  isUserMessage?: boolean,
-}
-
-type FormData = {
+export type FormData = {
   input: string;
   summaryMode?: boolean;
 };
@@ -63,25 +56,41 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     closeAiAssistantSidebar,
   } = props;
 
-  const [currentThreadTitle, setCurrentThreadTitle] = useState<string | undefined>(threadData?.title);
+  // States
   const [currentThreadId, setCurrentThreadId] = useState<string | undefined>(threadData?.threadId);
-  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
-  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+  const [messageLogs, setMessageLogs] = useState<MessageLog[]>([]);
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<MessageLog>();
   const [errorMessage, setErrorMessage] = useState<string | undefined>();
   const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
-  const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
 
+  // Hooks
   const { t } = useTranslation();
   const { data: growiCloudUri } = useGrowiCloudUri();
-  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
-  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantData?._id, threadData?.threadId);
 
-  const { postMessage: postMessageForKnowledgeAssistant, processMessage: processMessageForKnowledgeAssistant } = useKnowledgeAssistant();
   const {
+    createThread: createThreadForKnowledgeAssistant,
+    postMessage: postMessageForKnowledgeAssistant,
+    processMessage: processMessageForKnowledgeAssistant,
+
+    // Views
+    initialView: initialViewForKnowledgeAssistant,
+    generateMessageCard: generateMessageCardForKnowledgeAssistant,
+    headerIcon: headerIconForKnowledgeAssistant,
+    headerText: headerTextForKnowledgeAssistant,
+    placeHolder: placeHolderForKnowledgeAssistant,
+  } = useKnowledgeAssistant();
+
+  const {
+    createThread: createThreadForEditorAssistant,
     postMessage: postMessageForEditorAssistant,
     processMessage: processMessageForEditorAssistant,
-    accept,
-    reject,
+
+    // Views
+    generateInitialView: generateInitialViewForEditorAssistant,
+    generateMessageCard: generateMessageCardForEditorAssistant,
+    headerIcon: headerIconForEditorAssistant,
+    headerText: headerTextForEditorAssistant,
+    placeHolder: placeHolderForEditorAssistant,
   } = useEditorAssistant();
 
   const form = useForm<FormData>({
@@ -91,71 +100,33 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     },
   });
 
-  useEffect(() => {
-    const fetchAndSetMessageData = async() => {
-      const messageData = await mutateMessageData();
-      if (messageData != null) {
-        const normalizedMessageData = messageData.data
-          .reverse()
-          .filter(message => message.metadata?.shouldHideMessage !== 'true');
-
-        setMessageLogs(() => {
-          return normalizedMessageData.map((message, index) => (
-            {
-              id: index.toString(),
-              content: message.content[0].type === 'text' ? message.content[0].text.value : '',
-              isUserMessage: message.role === 'user',
-            }
-          ));
-        });
-      }
-    };
-
-    if (threadData != null) {
-      fetchAndSetMessageData();
-    }
-  }, [mutateMessageData, threadData]);
+  // Effects
+  useFetchAndSetMessageDataEffect(setMessageLogs, threadData?.threadId);
 
-  const isActionButtonShown = useCallback((messageId: string) => {
-    if (!isEditorAssistant) {
-      return false;
+  // Functions
+  const createThread = useCallback(async(initialUserMessage: string) => {
+    if (isEditorAssistant) {
+      const thread = await createThreadForEditorAssistant();
+      return thread;
     }
 
-    if (generatingAnswerMessage != null) {
-      return false;
+    if (aiAssistantData == null) {
+      return;
     }
-
-    const latestAssistantMessageLogId = messageLogs
-      .filter(message => !message.isUserMessage)
-      .slice(-1)[0];
-
-    if (messageId === latestAssistantMessageLogId?.id) {
-      return true;
+    const thread = await createThreadForKnowledgeAssistant(aiAssistantData._id, initialUserMessage);
+    return thread;
+  }, [aiAssistantData, createThreadForEditorAssistant, createThreadForKnowledgeAssistant, isEditorAssistant]);
+
+  const postMessage = useCallback(async(currentThreadId: string, input: string, summaryMode?: boolean) => {
+    if (isEditorAssistant) {
+      const response = await postMessageForEditorAssistant(currentThreadId, input);
+      return response;
     }
-
-    return false;
-  }, [generatingAnswerMessage, isEditorAssistant, messageLogs]);
-
-  const headerIcon = useMemo(() => {
-    return isEditorAssistant
-      ? <span className="material-symbols-outlined growi-ai-chat-icon me-3 fs-4">support_agent</span>
-      : <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>;
-  }, [isEditorAssistant]);
-
-  const headerText = useMemo(() => {
-    return isEditorAssistant
-      ? <>{t('Editor Assistant')}</>
-      : <>{currentThreadTitle ?? aiAssistantData?.name}</>;
-  }, [isEditorAssistant, currentThreadTitle, aiAssistantData?.name, t]);
-
-  const placeHolder = useMemo(() => {
-    if (form.formState.isSubmitting) {
-      return '';
+    if (aiAssistantData?._id != null) {
+      const response = postMessageForKnowledgeAssistant(aiAssistantData._id, currentThreadId, input, summaryMode);
+      return response;
     }
-    return t(isEditorAssistant
-      ? 'sidebar_ai_assistant.editor_assistant_placeholder'
-      : 'sidebar_ai_assistant.knowledge_assistant_placeholder');
-  }, [form.formState.isSubmitting, isEditorAssistant, t]);
+  }, [aiAssistantData?._id, isEditorAssistant, postMessageForEditorAssistant, postMessageForKnowledgeAssistant]);
 
   const isGenerating = generatingAnswerMessage != null;
   const submit = useCallback(async(data: FormData) => {
@@ -187,23 +158,13 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     let currentThreadId_ = currentThreadId;
     if (currentThreadId_ == null) {
       try {
-        const res = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
-          type: isEditorAssistant ? ThreadType.EDITOR : ThreadType.KNOWLEDGE,
-          aiAssistantId: isEditorAssistant ? selectedAiAssistant?._id : aiAssistantData?._id,
-          initialUserMessage: isEditorAssistant ? undefined : newUserMessage.content,
-        });
-
-        const thread = res.data;
+        const thread = await createThread(newUserMessage.content);
+        if (thread == null) {
+          return;
+        }
 
         setCurrentThreadId(thread.threadId);
-        setCurrentThreadTitle(thread.title);
-
         currentThreadId_ = thread.threadId;
-
-        // No need to await because data is not used
-        if (!isEditorAssistant) {
-          mutateThreadData();
-        }
       }
       catch (err) {
         logger.error(err.toString());
@@ -217,15 +178,7 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
         return;
       }
 
-      const response = await (async() => {
-        if (isEditorAssistant) {
-          return postMessageForEditorAssistant(currentThreadId_, data.input);
-        }
-        if (aiAssistantData?._id != null) {
-          return postMessageForKnowledgeAssistant(aiAssistantData._id, currentThreadId_, data.input, data.summaryMode);
-        }
-      })();
-
+      const response = await postMessage(currentThreadId_, data.input, data.summaryMode);
       if (response == null) {
         return;
       }
@@ -322,7 +275,7 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     }
 
   // eslint-disable-next-line max-len
-  }, [isGenerating, messageLogs, form, currentThreadId, isEditorAssistant, selectedAiAssistant?._id, aiAssistantData?._id, mutateThreadData, t, postMessageForEditorAssistant, postMessageForKnowledgeAssistant, processMessageForKnowledgeAssistant, processMessageForEditorAssistant, growiCloudUri]);
+  }, [isGenerating, messageLogs, form, currentThreadId, createThread, t, postMessage, processMessageForKnowledgeAssistant, processMessageForEditorAssistant, growiCloudUri]);
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
     if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
@@ -330,21 +283,48 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     }
   };
 
-  const clickQuickMenuHandler = useCallback(async(quickMenu: string) => {
-    await submit({ input: quickMenu });
-  }, [submit]);
+  // Views
+  const headerIcon = useMemo(() => {
+    return isEditorAssistant
+      ? headerIconForEditorAssistant
+      : headerIconForKnowledgeAssistant;
+  }, [headerIconForEditorAssistant, headerIconForKnowledgeAssistant, isEditorAssistant]);
+
+  const headerText = useMemo(() => {
+    return isEditorAssistant
+      ? headerTextForEditorAssistant
+      : headerTextForKnowledgeAssistant;
+  }, [isEditorAssistant, headerTextForEditorAssistant, headerTextForKnowledgeAssistant]);
+
+  const placeHolder = useMemo(() => {
+    if (form.formState.isSubmitting) {
+      return '';
+    }
+    return t(isEditorAssistant
+      ? placeHolderForEditorAssistant
+      : placeHolderForKnowledgeAssistant);
+  }, [form.formState.isSubmitting, isEditorAssistant, placeHolderForEditorAssistant, placeHolderForKnowledgeAssistant, t]);
+
+  const initialView = useMemo(() => {
+    if (isEditorAssistant) {
+      return generateInitialViewForEditorAssistant(submit);
+    }
 
-  const clickAcceptHandler = useCallback(() => {
-    accept();
-  }, [accept]);
+    return initialViewForKnowledgeAssistant;
+  }, [generateInitialViewForEditorAssistant, initialViewForKnowledgeAssistant, isEditorAssistant, submit]);
 
-  const clickDiscardHandler = useCallback(() => {
-    reject();
-  }, [reject]);
+  const messageCard = useCallback(
+    (role: MessageCardRole, children: string, messageId?: string, messageLogs?: MessageLog[], generatingAnswerMessage?: MessageLog) => {
+      if (isEditorAssistant) {
+        if (messageId == null || messageLogs == null) {
+          return <></>;
+        }
+        return generateMessageCardForEditorAssistant(role, children, messageId, messageLogs, generatingAnswerMessage);
+      }
 
-  const selectAiAssistantHandler = useCallback((aiAssistant?: AiAssistantHasId) => {
-    setSelectedAiAssistant(aiAssistant);
-  }, []);
+      return generateMessageCardForKnowledgeAssistant(role, children);
+    }, [generateMessageCardForEditorAssistant, generateMessageCardForKnowledgeAssistant, isEditorAssistant],
+  );
 
   return (
     <>
@@ -368,15 +348,9 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
             ? (
               <div className="vstack gap-4 pb-2">
                 { messageLogs.map(message => (
-                  <MessageCard
-                    key={message.id}
-                    role={message.isUserMessage ? 'user' : 'assistant'}
-                    showActionButtons={isActionButtonShown(message.id)}
-                    onAccept={clickAcceptHandler}
-                    onDiscard={clickDiscardHandler}
-                  >
-                    {message.content}
-                  </MessageCard>
+                  <>
+                    {messageCard(message.isUserMessage ? 'user' : 'assistant', message.content, message.id, messageLogs, generatingAnswerMessage)}
+                  </>
                 )) }
                 { generatingAnswerMessage != null && (
                   <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
@@ -391,28 +365,7 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
               </div>
             )
             : (
-              <>{isEditorAssistant
-                ? (
-                  <>
-                    <div className="py-2">
-                      <AiAssistantDropdown
-                        selectedAiAssistant={selectedAiAssistant}
-                        onSelect={selectAiAssistantHandler}
-                      />
-                    </div>
-                    <QuickMenuList
-                      onClick={clickQuickMenuHandler}
-                    />
-                  </>
-                )
-                : (
-                  <AiAssistantChatInitialView
-                    description={aiAssistantData?.description ?? ''}
-                    additionalInstruction={aiAssistantData?.additionalInstruction ?? ''}
-                    pagePathPatterns={aiAssistantData?.pagePathPatterns ?? []}
-                  />
-                )}
-              </>
+              <>{ initialView }</>
             )
           }
 
@@ -515,7 +468,6 @@ export const AiAssistantSidebar: FC = memo((): JSX.Element => {
   const sidebarRef = useRef<HTMLDivElement>(null);
   const sidebarScrollerRef = useRef<HTMLDivElement>(null);
 
-  const { data: editorMode } = useEditorMode();
   const { data: aiAssistantSidebarData, close: closeAiAssistantSidebar } = useAiAssistantSidebar();
   const { mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
 
@@ -524,24 +476,8 @@ export const AiAssistantSidebar: FC = memo((): JSX.Element => {
   const isOpened = aiAssistantSidebarData?.isOpened;
   const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false;
 
-  useEffect(() => {
-    const handleClickOutside = (event: MouseEvent) => {
-      if (isOpened && sidebarRef.current && !sidebarRef.current.contains(event.target as Node) && !isEditorAssistant) {
-        closeAiAssistantSidebar();
-      }
-    };
-
-    document.addEventListener('mousedown', handleClickOutside);
-    return () => {
-      document.removeEventListener('mousedown', handleClickOutside);
-    };
-  }, [closeAiAssistantSidebar, isEditorAssistant, isOpened]);
-
-  useEffect(() => {
-    if (isEditorAssistant && editorMode !== EditorMode.Editor) {
-      closeAiAssistantSidebar();
-    }
-  }, [closeAiAssistantSidebar, editorMode, isEditorAssistant]);
+  useAiAssistantSidebarCloseEffectForEditorAssistant();
+  useAiAssistantSidebarCloseEffectForKnowledgeAssistant(sidebarRef);
 
   useEffect(() => {
     if (!aiAssistantSidebarData?.isOpened) {

+ 3 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx

@@ -106,8 +106,10 @@ const AssistantMessageCard = ({
   );
 };
 
+export type MessageCardRole = 'user' | 'assistant';
+
 type Props = {
-  role: 'user' | 'assistant',
+  role: MessageCardRole,
   children: string,
   showActionButtons?: boolean,
   onDiscard?: () => void,

+ 147 - 19
apps/app/src/features/openai/client/services/editor-assistant.ts → apps/app/src/features/openai/client/services/editor-assistant.tsx

@@ -1,5 +1,5 @@
 import {
-  useCallback, useEffect, useState, useRef,
+  useCallback, useEffect, useState, useRef, useMemo,
 } from 'react';
 
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
@@ -8,8 +8,10 @@ import {
 } from '@growi/editor/dist/client/services/unified-merge-view';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useSecondaryYdocs } from '@growi/editor/dist/client/stores/use-secondary-ydocs';
+import { useTranslation } from 'react-i18next';
 import { type Text as YText } from 'yjs';
 
+import { apiv3Post } from '~/client/util/apiv3-client';
 import {
   SseMessageSchema,
   SseDetectedDiffSchema,
@@ -24,8 +26,22 @@ import {
 } from '~/features/openai/interfaces/editor-assistant/sse-schemas';
 import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
 import { useIsEnableUnifiedMergeView } from '~/stores-universal/context';
+import { EditorMode, useEditorMode } from '~/stores-universal/ui';
 import { useCurrentPageId } from '~/stores/page';
 
+import type { AiAssistantHasId } from '../../interfaces/ai-assistant';
+import type { MessageLog } from '../../interfaces/message';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+import { ThreadType } from '../../interfaces/thread-relation';
+import { AiAssistantDropdown } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantDropdown';
+import { type FormData } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar';
+import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
+import { QuickMenuList } from '../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
+import { useAiAssistantSidebar } from '../stores/ai-assistant';
+
+interface CreateThread {
+  (): Promise<IThreadRelationHasId>;
+}
 interface PostMessage {
   (threadId: string, userMessage: string): Promise<Response>;
 }
@@ -37,6 +53,13 @@ interface ProcessMessage {
   }): void;
 }
 
+interface GenerateInitialView {
+  (onSubmit: (data: FormData) => Promise<void>): JSX.Element;
+}
+interface GenerateMessageCard {
+  (role: MessageCardRole, children: string, messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
+}
+
 type DetectedDiff = Array<{
   data: SseDetectedDiff,
   applied: boolean,
@@ -44,10 +67,16 @@ type DetectedDiff = Array<{
 }>
 
 type UseEditorAssistant = () => {
+  createThread: CreateThread,
   postMessage: PostMessage,
   processMessage: ProcessMessage,
-  accept: () => void,
-  reject: () => void,
+
+  // Views
+  generateInitialView: GenerateInitialView,
+  generateMessageCard: GenerateMessageCard,
+  headerIcon: JSX.Element,
+  headerText: JSX.Element,
+  placeHolder: string,
 }
 
 const insertTextAtLine = (yText: YText, lineNumber: number, textToInsert: string): void => {
@@ -111,14 +140,25 @@ export const useEditorAssistant: UseEditorAssistant = () => {
   // States
   const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
   const [selectedText, setSelectedText] = useState<string>();
+  const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
 
-  // SWR Hooks
+  // Hooks
+  const { t } = useTranslation();
   const { data: currentPageId } = useCurrentPageId();
   const { data: isEnableUnifiedMergeView, mutate: mutateIsEnableUnifiedMergeView } = useIsEnableUnifiedMergeView();
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
   const yDocs = useSecondaryYdocs(isEnableUnifiedMergeView ?? false, { pageId: currentPageId ?? undefined, useSecondary: isEnableUnifiedMergeView ?? false });
+  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
 
   // Functions
+  const createThread: CreateThread = useCallback(async() => {
+    const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
+      type: ThreadType.EDITOR,
+      aiAssistantId: selectedAiAssistant?._id,
+    });
+    return response.data;
+  }, [selectedAiAssistant?._id]);
+
   const postMessage: PostMessage = useCallback(async(threadId, userMessage) => {
     const response = await fetch('/_api/v3/openai/edit', {
       method: 'POST',
@@ -155,19 +195,6 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     });
   }, [mutateIsEnableUnifiedMergeView]);
 
-  const accept = useCallback(() => {
-    if (codeMirrorEditor?.view == null) {
-      return;
-    }
-
-    acceptAllChunks(codeMirrorEditor.view);
-    mutateIsEnableUnifiedMergeView(false);
-  }, [codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
-
-  const reject = useCallback(() => {
-    mutateIsEnableUnifiedMergeView(false);
-  }, [mutateIsEnableUnifiedMergeView]);
-
   const selectTextHandler = useCallback((selectedText: string, selectedTextFirstLineNumber: number) => {
     setSelectedText(selectedText);
     lineRef.current = selectedTextFirstLineNumber;
@@ -250,10 +277,111 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     }
   }, [codeMirrorEditor, detectedDiff, selectedText, yDocs?.secondaryDoc]);
 
+
+  // Views
+  const headerIcon = useMemo(() => {
+    return <span className="material-symbols-outlined growi-ai-chat-icon me-3 fs-4">support_agent</span>;
+  }, []);
+
+  const headerText = useMemo(() => {
+    return <>{t('Editor Assistant')}</>;
+  }, [t]);
+
+  const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.editor_assistant_placeholder' }, []);
+
+  const generateInitialView: GenerateInitialView = useCallback((onSubmit) => {
+    const selectAiAssistantHandler = (aiAssistant?: AiAssistantHasId) => {
+      setSelectedAiAssistant(aiAssistant);
+    };
+
+    const clickQuickMenuHandler = async(quickMenu: string) => {
+      await onSubmit({ input: quickMenu });
+    };
+
+    return (
+      <>
+        <div className="py-2">
+          <AiAssistantDropdown
+            selectedAiAssistant={selectedAiAssistant}
+            onSelect={selectAiAssistantHandler}
+          />
+        </div>
+        <QuickMenuList
+          onClick={clickQuickMenuHandler}
+        />
+      </>
+    );
+  }, [selectedAiAssistant]);
+
+
+  const generateMessageCard: GenerateMessageCard = useCallback((role, children, messageId, messageLogs, generatingAnswerMessage) => {
+    const isActionButtonShown = (() => {
+      if (!aiAssistantSidebarData?.isEditorAssistant) {
+        return false;
+      }
+
+      if (generatingAnswerMessage != null) {
+        return false;
+      }
+
+      const latestAssistantMessageLogId = messageLogs
+        .filter(message => !message.isUserMessage)
+        .slice(-1)[0];
+
+      if (messageId === latestAssistantMessageLogId?.id) {
+        return true;
+      }
+
+      return false;
+    })();
+
+
+    const accept = () => {
+      if (codeMirrorEditor?.view == null) {
+        return;
+      }
+
+      acceptAllChunks(codeMirrorEditor.view);
+      mutateIsEnableUnifiedMergeView(false);
+    };
+
+    const reject = () => {
+      mutateIsEnableUnifiedMergeView(false);
+    };
+
+    return (
+      <MessageCard
+        role={role}
+        showActionButtons={isActionButtonShown}
+        onAccept={accept}
+        onDiscard={reject}
+      >
+        {children}
+      </MessageCard>
+    );
+  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, mutateIsEnableUnifiedMergeView]);
+
   return {
+    createThread,
     postMessage,
     processMessage,
-    accept,
-    reject,
+
+    // Views
+    generateInitialView,
+    generateMessageCard,
+    headerIcon,
+    headerText,
+    placeHolder,
   };
 };
+
+export const useAiAssistantSidebarCloseEffect = (): void => {
+  const { data, close } = useAiAssistantSidebar();
+  const { data: editorMode } = useEditorMode();
+
+  useEffect(() => {
+    if (data?.isEditorAssistant && editorMode !== EditorMode.Editor) {
+      close();
+    }
+  }, [close, data?.isEditorAssistant, editorMode]);
+};

+ 0 - 40
apps/app/src/features/openai/client/services/knowledge-assistant.ts

@@ -1,40 +0,0 @@
-import { useCallback } from 'react';
-
-import { SseMessageSchema, type SseMessage } from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
-import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
-
-interface PostMessage {
-  (aiAssistantId: string, threadId: string, userMessage: string, summaryMode?: boolean): Promise<Response>;
-}
-interface ProcessMessage {
-  (data: unknown, handler: {
-    onMessage: (data: SseMessage) => void}
-  ): void;
-}
-
-export const useKnowledgeAssistant = (): { postMessage: PostMessage, processMessage: ProcessMessage } => {
-  const postMessage: PostMessage = useCallback(async(aiAssistantId, threadId, userMessage, summaryMode) => {
-    const response = await fetch('/_api/v3/openai/message', {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        aiAssistantId,
-        threadId,
-        userMessage,
-        summaryMode,
-      }),
-    });
-    return response;
-  }, []);
-
-  const processMessage: ProcessMessage = useCallback((data, handler) => {
-    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
-      handler.onMessage(data);
-    });
-  }, []);
-
-  return {
-    postMessage,
-    processMessage,
-  };
-};

+ 193 - 0
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -0,0 +1,193 @@
+import type { Dispatch, SetStateAction, RefObject } from 'react';
+import {
+  useCallback, useMemo, useState, useEffect,
+} from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { SseMessageSchema, type SseMessage } from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
+import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
+
+import type { MessageLog } from '../../interfaces/message';
+import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
+import { ThreadType } from '../../interfaces/thread-relation';
+import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
+import { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
+import { useAiAssistantSidebar } from '../stores/ai-assistant';
+import { useSWRMUTxMessages } from '../stores/message';
+import { useSWRMUTxThreads } from '../stores/thread';
+
+interface CreateThread {
+  (aiAssistantId: string, initialUserMessage: string): Promise<IThreadRelationHasId>;
+}
+
+interface PostMessage {
+  (aiAssistantId: string, threadId: string, userMessage: string, summaryMode?: boolean): Promise<Response>;
+}
+
+interface ProcessMessage {
+  (data: unknown, handler: {
+    onMessage: (data: SseMessage) => void}
+  ): void;
+}
+
+interface GenerateMessageCard {
+  (role: MessageCardRole, children: string): JSX.Element;
+}
+
+type UseKnowledgeAssistant = () => {
+  createThread: CreateThread
+  postMessage: PostMessage
+  processMessage: ProcessMessage
+
+  // Views
+  initialView: JSX.Element
+  generateMessageCard: GenerateMessageCard,
+  headerIcon: JSX.Element
+  headerText: JSX.Element
+  placeHolder: string
+}
+
+export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
+  // Hooks
+  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
+  const { aiAssistantData } = aiAssistantSidebarData ?? {};
+  const { threadData } = aiAssistantSidebarData ?? {};
+  const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
+
+  // States
+  const [currentThreadTitle, setCurrentThreadId] = useState(threadData?.title);
+
+  // Functions
+  const createThread: CreateThread = useCallback(async(aiAssistantId, initialUserMessage) => {
+    const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
+      type: ThreadType.KNOWLEDGE,
+      aiAssistantId,
+      initialUserMessage,
+    });
+    const thread = response.data;
+
+    setCurrentThreadId(thread.title);
+
+    // No need to await because data is not used
+    mutateThreadData();
+
+    return thread;
+  }, [mutateThreadData]);
+
+  const postMessage: PostMessage = useCallback(async(aiAssistantId, threadId, userMessage, summaryMode) => {
+    const response = await fetch('/_api/v3/openai/message', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        aiAssistantId,
+        threadId,
+        userMessage,
+        summaryMode,
+      }),
+    });
+    return response;
+  }, []);
+
+  const processMessage: ProcessMessage = useCallback((data, handler) => {
+    handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handler.onMessage(data);
+    });
+  }, []);
+
+  // Views
+  const headerIcon = useMemo(() => {
+    return <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>;
+  }, []);
+
+  const headerText = useMemo(() => {
+    return <>{currentThreadTitle ?? aiAssistantData?.name}</>;
+  }, [aiAssistantData?.name, currentThreadTitle]);
+
+  const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.knowledge_assistant_placeholder' }, []);
+
+  const initialView = useMemo(() => {
+    if (aiAssistantSidebarData?.aiAssistantData == null) {
+      return <></>;
+    }
+
+    return (
+      <AiAssistantChatInitialView
+        description={aiAssistantSidebarData.aiAssistantData.description}
+        additionalInstruction={aiAssistantSidebarData.aiAssistantData.additionalInstruction}
+        pagePathPatterns={aiAssistantSidebarData.aiAssistantData.pagePathPatterns}
+      />
+    );
+  }, [aiAssistantSidebarData?.aiAssistantData]);
+
+  const generateMessageCard: GenerateMessageCard = useCallback((role, children) => {
+    return (
+      <MessageCard
+        role={role}
+      >
+        {children}
+      </MessageCard>
+    );
+  }, []);
+
+  return {
+    createThread,
+    postMessage,
+    processMessage,
+
+    // Views
+    initialView,
+    generateMessageCard,
+    headerIcon,
+    headerText,
+    placeHolder,
+  };
+};
+
+
+export const useFetchAndSetMessageDataEffect = (setMessageLogs: Dispatch<SetStateAction<MessageLog[]>>, threadId?: string): void => {
+  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
+  const { trigger: mutateMessageData } = useSWRMUTxMessages(aiAssistantSidebarData?.aiAssistantData?._id, threadId);
+
+  useEffect(() => {
+    const fetchAndSetMessageData = async() => {
+      const messageData = await mutateMessageData();
+      if (messageData != null) {
+        const normalizedMessageData = messageData.data
+          .reverse()
+          .filter(message => message.metadata?.shouldHideMessage !== 'true');
+
+        setMessageLogs(() => {
+          return normalizedMessageData.map((message, index) => (
+            {
+              id: index.toString(),
+              content: message.content[0].type === 'text' ? message.content[0].text.value : '',
+              isUserMessage: message.role === 'user',
+            }
+          ));
+        });
+      }
+    };
+
+    if (threadId != null) {
+      fetchAndSetMessageData();
+    }
+
+  }, [mutateMessageData, setMessageLogs, threadId]);
+};
+
+export const useAiAssistantSidebarCloseEffect = (sidebarRef: RefObject<HTMLDivElement>): void => {
+  const { data, close } = useAiAssistantSidebar();
+
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (data?.isOpened && sidebarRef.current && !sidebarRef.current.contains(event.target as Node) && !data.isEditorAssistant) {
+        close();
+      }
+    };
+
+    document.addEventListener('mousedown', handleClickOutside);
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+    };
+  }, [close, data?.isEditorAssistant, data?.isOpened, sidebarRef]);
+};

+ 6 - 0
apps/app/src/features/openai/interfaces/message.ts

@@ -11,3 +11,9 @@ export type MessageWithCustomMetaData = Omit<OpenAI.Beta.Threads.Messages.Messag
 };
 
 export type MessageListParams = OpenAI.Beta.Threads.Messages.MessageListParams;
+
+export type MessageLog = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}