Yuki Takei 1 год назад
Родитель
Сommit
0d9f96cd5a

+ 21 - 0
apps/app/src/client/components/RagSearch/RagSearchModal.module.scss

@@ -0,0 +1,21 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+@use '@growi/ui/scss/atoms/btn-muted';
+
+.rag-search-modal :global {
+
+  .textarea-ask {
+    max-height: 30vh;
+  }
+
+  .btn-submit {
+    font-size: 1.1em;
+  }
+}
+
+
+// == Colors
+.rag-search-modal :global {
+  .btn-submit {
+    @include btn-muted.colorize(bs.$purple, bs.$purple);
+  }
+}

+ 94 - 54
apps/app/src/client/components/RagSearch/RagSearchModal.tsx

@@ -1,14 +1,20 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useEffect, useState } from 'react';
 
 
+import { useForm } from 'react-hook-form';
 import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { useRagSearchModal } from '~/stores/rag-search';
 import { useRagSearchModal } from '~/stores/rag-search';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { AutoResizeTextarea } from './AutoResizeTextarea';
 import { MessageCard } from './MessageCard';
 import { MessageCard } from './MessageCard';
 
 
 
 
+import styles from './RagSearchModal.module.scss';
+
+const moduleClass = styles['rag-search-modal'];
+
 const logger = loggerFactory('growi:clinet:components:RagSearchModal');
 const logger = loggerFactory('growi:clinet:components:RagSearchModal');
 
 
 
 
@@ -18,12 +24,21 @@ type Message = {
   isUserMessage?: boolean,
   isUserMessage?: boolean,
 }
 }
 
 
+type FormData = {
+  input: string;
+};
+
 const RagSearchModal = (): JSX.Element => {
 const RagSearchModal = (): JSX.Element => {
 
 
-  const [input, setInput] = useState('');
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+    },
+  });
 
 
   const [threadId, setThreadId] = useState<string | undefined>();
   const [threadId, setThreadId] = useState<string | undefined>();
-  const [messages, setMessages] = useState<Message[]>([]);
+  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
+  const [lastMessage, setLastMessage] = useState<Message>();
 
 
   const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
   const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
 
 
@@ -56,28 +71,52 @@ const RagSearchModal = (): JSX.Element => {
     createThread();
     createThread();
   }, [isOpened, threadId]);
   }, [isOpened, threadId]);
 
 
-  const onClickSubmitUserMessageHandler = useCallback(async() => {
-    const newUserMessage = { id: messages.length.toString(), content: input, isUserMessage: true };
-    setMessages(msgs => [...msgs, newUserMessage]);
-
-    setInput('');
+  const clickSubmitUserMessageHandler = form.handleSubmit(async(data) => {
+    const { length: logLength } = messageLogs;
 
 
     // post message
     // post message
     try {
     try {
-      const res = await fetch('/_api/v3/openai/message', {
+      form.clearErrors();
+
+      const response = await fetch('/_api/v3/openai/message', {
         method: 'POST',
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ userMessage: input, threadId }),
+        body: JSON.stringify({ userMessage: data.input, threadId }),
       });
       });
 
 
-      const reader = res.body?.getReader();
+      if (!response.ok) {
+        const resJson = await response.json();
+        if ('errors' in resJson) {
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          const errors = resJson.errors.map(({ message }) => message).join(', ');
+          form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
+        }
+        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');
       const decoder = new TextDecoder('utf-8');
 
 
       const read = async() => {
       const read = async() => {
         if (reader == null) return;
         if (reader == null) return;
 
 
         const { done, value } = await reader.read();
         const { done, value } = await reader.read();
+
+        // add assistant message to the logs
         if (done) {
         if (done) {
+          setMessageLogs(msgs => [...msgs, newAssistantMessage]);
+          setLastMessage(undefined);
           return;
           return;
         }
         }
 
 
@@ -92,64 +131,65 @@ const RagSearchModal = (): JSX.Element => {
             return data.content[0].text.value;
             return data.content[0].text.value;
           });
           });
 
 
-        console.log(textValues.join(''));
+        // append text values to the assistant message
+        newAssistantMessage.content += textValues.join('');
+        setLastMessage(newAssistantMessage);
 
 
         read();
         read();
       };
       };
       read();
       read();
-
-      // const res = await apiv3Post('/openai/message', { userMessage: input, threadId });
-
-      // if (res.data) {
-      //   const newMessages: Message[] = assistantMessageData.data.reverse()
-      //     .map((message: any) => {
-      //       return {
-      //         id: message.id,
-      //         content: message.content[0].text.value,
-      //       };
-      //     });
-
-      //   setMessages(msgs => [...msgs, ...newMessages]);
-      //   setThreadId(assistantMessageData.data[0].threadId);
-      // }
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err.toString());
       logger.error(err.toString());
+      form.setError('input', { type: 'manual', message: err.toString() });
     }
     }
-  }, [input, messages.length, threadId]);
 
 
-  return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRagSearchModal} data-testid="search-modal">
-      <ModalBody>
-        <ModalHeader tag="h4" className="mb-3 p-0">
-          <span className="material-symbols-outlined me-2 text-primary">psychology</span>
-          GROWI Assistant
-        </ModalHeader>
+  });
 
 
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeRagSearchModal} className={moduleClass}>
+      <ModalHeader tag="h4" toggle={closeRagSearchModal} className="pe-4">
+        <span className="material-symbols-outlined text-primary">psychology</span>
+        GROWI Assistant
+      </ModalHeader>
+      <ModalBody className="px-lg-5 py-4">
         <div className="vstack gap-4">
         <div className="vstack gap-4">
-          { messages.map(message => (
+          { messageLogs.map(message => (
             <MessageCard key={message.id} right={message.isUserMessage}>{message.content}</MessageCard>
             <MessageCard key={message.id} right={message.isUserMessage}>{message.content}</MessageCard>
           )) }
           )) }
+          { lastMessage != null && (
+            <MessageCard>{lastMessage.content}</MessageCard>
+          )}
         </div>
         </div>
 
 
-        <div className="input-group mt-5">
-          <input
-            type="text"
-            className="form-control"
-            placeholder="お手伝いできることはありますか?"
-            aria-label="Recipient's username"
-            aria-describedby="button-addon2"
-            value={input}
-            onChange={e => setInput(e.target.value)}
-          />
-          <button
-            type="button"
-            id="button-addon2"
-            className="btn btn-outline-secondary"
-            onClick={onClickSubmitUserMessageHandler}
-          >
-            <span className="material-symbols-outlined">arrow_upward</span>
-          </button>
+        <div>
+          <form onSubmit={clickSubmitUserMessageHandler} className="hstack gap-2 align-items-end mt-4">
+            <textarea
+              {...form.register('input')}
+              required
+              className="form-control textarea-ask"
+              style={{ resize: 'none' }}
+              rows={1}
+              // auto resize
+              // refs: https://zenn.dev/soma3134/articles/1e2fb0eab75b2d
+              onChange={(e) => {
+                e.target.style.height = 'auto';
+                e.target.style.height = `${e.target.scrollHeight + 4}px`;
+              }}
+              placeholder="ききたいことを入力してください"
+            />
+            <button
+              type="submit"
+              className="btn btn-submit no-border"
+              disabled={form.formState.isSubmitting}
+            >
+              <span className="material-symbols-outlined">send</span>
+            </button>
+          </form>
+
+          {form.formState.errors.input != null && (
+            <span className="text-danger small">{form.formState.errors.input?.message}</span>
+          )}
         </div>
         </div>
       </ModalBody>
       </ModalBody>
     </Modal>
     </Modal>