Browse Source

Use AiChatModal logic

Shun Miyazawa 1 year ago
parent
commit
b49ff1950f

+ 334 - 42
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantChatSidebar/AiAssistantChatSidebar.tsx

@@ -1,73 +1,365 @@
+import type { KeyboardEvent } from 'react';
 import {
-  type FC, memo, useRef, useEffect,
+  type FC, memo, useRef, useEffect, useState, useCallback,
 } from 'react';
 
+import { useForm, Controller } from 'react-hook-form';
+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 { MessageCard } from '~/features/openai/chat/components/AiChatModal/MessageCard';
+import { ResizableTextarea } from '~/features/openai/chat/components/AiChatModal/ResizableTextArea';
+import { MessageErrorCode, StreamErrorCode } from '~/features/openai/interfaces/message-error';
+import { useGrowiCloudUri } from '~/stores-universal/context';
+import loggerFactory from '~/utils/logger';
+
 import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
 import { useAiAssistantChatSidebar } from '../../../stores/ai-assistant';
 
 import styles from './AiAssistantChatSidebar.module.scss';
 
+const logger = loggerFactory('growi:openai:client:components:AiAssistantChatSidebar');
+
 const moduleClass = styles['grw-ai-assistant-chat-sidebar'] ?? '';
 
 const RIGHT_SIDEBAR_WIDTH = 500;
 
