import type { KeyboardEvent } from 'react'; import React, { useCallback, useEffect, useState } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Modal, ModalBody, ModalFooter, ModalHeader, } from 'reactstrap'; import { apiv3Post } from '~/client/util/apiv3-client'; import { toastError } from '~/client/util/toastr'; import loggerFactory from '~/utils/logger'; import { useRagSearchModal } from '../../../client/stores/rag-search'; import { MessageErrorCode } from '../../../interfaces/message-error'; import { MessageCard } from './MessageCard'; import { ResizableTextarea } from './ResizableTextArea'; import styles from './AiChatModal.module.scss'; const moduleClass = styles['grw-aichat-modal'] ?? ''; const logger = loggerFactory('growi:clinet:components:RagSearchModal'); type Message = { id: string, content: string, isUserMessage?: boolean, } type FormData = { input: string; }; const AiChatModalSubstance = (): JSX.Element => { const { t } = useTranslation(); const form = useForm({ defaultValues: { input: '', }, }); const [threadId, setThreadId] = useState(); const [messageLogs, setMessageLogs] = useState([]); const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState(); const isGenerating = generatingAnswerMessage != null; useEffect(() => { // do nothing when the modal is closed or threadId is already set if (threadId != null) { return; } const createThread = async() => { // create thread try { const res = await apiv3Post('/openai/thread'); const thread = res.data.thread; setThreadId(thread.id); } catch (err) { logger.error(err.toString()); toastError(t('modal_aichat.failed_to_create_or_retrieve_thread')); } }; createThread(); }, [t, 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 { const response = await fetch('/_api/v3/openai/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userMessage: data.input, threadId }), }); 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); // Extract text values from the chunk const textValues = chunk .split('\n\n') .filter(line => line.trim().startsWith('data:')) .map((line) => { const data = JSON.parse(line.replace('data: ', '')); return data.content[0].text.value; }); // 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, isGenerating, messageLogs, t, threadId]); const keyDownHandler = (event: KeyboardEvent) => { if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { form.handleSubmit(submit)(); } }; return ( <>
{ messageLogs.map(message => ( {message.content} )) } { generatingAnswerMessage != null && ( {generatingAnswerMessage.content} )} { messageLogs.length > 0 && (
{t('modal_aichat.caution_against_hallucination')}
)}
( )} /> {form.formState.errors.input != null && ( {form.formState.errors.input?.message} )}
); }; export const AiChatModal = (): JSX.Element => { const { t } = useTranslation(); const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal(); const isOpened = ragSearchModalData?.isOpened ?? false; return ( knowledge_assistant {t('modal_aichat.title')} {t('modal_aichat.title_beta_label')} { isOpened && ( ) } ); };