AiChatModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import type { KeyboardEvent } from 'react';
  2. import React, { useCallback, useEffect, useState } from 'react';
  3. import { useForm, Controller } from 'react-hook-form';
  4. import { useTranslation } from 'react-i18next';
  5. import {
  6. Collapse,
  7. Modal, ModalBody, ModalFooter, ModalHeader,
  8. UncontrolledTooltip,
  9. } from 'reactstrap';
  10. import { apiv3Post } from '~/client/util/apiv3-client';
  11. import { toastError } from '~/client/util/toastr';
  12. import { useGrowiCloudUri } from '~/stores-universal/context';
  13. import loggerFactory from '~/utils/logger';
  14. import { useRagSearchModal } from '../../../client/stores/rag-search';
  15. import { MessageErrorCode, StreamErrorCode } from '../../../interfaces/message-error';
  16. import { MessageCard } from './MessageCard';
  17. import { ResizableTextarea } from './ResizableTextArea';
  18. import styles from './AiChatModal.module.scss';
  19. const moduleClass = styles['grw-aichat-modal'] ?? '';
  20. const logger = loggerFactory('growi:clinet:components:RagSearchModal');
  21. type Message = {
  22. id: string,
  23. content: string,
  24. isUserMessage?: boolean,
  25. }
  26. type FormData = {
  27. input: string;
  28. summaryMode?: boolean;
  29. };
  30. const AiChatModalSubstance = (): JSX.Element => {
  31. const { t } = useTranslation();
  32. const form = useForm<FormData>({
  33. defaultValues: {
  34. input: '',
  35. summaryMode: true,
  36. },
  37. });
  38. const [threadId, setThreadId] = useState<string | undefined>();
  39. const [messageLogs, setMessageLogs] = useState<Message[]>([]);
  40. const [generatingAnswerMessage, setGeneratingAnswerMessage] = useState<Message>();
  41. const [errorMessage, setErrorMessage] = useState<string | undefined>();
  42. const [isErrorDetailCollapsed, setIsErrorDetailCollapsed] = useState<boolean>(false);
  43. const { data: growiCloudUri } = useGrowiCloudUri();
  44. const isGenerating = generatingAnswerMessage != null;
  45. const submit = useCallback(async(data: FormData) => {
  46. // do nothing when the assistant is generating an answer
  47. if (isGenerating) {
  48. return;
  49. }
  50. // do nothing when the input is empty
  51. if (data.input.trim().length === 0) {
  52. return;
  53. }
  54. const { length: logLength } = messageLogs;
  55. // add user message to the logs
  56. const newUserMessage = { id: logLength.toString(), content: data.input, isUserMessage: true };
  57. setMessageLogs(msgs => [...msgs, newUserMessage]);
  58. // reset form
  59. form.reset({ input: '', summaryMode: data.summaryMode });
  60. setErrorMessage(undefined);
  61. // add an empty assistant message
  62. const newAnswerMessage = { id: (logLength + 1).toString(), content: '' };
  63. setGeneratingAnswerMessage(newAnswerMessage);
  64. // create thread
  65. let currentThreadId = threadId;
  66. if (threadId == null) {
  67. try {
  68. const res = await apiv3Post('/openai/thread');
  69. const thread = res.data.thread;
  70. setThreadId(thread.id);
  71. currentThreadId = thread.id;
  72. }
  73. catch (err) {
  74. logger.error(err.toString());
  75. toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
  76. }
  77. }
  78. // post message
  79. try {
  80. const response = await fetch('/_api/v3/openai/message', {
  81. method: 'POST',
  82. headers: { 'Content-Type': 'application/json' },
  83. body: JSON.stringify({ userMessage: data.input, threadId: currentThreadId, summaryMode: data.summaryMode }),
  84. });
  85. if (!response.ok) {
  86. const resJson = await response.json();
  87. if ('errors' in resJson) {
  88. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  89. const errors = resJson.errors.map(({ message }) => message).join(', ');
  90. form.setError('input', { type: 'manual', message: `[${response.status}] ${errors}` });
  91. const hasThreadIdNotSetError = resJson.errors.some(err => err.code === MessageErrorCode.THREAD_ID_IS_NOT_SET);
  92. if (hasThreadIdNotSetError) {
  93. toastError(t('modal_aichat.failed_to_create_or_retrieve_thread'));
  94. }
  95. }
  96. setGeneratingAnswerMessage(undefined);
  97. return;
  98. }
  99. const reader = response.body?.getReader();
  100. const decoder = new TextDecoder('utf-8');
  101. const read = async() => {
  102. if (reader == null) return;
  103. const { done, value } = await reader.read();
  104. // add assistant message to the logs
  105. if (done) {
  106. setGeneratingAnswerMessage((generatingAnswerMessage) => {
  107. if (generatingAnswerMessage == null) return;
  108. setMessageLogs(msgs => [...msgs, generatingAnswerMessage]);
  109. return undefined;
  110. });
  111. return;
  112. }
  113. const chunk = decoder.decode(value);
  114. const textValues: string[] = [];
  115. const lines = chunk.split('\n\n');
  116. lines.forEach((line) => {
  117. const trimedLine = line.trim();
  118. if (trimedLine.startsWith('data:')) {
  119. const data = JSON.parse(line.replace('data: ', ''));
  120. textValues.push(data.content[0].text.value);
  121. }
  122. else if (trimedLine.startsWith('error:')) {
  123. const error = JSON.parse(line.replace('error: ', ''));
  124. logger.error(error.errorMessage);
  125. form.setError('input', { type: 'manual', message: error.message });
  126. if (error.code === StreamErrorCode.BUDGET_EXCEEDED) {
  127. setErrorMessage(growiCloudUri != null ? 'modal_aichat.budget_exceeded_for_growi_cloud' : 'modal_aichat.budget_exceeded');
  128. }
  129. }
  130. });
  131. // append text values to the assistant message
  132. setGeneratingAnswerMessage((prevMessage) => {
  133. if (prevMessage == null) return;
  134. return {
  135. ...prevMessage,
  136. content: prevMessage.content + textValues.join(''),
  137. };
  138. });
  139. read();
  140. };
  141. read();
  142. }
  143. catch (err) {
  144. logger.error(err.toString());
  145. form.setError('input', { type: 'manual', message: err.toString() });
  146. }
  147. }, [form, growiCloudUri, isGenerating, messageLogs, t, threadId]);
  148. const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
  149. if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
  150. form.handleSubmit(submit)();
  151. }
  152. };
  153. return (
  154. <>
  155. <ModalBody className="pb-0 pt-3 pt-lg-4 px-3 px-lg-4">
  156. <div className="vstack gap-4 pb-4">
  157. { messageLogs.map(message => (
  158. <MessageCard key={message.id} role={message.isUserMessage ? 'user' : 'assistant'}>{message.content}</MessageCard>
  159. )) }
  160. { generatingAnswerMessage != null && (
  161. <MessageCard role="assistant">{generatingAnswerMessage.content}</MessageCard>
  162. )}
  163. { messageLogs.length > 0 && (
  164. <div className="d-flex justify-content-center">
  165. <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
  166. {t('modal_aichat.caution_against_hallucination')}
  167. </span>
  168. </div>
  169. )}
  170. </div>
  171. </ModalBody>
  172. <ModalFooter className="flex-column align-items-start pt-0 pb-3 pb-lg-4 px-3 px-lg-4">
  173. <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-3">
  174. <div className="flex-fill hstack gap-2 align-items-end m-0">
  175. <Controller
  176. name="input"
  177. control={form.control}
  178. render={({ field }) => (
  179. <ResizableTextarea
  180. {...field}
  181. required
  182. className="form-control textarea-ask"
  183. style={{ resize: 'none' }}
  184. rows={1}
  185. placeholder={!form.formState.isSubmitting ? t('modal_aichat.placeholder') : ''}
  186. onKeyDown={keyDownHandler}
  187. disabled={form.formState.isSubmitting}
  188. />
  189. )}
  190. />
  191. <button
  192. type="submit"
  193. className="btn btn-submit no-border"
  194. disabled={form.formState.isSubmitting || isGenerating}
  195. >
  196. <span className="material-symbols-outlined">send</span>
  197. </button>
  198. </div>
  199. <div className="form-check form-switch">
  200. <input
  201. id="swSummaryMode"
  202. type="checkbox"
  203. role="switch"
  204. className="form-check-input"
  205. {...form.register('summaryMode')}
  206. disabled={form.formState.isSubmitting || isGenerating}
  207. />
  208. <label className="form-check-label" htmlFor="swSummaryMode">
  209. {t('modal_aichat.summary_mode_label')}
  210. </label>
  211. {/* Help */}
  212. <a
  213. id="tooltipForHelpOfSummaryMode"
  214. role="button"
  215. className="ms-1"
  216. >
  217. <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
  218. </a>
  219. <UncontrolledTooltip
  220. target="tooltipForHelpOfSummaryMode"
  221. >
  222. {t('modal_aichat.summary_mode_help')}
  223. </UncontrolledTooltip>
  224. </div>
  225. </form>
  226. {form.formState.errors.input != null && (
  227. <div className="mt-4 bg-danger bg-opacity-10 rounded-3 p-2 w-100">
  228. <div>
  229. <span className="material-symbols-outlined text-danger me-2">error</span>
  230. <span className="text-danger">{ errorMessage != null ? t(errorMessage) : t('modal_aichat.error_message') }</span>
  231. </div>
  232. <button
  233. type="button"
  234. className="btn btn-link text-secondary p-0"
  235. aria-expanded={isErrorDetailCollapsed}
  236. onClick={() => setIsErrorDetailCollapsed(!isErrorDetailCollapsed)}
  237. >
  238. <span className={`material-symbols-outlined mt-2 me-1 ${isErrorDetailCollapsed ? 'rotate-90' : ''}`}>
  239. chevron_right
  240. </span>
  241. <span className="small">{t('modal_aichat.show_error_detail')}</span>
  242. </button>
  243. <Collapse isOpen={isErrorDetailCollapsed}>
  244. <div className="ms-2">
  245. <div className="">
  246. <div className="text-secondary small">
  247. {form.formState.errors.input?.message}
  248. </div>
  249. </div>
  250. </div>
  251. </Collapse>
  252. </div>
  253. )}
  254. </ModalFooter>
  255. </>
  256. );
  257. };
  258. export const AiChatModal = (): JSX.Element => {
  259. const { t } = useTranslation();
  260. const { data: ragSearchModalData, close: closeRagSearchModal } = useRagSearchModal();
  261. const isOpened = ragSearchModalData?.isOpened ?? false;
  262. return (
  263. <Modal size="lg" isOpen={isOpened} toggle={closeRagSearchModal} className={moduleClass} scrollable>
  264. <ModalHeader tag="h4" toggle={closeRagSearchModal} className="pe-4">
  265. <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">knowledge_assistant</span>
  266. <span className="fw-bold">{t('modal_aichat.title')}</span>
  267. <span className="fs-5 text-body-secondary ms-3">{t('modal_aichat.title_beta_label')}</span>
  268. </ModalHeader>
  269. { isOpened && (
  270. <AiChatModalSubstance />
  271. ) }
  272. </Modal>
  273. );
  274. };