+type Message = {
+  id: string,
+  content: string,
+  isUserMessage?: boolean,
+}
+
+type FormData = {
+  input: string;
+  summaryMode?: boolean;
+};
+
+
 type AiAssistantChatSidebarSubstanceProps = {
   aiAssistantData?: AiAssistantHasId;
   closeAiAssistantChatSidebar: () => void
 }
 
 const AiAssistantChatSidebarSubstance: React.FC<AiAssistantChatSidebarSubstanceProps> = (props: AiAssistantChatSidebarSubstanceProps) => {
+  const { t } = useTranslation();
+
   const { aiAssistantData, closeAiAssistantChatSidebar } = props;
 
+  const form = useForm<FormData>({
+    defaultValues: {
+      input: '',
+      summaryMode: true,
+    },
+  });
+
+  const [threadId, setThreadId] = useState<string | undefined>();
+  const [messageLogs, setMessageLogs] = useState<Message[]>([]);
+  const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
+  const [errorMessage, setErrorMessage] = useState<string | undefined>();
+  const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
+
+  const { data: growiCloudUri } = useGrowiCloudUri();
+
+  const isGenerating = generatingAnswerMessage != null;
+
+  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({ input: '', summaryMode: data.summaryMode });
+    setErrorMessage(undefined);
+
+    // add an empty assistant message
+    const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
+    setGeneratingAnswerMessage(newAnswerMessage);
+
+    // create thread
+    let currentThreadId = threadId;
+    if (threadId == null) {
+      try {
+        const res = await apiv3Post('/openai/thread');
+        const thread = res.data.thread;
+
+        setThreadId(thread.id);
+        currentThreadId = thread.id;
+      }
+      catch (err) {
+        logger.error(err.toString());
+        toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
+      }
+    }
+
+    // post message
+    try {
+      const response = await fetch('/_api/v3/openai/message', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ userMessage: data.input, threadId: currentThreadId, summaryMode: data.summaryMode }),
+      });
+
+      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}` });
+
+          const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
+          if (hasThreadIdNotSetError) {
+            toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
+          }
+        }
+        setGeneratingAnswerMessage(undefined);
+        return;
+      }
+
+      const reader = response.body?.getReader();
+      const decoder = new TextDecoder('utf-8');
+
+      const read = async() => {
+        if (reader == null) return;
+
+        const { done, value } = await reader.read();
+
+        // add assistant message to the logs
+        if (done) {
+          setGeneratingAnswerMessage((generatingAnswerMessage) => {
+            if (generatingAnswerMessage == null) return;
+            setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
+            return undefined;
+          });
+          return;
+        }
+
+        const chunk = decoder.decode(value);
+
+        const textValues: string[] = [];
+        const lines = chunk.split('\n\n');
+        lines.forEach((line) => {
+          const trimedLine = line.trim();
+          if (trimedLine.startsWith('data:')) {
+            const data = JSON.parse(line.replace('data: ', ''));
+            textValues.push(data.content[0].text.value);
+          }
+          else if (trimedLine.startsWith('error:')) {
+            const error = JSON.parse(line.replace('error: ', ''));
+            logger.error(error.errorMessage);
+            form.setError('input', { type: 'manual', message: error.message });
+
+            if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
+              setErrorMessage(growiCloudUri != null ? 'modal_aichat.budget_exceeded_for_growi_cloud' : 'modal_aichat.budget_exceeded');
+            }
+          }
+        });
+
+
+        // append text values to the assistant message
+        setGeneratingAnswerMessage((prevMessage) => {
+          if (prevMessage == null) return;
+          return {
+            ...prevMessage,
+            content: prevMessage.content + textValues.join(''),
+          };
+        });
+
+        read();
+      };
+      read();
+    }
+    catch (err) {
+      logger.error(err.toString());
+      form.setError('input', { type: 'manual', message: err.toString() });
+    }
+
+  }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
+
+  const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
+    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+      form.handleSubmit(submit)();
+    }
+  };
+
+
   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-4 d-flex flex-column gap-4">
-        <p className="fs-6 text-secondary mb-0">
-          {aiAssistantData?.description}
-        </p>
-
-        <div>
-          <p className="text-secondary">アシスタントへの指示</p>
-          <div className="card bg-light border-0">
-            <div className="card-body p-3">
-              <p className="fs-6 text-secondary mb-0">
-                {aiAssistantData?.additionalInstruction}
-              </p>
-            </div>
-          </div>
+      <div className="d-flex flex-column vh-100">
+        <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-4 d-flex flex-column gap-4 vh-100">
+
+
+          { threadId != null
+            ? (
+              <div className="vstack gap-4 pb-2">
+                { messageLogs.map(message => (
+                  <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
+                )) }
+                { generatingAnswerMessage != null && (
+                  <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
+                )}
+                { messageLogs.length > 0 && (
+                  <div className="d-flex justify-content-center">
+                    <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
+                      {t('modal_aichat.caution_against_hallucination')}
+                    </span>
+                  </div>
+                )}
+              </div>
+            )
+            : (
+              <>
+                <p className="fs-6 text-secondary mb-0">
+                  {aiAssistantData?.description}
+                </p>
+
+                <div>
+                  <p className="text-secondary">アシスタントへの指示</p>
+                  <div className="card bg-light border-0">
+                    <div className="card-body p-3">
+                      <p className="fs-6 text-secondary mb-0">
+                        {aiAssistantData?.additionalInstruction}
+                      </p>
+                    </div>
+                  </div>
+                </div>
+
+                <div>
+                  <div className="d-flex align-items-center">
+                    <p className="text-secondary mb-0">参照するページ</p>
+                  </div>
+                  <div className="d-flex flex-column gap-1">
+                    { aiAssistantData?.pagePathPatterns.map(pagePathPattern => (
+                      <a
+                        key={pagePathPattern}
+                        href="#"
+                        className="fs-6 text-secondary text-decoration-none"
+                      >
+                        {pagePathPattern}
+                      </a>
+                    ))}
+                  </div>
+                </div>
+
+              </>
+            )
+          }
+
+          <div className="mt-auto">
+            <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
+              <div className="flex-fill hstack gap-2 align-items-end m-0">
+                <Controller
+                  name="input"
+                  control={form.control}
+                  render={({ field }) => (
+                    <ResizableTextarea
+                      {...field}
+                      required
+                      className="form-control textarea-ask"
+                      style={{ resize: 'none' }}
+                      rows={1}
+                      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 || isGenerating}
+                >
+                  <span className="material-symbols-outlined">send</span>
+                </button>
+              </div>
+              <div className="form-check form-switch">
+                <input
+                  id="swSummaryMode"
+                  type="checkbox"
+                  role="switch"
+                  className="form-check-input"
+                  {...form.register('summaryMode')}
+                  disabled={form.formState.isSubmitting || isGenerating}
+                />
+                <label className="form-check-label" htmlFor="swSummaryMode">
+                  {t('modal_aichat.summary_mode_label')}
+                </label>
+
+                {/* Help */}
+                <a
+                  id="tooltipForHelpOfSummaryMode"
+                  role="button"
+                  className="ms-1"
+                >
+                  <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
+                </a>
+                <UncontrolledTooltip
+                  target="tooltipForHelpOfSummaryMode"
+                >
+                  {t('modal_aichat.summary_mode_help')}
+                </UncontrolledTooltip>
+              </div>
+            </form>
+
+            {form.formState.errors.input != null && (
+              <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
+                <div>
+                  <span className="material-symbols-outlined text-danger me-2">error</span>
+                  <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('modal_aichat.error_message') }</span>
+                </div>
+
+                <button
+                  type="button"
+                  className="btn btn-link text-secondary p-0"
+                  aria-expanded={isErrorDetailCollapsed}
+                  onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
+                >
+                  <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
+                    chevron_right
+                  </span>
+                  <span className="small">{t('modal_aichat.show_error_detail')}</span>
+                </button>
+
+                <Collapse isOpen={isErrorDetailCollapsed}>
+                  <div className="ms-2">
+                    <div className="">
+                      <div className="text-secondary small">
+                        {form.formState.errors.input?.message}
+                      </div>
+                    </div>
+                  </div>
+                </Collapse>
+              </div>
+            )}
 
-        <div>
-          <div className="d-flex align-items-center">
-            <p className="text-secondary mb-0">参照するページ</p>
-          </div>
-          <div className="d-flex flex-column gap-1">
-            { aiAssistantData?.pagePathPatterns.map(pagePathPattern => (
-              <a
-                key={pagePathPattern}
-                href="#"
-                className="fs-6 text-secondary text-decoration-none"
-              >
-                {pagePathPattern}
-              </a>
-            ))}
           </div>
         </div>
-
       </div>
     </>
   );