Răsfoiți Sursa

Merge pull request #9991 from weseek/feat/166531-display-spinner-while-creating-diff

feat(ai): Display spinner while creating diff
Shun Miyazawa 10 luni în urmă
părinte
comite
fa2896425b

+ 1 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -515,6 +515,7 @@
     "accept": "Accept",
     "use_assistant": "Use Assistant",
     "remove_assistant": "Deselect the selected assistant",
+    "text_generation_by_editor_assistant_label": "Editor Assistant is generating text",
     "preset_menu": {
       "summarize": {
         "title": "Summarize this article",

+ 1 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -509,6 +509,7 @@
     "accept": "Accepter",
     "use_assistant": "Utiliser l'assistant",
     "remove_assistant": "Désélectionner l'assistant sélectionné",
+    "text_generation_by_editor_assistant_label": "L'assistant de rédaction génère du texte",
     "preset_menu": {
       "summarize": {
         "title": "Résumer cet article'",

+ 1 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -547,6 +547,7 @@
     "accept": "採用",
     "use_assistant": "アシスタントを使用する",
     "remove_assistant": "選択されているアシスタントの解除",
+    "text_generation_by_editor_assistant_label": "エディターアシスタントが文章を生成中",
     "preset_menu": {
       "summarize": {
         "title": "この記事の要約をつくる",

+ 1 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -504,6 +504,7 @@
     "accept": "接受",
     "use_assistant": "使用助手",
     "remove_assistant": "取消选定的助手",
+    "text_generation_by_editor_assistant_label": "编辑助理正在生成文本",
     "preset_menu": {
       "summarize": {
         "title": "为此文章创建摘要",

+ 32 - 16
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -29,7 +29,7 @@ import {
 import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
 import { useSWRxThreads } from '../../../stores/thread';
 
-import { MessageCard, type MessageCardRole } from './MessageCard';
+import { MessageCard } from './MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 
 import styles from './AiAssistantSidebar.module.scss';
@@ -78,7 +78,6 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
     // Views
     initialView: initialViewForKnowledgeAssistant,
-    generateMessageCard: generateMessageCardForKnowledgeAssistant,
     generateModeSwitchesDropdown: generateModeSwitchesDropdownForKnowledgeAssistant,
     headerIcon: headerIconForKnowledgeAssistant,
     headerText: headerTextForKnowledgeAssistant,
@@ -95,7 +94,8 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
     // Views
     generateInitialView: generateInitialViewForEditorAssistant,
-    generateMessageCard: generateMessageCardForEditorAssistant,
+    generatingEditorTextLabel,
+    generateActionButtons,
     headerIcon: headerIconForEditorAssistant,
     headerText: headerTextForEditorAssistant,
     placeHolder: placeHolderForEditorAssistant,
@@ -354,18 +354,25 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     return initialViewForKnowledgeAssistant;
   }, [generateInitialViewForEditorAssistant, initialViewForKnowledgeAssistant, isEditorAssistant, submit]);
 
-  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 messageCardAdditionalItemForGeneratingMessage = useMemo(() => {
+    if (isEditorAssistant) {
+      return generatingEditorTextLabel;
+    }
+
+    return <></>;
+  }, [generatingEditorTextLabel, isEditorAssistant]);
+
+
+  const messageCardAdditionalItemForGeneratedMessage = useCallback((messageId?: string) => {
+    if (isEditorAssistant) {
+      if (messageId == null || messageLogs == null) {
+        return <></>;
       }
+      return generateActionButtons(messageId, messageLogs, generatingAnswerMessage);
+    }
 
-      return generateMessageCardForKnowledgeAssistant(role, children);
-    }, [generateMessageCardForEditorAssistant, generateMessageCardForKnowledgeAssistant, isEditorAssistant],
-  );
+    return undefined;
+  }, [generateActionButtons, generatingAnswerMessage, isEditorAssistant, messageLogs]);
 
   return (
     <>
@@ -390,11 +397,21 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
               <div className="vstack gap-4 pb-2">
                 { messageLogs.map(message => (
                   <>
-                    {messageCard(message.isUserMessage ? 'user' : 'assistant', message.content, message.id, messageLogs, generatingAnswerMessage)}
+                    <MessageCard
+                      role={message.isUserMessage ? 'user' : 'assistant'}
+                      additionalItem={messageCardAdditionalItemForGeneratedMessage(message.id)}
+                    >
+                      {message.content}
+                    </MessageCard>
                   </>
                 )) }
                 { generatingAnswerMessage != null && (
-                  <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
+                  <MessageCard
+                    role="assistant"
+                    additionalItem={messageCardAdditionalItemForGeneratingMessage}
+                  >
+                    {generatingAnswerMessage.content}
+                  </MessageCard>
                 )}
                 { messageLogs.length > 0 && (
                   <div className="d-flex justify-content-center">
@@ -471,7 +488,6 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
                 </Collapse>
               </div>
             )}
-
           </div>
         </div>
       </div>

+ 10 - 44
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useState, type JSX } from 'react';
+import { type JSX } from 'react';
 
 import type { LinkProps } from 'next/link';
 import { useTranslation } from 'react-i18next';
@@ -33,27 +33,14 @@ const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): J
 };
 
 const AssistantMessageCard = ({
-  children, showActionButtons, onAccept, onDiscard,
+  children,
+  additionalItem,
 }: {
   children: string,
-  showActionButtons?: boolean
-  onAccept?: () => void,
-  onDiscard?: () => void,
+  additionalItem?: JSX.Element,
 }): JSX.Element => {
   const { t } = useTranslation();
 
-  const [isActionButtonClicked, setIsActionButtonClicked] = useState(false);
-
-  const clickActionButtonHandler = useCallback((action: 'accept' | 'discard') => {
-    setIsActionButtonClicked(true);
-    if (action === 'accept') {
-      onAccept?.();
-      return;
-    }
-
-    onDiscard?.();
-  }, [onAccept, onDiscard]);
-
   return (
     <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
       <div className="card-body d-flex">
@@ -65,25 +52,7 @@ const AssistantMessageCard = ({
             ? (
               <>
                 <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
-
-                {showActionButtons && !isActionButtonClicked && (
-                  <div className="d-flex mt-2 justify-content-start">
-                    <button
-                      type="button"
-                      className="btn btn-outline-secondary me-2"
-                      onClick={() => clickActionButtonHandler('discard')}
-                    >
-                      {t('sidebar_ai_assistant.discard')}
-                    </button>
-                    <button
-                      type="button"
-                      className="btn btn-success"
-                      onClick={() => clickActionButtonHandler('accept')}
-                    >
-                      {t('sidebar_ai_assistant.accept')}
-                    </button>
-                  </div>
-                )}
+                { additionalItem }
               </>
             )
             : (
@@ -98,28 +67,25 @@ const AssistantMessageCard = ({
   );
 };
 
-export type MessageCardRole = 'user' | 'assistant';
+
+type MessageCardRole = 'user' | 'assistant';
 
 type Props = {
   role: MessageCardRole,
   children: string,
-  showActionButtons?: boolean,
-  onDiscard?: () => void,
-  onAccept?: () => void,
+  additionalItem?: JSX.Element,
 }
 
 export const MessageCard = (props: Props): JSX.Element => {
   const {
-    role, children, showActionButtons, onAccept, onDiscard,
+    role, children, additionalItem,
   } = props;
 
   return role === 'user'
     ? <UserMessageCard>{children}</UserMessageCard>
     : (
       <AssistantMessageCard
-        showActionButtons={showActionButtons}
-        onAccept={onAccept}
-        onDiscard={onDiscard}
+        additionalItem={additionalItem}
       >{children}
       </AssistantMessageCard>
     );

+ 77 - 19
apps/app/src/features/openai/client/services/editor-assistant.tsx

@@ -1,5 +1,5 @@
 import {
-  useCallback, useEffect, useState, useRef, useMemo,
+  useCallback, useEffect, useState, useRef, useMemo, type FC,
 } from 'react';
 
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
@@ -31,7 +31,6 @@ 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 { MessageCard, type MessageCardRole } from '../components/AiAssistant/AiAssistantSidebar/MessageCard';
 import { QuickMenuList } from '../components/AiAssistant/AiAssistantSidebar/QuickMenuList';
 import { useAiAssistantSidebar } from '../stores/ai-assistant';
 
@@ -52,8 +51,8 @@ interface ProcessMessage {
 interface GenerateInitialView {
   (onSubmit: (data: FormData) => Promise<void>): JSX.Element;
 }
-interface GenerateMessageCard {
-  (role: MessageCardRole, children: string, messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
+interface GenerateActionButtons {
+  (messageId: string, messageLogs: MessageLog[], generatingAnswerMessage?: MessageLog): JSX.Element;
 }
 export interface FormData {
   input: string,
@@ -73,10 +72,12 @@ type UseEditorAssistant = () => {
   form: UseFormReturn<FormData>
   resetForm: () => void
   isTextSelected: boolean,
+  isGeneratingEditorText: boolean,
 
   // Views
   generateInitialView: GenerateInitialView,
-  generateMessageCard: GenerateMessageCard,
+  generatingEditorTextLabel?: JSX.Element,
+  generateActionButtons: GenerateActionButtons,
   headerIcon: JSX.Element,
   headerText: JSX.Element,
   placeHolder: string,
@@ -138,11 +139,13 @@ const getLineInfo = (yText: YText, lineNumber: number): { text: string, startInd
 export const useEditorAssistant: UseEditorAssistant = () => {
   // Refs
   const lineRef = useRef<number>(0);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
   // States
   const [detectedDiff, setDetectedDiff] = useState<DetectedDiff>();
   const [selectedAiAssistant, setSelectedAiAssistant] = useState<AiAssistantHasId>();
   const [selectedText, setSelectedText] = useState<string>();
+  const [isGeneratingEditorText, setIsGeneratingEditorText] = useState<boolean>(false);
 
   const isTextSelected = useMemo(() => selectedText != null && selectedText.length !== 0, [selectedText]);
 
@@ -205,10 +208,30 @@ export const useEditorAssistant: UseEditorAssistant = () => {
   }, [codeMirrorEditor, mutateIsEnableUnifiedMergeView, selectedText]);
 
   const processMessage: ProcessMessage = useCallback((data, handler) => {
+    // Reset timer whenever data is received
+    const handleDataReceived = () => {
+    // Clear existing timer
+      if (timerRef.current != null) {
+        clearTimeout(timerRef.current);
+      }
+
+      // Hide spinner since data is flowing
+      if (isGeneratingEditorText) {
+        setIsGeneratingEditorText(false);
+      }
+
+      // Set new timer
+      timerRef.current = setTimeout(() => {
+        setIsGeneratingEditorText(true);
+      }, 500);
+    };
+
     handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
+      handleDataReceived();
       handler.onMessage(data);
     });
     handleIfSuccessfullyParsed(data, SseDetectedDiffSchema, (data: SseDetectedDiff) => {
+      handleDataReceived();
       mutateIsEnableUnifiedMergeView(true);
       setDetectedDiff((prev) => {
         const newData = { data, applied: false, id: crypto.randomUUID() };
@@ -222,13 +245,14 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     handleIfSuccessfullyParsed(data, SseFinalizedSchema, (data: SseFinalized) => {
       handler.onFinalized(data);
     });
-  }, [mutateIsEnableUnifiedMergeView]);
+  }, [isGeneratingEditorText, mutateIsEnableUnifiedMergeView]);
 
   const selectTextHandler = useCallback((selectedText: string, selectedTextFirstLineNumber: number) => {
     setSelectedText(selectedText);
     lineRef.current = selectedTextFirstLineNumber;
   }, []);
 
+
   // Effects
   useTextSelectionEffect(codeMirrorEditor, selectTextHandler);
 
@@ -279,6 +303,16 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     }
   }, [detectedDiff]);
 
+  useEffect(() => {
+    return () => {
+      if (timerRef.current != null) {
+        clearTimeout(timerRef.current);
+        timerRef.current = null;
+      }
+    };
+  }, []);
+
+
   // Views
   const headerIcon = useMemo(() => {
     return <span className="material-symbols-outlined growi-ai-chat-icon me-3 fs-4">support_agent</span>;
@@ -314,8 +348,7 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     );
   }, [selectedAiAssistant]);
 
-
-  const generateMessageCard: GenerateMessageCard = useCallback((role, children, messageId, messageLogs, generatingAnswerMessage) => {
+  const generateActionButtons: GenerateActionButtons = useCallback((messageId, messageLogs, generatingAnswerMessage) => {
     const isActionButtonShown = (() => {
       if (!aiAssistantSidebarData?.isEditorAssistant) {
         return false;
@@ -340,7 +373,6 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       return false;
     })();
 
-
     const accept = () => {
       if (codeMirrorEditor?.view == null) {
         return;
@@ -354,17 +386,41 @@ export const useEditorAssistant: UseEditorAssistant = () => {
       mutateIsEnableUnifiedMergeView(false);
     };
 
+    if (!isActionButtonShown) {
+      return <></>;
+    }
+
     return (
-      <MessageCard
-        role={role}
-        showActionButtons={isActionButtonShown}
-        onAccept={accept}
-        onDiscard={reject}
-      >
-        {children}
-      </MessageCard>
+      <div className="d-flex mt-2 justify-content-start">
+        <button
+          type="button"
+          className="btn btn-outline-secondary me-2"
+          onClick={reject}
+        >
+          {t('sidebar_ai_assistant.discard')}
+        </button>
+        <button
+          type="button"
+          className="btn btn-success"
+          onClick={accept}
+        >
+          {t('sidebar_ai_assistant.accept')}
+        </button>
+      </div>
+    );
+  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, isEnableUnifiedMergeView, mutateIsEnableUnifiedMergeView, t]);
+
+  const generatingEditorTextLabel = useMemo(() => {
+    return (
+      <>
+        {isGeneratingEditorText && (
+          <span className="text-thinking">
+            {t('sidebar_ai_assistant.text_generation_by_editor_assistant_label')}
+          </span>
+        )}
+      </>
     );
-  }, [aiAssistantSidebarData?.isEditorAssistant, codeMirrorEditor?.view, isEnableUnifiedMergeView, mutateIsEnableUnifiedMergeView]);
+  }, [isGeneratingEditorText, t]);
 
   return {
     createThread,
@@ -373,10 +429,12 @@ export const useEditorAssistant: UseEditorAssistant = () => {
     form,
     resetForm,
     isTextSelected,
+    isGeneratingEditorText,
 
     // Views
     generateInitialView,
-    generateMessageCard,
+    generatingEditorTextLabel,
+    generateActionButtons,
     headerIcon,
     headerText,
     placeHolder,

+ 2 - 18
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -17,7 +17,6 @@ import type { MessageLog, MessageWithCustomMetaData } from '../../interfaces/mes
 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';
@@ -36,10 +35,6 @@ interface ProcessMessage {
   ): void;
 }
 
-interface GenerateMessageCard {
-  (role: MessageCardRole, children: string): JSX.Element;
-}
-
 export interface FormData {
   input: string
   summaryMode?: boolean
@@ -59,7 +54,6 @@ type UseKnowledgeAssistant = () => {
 
   // Views
   initialView: JSX.Element
-  generateMessageCard: GenerateMessageCard
   generateModeSwitchesDropdown: GenerateModeSwitchesDropdown
   headerIcon: JSX.Element
   headerText: JSX.Element
@@ -153,16 +147,6 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     );
   }, [aiAssistantSidebarData?.aiAssistantData]);
 
-  const generateMessageCard: GenerateMessageCard = useCallback((role, children) => {
-    return (
-      <MessageCard
-        role={role}
-      >
-        {children}
-      </MessageCard>
-    );
-  }, []);
-
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
   const toggleDropdown = useCallback(() => {
@@ -244,7 +228,7 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
 
     // Views
     initialView,
-    generateMessageCard,
+    // generateMessageCard,
     generateModeSwitchesDropdown,
     headerIcon,
     headerText,
@@ -328,5 +312,5 @@ export const useFetchAndSetMessageDataEffect = (
     };
 
     fetchAndSetLogs();
-  }, [threadId, mutateMessageData, setMessageLogs]); // Dependencies
+  }, [threadId, mutateMessageData, setMessageLogs, aiAssistantSidebarData?.isEditorAssistant]); // Dependencies
 };