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

Merge pull request #9208 from weseek/imprv/in-progress-behavior

imprv: In-progress behavior for AiChatModal
mergify[bot] пре 1 година
родитељ
комит
b0e280d426

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

@@ -488,7 +488,8 @@
     "title": "Knowledge Assistant",
     "title_beta_label": "(Beta)",
     "placeholder": "Ask me anything.",
-    "caution_against_hallucination": "Please verify the information and check the sources."
+    "caution_against_hallucination": "Please verify the information and check the sources.",
+    "progress_label": "Generating answers"
   },
   "link_edit": {
     "edit_link": "Edit Link",

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

@@ -482,7 +482,8 @@
     "title": "Assistant de Connaissance",
     "title_beta_label": "(Bêta)",
     "placeholder": "Demandez-moi n'importe quoi.",
-    "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources."
+    "caution_against_hallucination": "Veuillez vérifier les informations et consulter les sources.",
+    "progress_label": "Génération des réponses"
   },
   "link_edit": {
     "edit_link": "Modifier lien",

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

@@ -521,7 +521,8 @@
     "title": "ナレッジアシスタント",
     "title_beta_label": "(ベータ)",
     "placeholder": "ききたいことを入力してください",
-    "caution_against_hallucination": "情報が正しいか出典を確認しましょう"
+    "caution_against_hallucination": "情報が正しいか出典を確認しましょう",
+    "progress_label": "回答を生成しています"
   },
   "link_edit": {
     "edit_link": "リンク編集",

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

@@ -477,7 +477,8 @@
     "title": "知识助手",
     "title_beta_label": "(测试版)",
     "placeholder": "问我任何问题。",
-    "caution_against_hallucination": "请核实信息并检查来源。"
+    "caution_against_hallucination": "请核实信息并检查来源。",
+    "progress_label": "生成答案中"
   },
   "link_edit": {
     "edit_link": "Edit Link",

+ 3 - 2
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.module.scss

@@ -2,7 +2,7 @@
 @use '@growi/core-styles/scss/variables/growi-official-colors';
 @use '@growi/ui/scss/atoms/btn-muted';
 
-.rag-search-modal :global {
+.grw-aichat-modal :global {
 
   .textarea-ask {
     max-height: 30vh;
@@ -15,7 +15,7 @@
 
 
 // == Colors
-.rag-search-modal :global {
+.grw-aichat-modal :global {
   .growi-ai-chat-icon {
     color: growi-official-colors.$growi-ai-purple;
   }
@@ -24,3 +24,4 @@
     @include btn-muted.colorize(bs.$purple, bs.$purple);
   }
 }
+

+ 37 - 25
apps/app/src/features/openai/chat/components/AiChatModal/AiChatModal.tsx

@@ -16,7 +16,7 @@ import { ResizableTextarea } from './ResizableTextArea';
 
 import styles from './AiChatModal.module.scss';
 
-const moduleClass = styles['rag-search-modal'];
+const moduleClass = styles['grw-aichat-modal'] ?? '';
 
 const logger = loggerFactory('growi:clinet:components:RagSearchModal');
 
@@ -43,7 +43,9 @@ const AiChatModalSubstance = (): JSX.Element => {
 
   const [threadId, setThreadId] = useState<string | undefined>();
   const [messageLogs, setMessageLogs] = useState<Message[]>([]);
-  const [lastMessage, setLastMessage] = useState<Message>();
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+
+  const isGenerating = generatingAnswerMessage != null;
 
   useEffect(() => {
     // do nothing when the modal is closed or threadId is already set
@@ -68,12 +70,31 @@ const AiChatModalSubstance = (): JSX.Element => {
   }, [threadId]);
 
   const submit = useCallback(async(data: FormData) => {
+    // do nothing when the assistant is generating an answer
+    if (isGenerating) {
+      return;
+    }
+
+    // do nothing when the input is empty
+    if (data.input.trim().length === 0) {
+      return;
+    }
+
     const { length: logLength } = messageLogs;
 
+    // add user message to the logs
+    const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
+    setMessageLogs(msgs => [...msgs, newUserMessage]);
+
+    // reset form
+    form.reset();
+
+    // add an empty assistant message
+    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
+    setGeneratingAnswerMessage(newAnswerMessage);
+
     // post message
     try {
-      form.clearErrors();
-
       const response = await fetch('/_api/v3/openai/message', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
@@ -87,20 +108,10 @@ const AiChatModalSubstance = (): JSX.Element => {
           const errors = resJson.errors.map(({ message }) => message).join(', ');
           form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
         }
+        setGeneratingAnswerMessage(undefined);
         return;
       }
 
-      // add user message to the logs
-      const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
-      setMessageLogs(msgs => [...msgs, newUserMessage]);
-
-      // reset form
-      form.reset();
-
-      // add assistant message
-      const newAssistantMessage = { id: (logLength + 1).toString(), content: '' };
-      setLastMessage(newAssistantMessage);
-
       const reader = response.body?.getReader();
       const decoder = new TextDecoder('utf-8');
 
@@ -111,9 +122,9 @@ const AiChatModalSubstance = (): JSX.Element => {
 
         // add assistant message to the logs
         if (done) {
-          setLastMessage((lastMessage) => {
-            if (lastMessage == null) return;
-            setMessageLogs(msgs => [...msgs, lastMessage]);
+          setGeneratingAnswerMessage((generatingAnswerMessage) => {
+            if (generatingAnswerMessage == null) return;
+            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
             return undefined;
           });
           return;
@@ -131,7 +142,7 @@ const AiChatModalSubstance = (): JSX.Element => {
           });
 
         // append text values to the assistant message
-        setLastMessage((prevMessage) => {
+        setGeneratingAnswerMessage((prevMessage) => {
           if (prevMessage == null) return;
           return {
             ...prevMessage,
@@ -148,7 +159,7 @@ const AiChatModalSubstance = (): JSX.Element => {
       form.setError('input', { type: 'manual', message: err.toString() });
     }
 
-  }, [form, messageLogs, threadId]);
+  }, [form, isGenerating, messageLogs, threadId]);
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
     if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
@@ -163,8 +174,8 @@ const AiChatModalSubstance = (): JSX.Element => {
           { messageLogs.map(message => (
             <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
           )) }
-          { lastMessage != null && (
-            <MessageCard role="assistant">{lastMessage.content}</MessageCard>
+          { generatingAnswerMessage != null && (
+            <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
           )}
           { messageLogs.length > 0 && (
             <div className="d-flex justify-content-center">
@@ -176,7 +187,7 @@ const AiChatModalSubstance = (): JSX.Element => {
         </div>
       </ModalBody>
 
-      <ModalFooter className="pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
+      <ModalFooter className="flex-column align-items-start pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
         <form onSubmit={form.handleSubmit(submit)} className="flex-fill hstack gap-2 align-items-end m-0">
           <Controller
             name="input"
@@ -188,15 +199,16 @@ const AiChatModalSubstance = (): JSX.Element => {
                 className="form-control textarea-ask"
                 style={{ resize: 'none' }}
                 rows={1}
-                placeholder={t('modal_aichat.placeholder')}
+                placeholder={!form.formState.isSubmitting ? t('modal_aichat.placeholder') : ''}
                 onKeyDown={keyDownHandler}
+                disabled={form.formState.isSubmitting}
               />
             )}
           />
           <button
             type="submit"
             className="btn btn-submit no-border"
-            disabled={form.formState.isSubmitting}
+            disabled={form.formState.isSubmitting || isGenerating}
           >
             <span className="material-symbols-outlined">send</span>
           </button>

+ 33 - 0
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.module.scss

@@ -21,6 +21,39 @@
   }
 }
 
+// text animation
+// refs: https://web.dev/articles/speedy-css-tip-animated-gradient-text?hl=ja
+.assistant-message-card :global {
+  .text-thinking {
+    --bg-size: 400%;
+    --color-one: var(--bs-tertiary-color);
+    --color-two: var(--grw-highlight-300);
+    color: transparent;
+    background: linear-gradient(
+                  -90deg,
+                  var(--color-one),
+                  var(--color-two),
+                  var(--color-one)
+                ) 0 0 / var(--bg-size) 100%;
+    -webkit-background-clip: text;
+    background-clip: text;
+  }
+
+  @media (prefers-reduced-motion: no-preference) {
+    .text-thinking {
+      &:local {
+        animation: move-bg 6s linear infinite;
+      }
+    }
+    @keyframes move-bg {
+      from {
+        background-position: var(--bg-size) 0;
+      }
+    }
+  }
+}
+
+
  /*******************
  * UserMessageCard
  *******************/

+ 28 - 14
apps/app/src/features/openai/chat/components/AiChatModal/MessageCard.tsx

@@ -1,3 +1,4 @@
+import { useTranslation } from 'react-i18next';
 import ReactMarkdown from 'react-markdown';
 
 import styles from './MessageCard.module.scss';
@@ -7,35 +8,48 @@ const moduleClass = styles['message-card'] ?? '';
 
 const userMessageCardModuleClass = styles['user-message-card'] ?? '';
 
-const UserMessageCard = ({ children }: { children?: string }): JSX.Element => (
+const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
   <div className={`card d-inline-flex align-self-end bg-success-subtle bg-info-subtle ${moduleClass} ${userMessageCardModuleClass}`}>
-    { children != null && children.length > 0 && (
-      <div className="card-body">
-        <ReactMarkdown>{children}</ReactMarkdown>
-      </div>
-    ) }
+    <div className="card-body">
+      <ReactMarkdown>{children}</ReactMarkdown>
+    </div>
   </div>
 );
 
 
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
-const AssistantMessageCard = ({ children }: { children?: string }): JSX.Element => (
-  <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
-    { children != null && children.length > 0 && (
+const AssistantMessageCard = ({ children }: { children: string }): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  return (
+    <div className={`card border-0 ${moduleClass} ${assistantMessageCardModuleClass}`}>
       <div className="card-body d-flex">
         <div className="me-2 me-lg-3">
           <span className="material-symbols-outlined grw-ai-icon rounded-pill p-1">psychology</span>
         </div>
-        <ReactMarkdown>{children}</ReactMarkdown>
+
+        <div className="mt-1">
+          { children.length > 0
+            ? (
+              <ReactMarkdown>{children}</ReactMarkdown>
+            )
+            : (
+              <span className="text-thinking">
+                {t('modal_aichat.progress_label')} <span className="material-symbols-outlined">more_horiz</span>
+              </span>
+            )
+          }
+        </div>
       </div>
-    ) }
-  </div>
-);
+    </div>
+  );
+};
 
 type Props = {
   role: 'user' | 'assistant',
-  children?: string,
+  children: string,
 }
 
 export const MessageCard = (props: Props): JSX.Element => {

+ 5 - 1
apps/app/src/server/routes/apiv3/openai/message.ts

@@ -31,7 +31,11 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
   const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
 
   const validator: ValidationChain[] = [
-    body('userMessage').isString().withMessage('userMessage must be string'),
+    body('userMessage')
+      .isString()
+      .withMessage('userMessage must be string')
+      .notEmpty()
+      .withMessage('userMessage must be set'),
     body('threadId').isString().withMessage('threadId must be string'),
   ];