knowledge-assistant.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. import type { Dispatch, SetStateAction } from 'react';
  2. import {
  3. useCallback, useMemo, useState, useEffect,
  4. } from 'react';
  5. import { useForm, type UseFormReturn } from 'react-hook-form';
  6. import { useTranslation } from 'react-i18next';
  7. import {
  8. UncontrolledTooltip, Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
  9. } from 'reactstrap';
  10. import { apiv3Post } from '~/client/util/apiv3-client';
  11. import {
  12. SseMessageSchema, type SseMessage, SsePreMessageSchema, type SsePreMessage,
  13. } from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
  14. import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
  15. import type { MessageLog, MessageWithCustomMetaData } from '../../interfaces/message';
  16. import type { IThreadRelationHasId } from '../../interfaces/thread-relation';
  17. import { ThreadType } from '../../interfaces/thread-relation';
  18. import { AiAssistantChatInitialView } from '../components/AiAssistant/AiAssistantSidebar/AiAssistantChatInitialView';
  19. import { useAiAssistantSidebar } from '../stores/ai-assistant';
  20. import { useSWRMUTxMessages } from '../stores/message';
  21. import { useSWRMUTxThreads, useSWRINFxRecentThreads } from '../stores/thread';
  22. interface CreateThread {
  23. (aiAssistantId: string, initialUserMessage: string): Promise<IThreadRelationHasId>;
  24. }
  25. type PostMessageArgs = {
  26. aiAssistantId: string;
  27. threadId: string;
  28. formData: FormData;
  29. };
  30. interface PostMessage {
  31. (args: PostMessageArgs): Promise<Response>;
  32. }
  33. interface ProcessMessage {
  34. (data: unknown, handler: {
  35. onMessage: (data: SseMessage) => void
  36. onPreMessage: (data: SsePreMessage) => void
  37. }
  38. ): void;
  39. }
  40. export interface FormData {
  41. input: string
  42. summaryMode?: boolean
  43. extendedThinkingMode?: boolean
  44. }
  45. interface GenerateModeSwitchesDropdown {
  46. (isGenerating: boolean): JSX.Element
  47. }
  48. type UseKnowledgeAssistant = () => {
  49. createThread: CreateThread
  50. postMessage: PostMessage
  51. processMessage: ProcessMessage
  52. form: UseFormReturn<FormData>
  53. resetForm: () => void
  54. handleBackToInitialView: () => void;
  55. threadTitleView: JSX.Element | null;
  56. // Views
  57. initialView: JSX.Element
  58. generateModeSwitchesDropdown: GenerateModeSwitchesDropdown
  59. headerIcon: JSX.Element
  60. headerText: JSX.Element
  61. placeHolder: string
  62. }
  63. export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
  64. // Hooks
  65. const { data: aiAssistantSidebarData, refreshThreadData } = useAiAssistantSidebar();
  66. const { aiAssistantData } = aiAssistantSidebarData ?? {};
  67. const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
  68. const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
  69. const { t } = useTranslation();
  70. const form = useForm<FormData>({
  71. defaultValues: {
  72. input: '',
  73. summaryMode: true,
  74. extendedThinkingMode: false,
  75. },
  76. });
  77. const handleBackToInitialView = useCallback(() => {
  78. refreshThreadData(undefined);
  79. }, [refreshThreadData]);
  80. // Functions
  81. const resetForm = useCallback(() => {
  82. const summaryMode = form.getValues('summaryMode');
  83. const extendedThinkingMode = form.getValues('extendedThinkingMode');
  84. form.reset({ input: '', summaryMode, extendedThinkingMode });
  85. }, [form]);
  86. const createThread: CreateThread = useCallback(async(aiAssistantId, initialUserMessage) => {
  87. const response = await apiv3Post<IThreadRelationHasId>('/openai/thread', {
  88. type: ThreadType.KNOWLEDGE,
  89. aiAssistantId,
  90. initialUserMessage,
  91. });
  92. const thread = response.data;
  93. // No need to await because data is not used
  94. mutateThreadData();
  95. return thread;
  96. }, [mutateThreadData]);
  97. const postMessage: PostMessage = useCallback(async({ aiAssistantId, threadId, formData }) => {
  98. const response = await fetch('/_api/v3/openai/message', {
  99. method: 'POST',
  100. headers: { 'Content-Type': 'application/json' },
  101. body: JSON.stringify({
  102. aiAssistantId,
  103. threadId,
  104. userMessage: formData.input,
  105. summaryMode: form.getValues('summaryMode'),
  106. extendedThinkingMode: form.getValues('extendedThinkingMode'),
  107. }),
  108. });
  109. mutateRecentThreads();
  110. return response;
  111. }, [form, mutateRecentThreads]);
  112. const processMessage: ProcessMessage = useCallback((data, handler) => {
  113. handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
  114. handler.onMessage(data);
  115. });
  116. handleIfSuccessfullyParsed(data, SsePreMessageSchema, (data: SsePreMessage) => {
  117. handler.onPreMessage(data);
  118. });
  119. }, []);
  120. // Views
  121. const headerIcon = useMemo(() => {
  122. return <span className="growi-custom-icons growi-ai-chat-icon me-3 fs-4">ai_assistant</span>;
  123. }, []);
  124. const headerText = useMemo(() => {
  125. return <>{aiAssistantData?.name}</>;
  126. }, [aiAssistantData?.name]);
  127. const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.knowledge_assistant_placeholder' }, []);
  128. const initialView = useMemo(() => {
  129. if (aiAssistantSidebarData?.aiAssistantData == null) {
  130. return <></>;
  131. }
  132. return (
  133. <AiAssistantChatInitialView
  134. description={aiAssistantSidebarData.aiAssistantData.description}
  135. pagePathPatterns={aiAssistantSidebarData.aiAssistantData.pagePathPatterns}
  136. />
  137. );
  138. }, [aiAssistantSidebarData?.aiAssistantData]);
  139. const [dropdownOpen, setDropdownOpen] = useState(false);
  140. const toggleDropdown = useCallback(() => {
  141. setDropdownOpen(prevState => !prevState);
  142. }, []);
  143. const generateModeSwitchesDropdown: GenerateModeSwitchesDropdown = useCallback((isGenerating) => {
  144. return (
  145. <Dropdown isOpen={dropdownOpen} toggle={toggleDropdown} direction="up">
  146. <DropdownToggle size="sm" outline className="border-0">
  147. <span className="material-symbols-outlined">tune</span>
  148. </DropdownToggle>
  149. <DropdownMenu>
  150. <DropdownItem tag="div" toggle={false}>
  151. <div className="form-check form-switch">
  152. <input
  153. id="swSummaryMode"
  154. type="checkbox"
  155. role="switch"
  156. className="form-check-input"
  157. {...form.register('summaryMode')}
  158. disabled={form.formState.isSubmitting || isGenerating}
  159. />
  160. <label className="form-check-label" htmlFor="swSummaryMode">
  161. {t('sidebar_ai_assistant.summary_mode_label')}
  162. </label>
  163. <a
  164. id="tooltipForHelpOfSummaryMode"
  165. role="button"
  166. className="ms-1"
  167. >
  168. <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
  169. </a>
  170. <UncontrolledTooltip
  171. target="tooltipForHelpOfSummaryMode"
  172. >
  173. {t('sidebar_ai_assistant.summary_mode_help')}
  174. </UncontrolledTooltip>
  175. </div>
  176. </DropdownItem>
  177. <DropdownItem tag="div" toggle={false}>
  178. <div className="form-check form-switch">
  179. <input
  180. id="swExtendedThinkingMode"
  181. type="checkbox"
  182. role="switch"
  183. className="form-check-input"
  184. {...form.register('extendedThinkingMode')}
  185. disabled={form.formState.isSubmitting || isGenerating}
  186. />
  187. <label className="form-check-label" htmlFor="swExtendedThinkingMode">
  188. {t('sidebar_ai_assistant.extended_thinking_mode_label')}
  189. </label>
  190. <a
  191. id="tooltipForHelpOfExtendedThinkingMode"
  192. role="button"
  193. className="ms-1"
  194. >
  195. <span className="material-symbols-outlined fs-6" style={{ lineHeight: 'unset' }}>help</span>
  196. </a>
  197. <UncontrolledTooltip
  198. target="tooltipForHelpOfExtendedThinkingMode"
  199. >
  200. {t('sidebar_ai_assistant.extended_thinking_mode_help')}
  201. </UncontrolledTooltip>
  202. </div>
  203. </DropdownItem>
  204. </DropdownMenu>
  205. </Dropdown>
  206. );
  207. }, [dropdownOpen, toggleDropdown, form, t]);
  208. const threadTitleView = useMemo(() => {
  209. const { threadData } = aiAssistantSidebarData ?? {};
  210. if (threadData?.title == null) {
  211. return null;
  212. }
  213. return (
  214. <div className="thread-title-sticky position-sticky top-0 py-2 px-3">
  215. <div className="d-flex align-items-center gap-2">
  216. <button
  217. type="button"
  218. className="btn btn-sm btn-link p-0 text-secondary"
  219. onClick={handleBackToInitialView}
  220. >
  221. <span className="material-symbols-outlined">chevron_left</span>
  222. </button>
  223. <span className="text-truncate small">{threadData.title}</span>
  224. </div>
  225. </div>
  226. );
  227. }, [aiAssistantSidebarData, handleBackToInitialView]);
  228. return {
  229. createThread,
  230. postMessage,
  231. processMessage,
  232. form,
  233. resetForm,
  234. handleBackToInitialView,
  235. threadTitleView,
  236. // Views
  237. initialView,
  238. // generateMessageCard,
  239. generateModeSwitchesDropdown,
  240. headerIcon,
  241. headerText,
  242. placeHolder,
  243. };
  244. };
  245. // Helper function to transform API message data to MessageLog[]
  246. const transformApiMessagesToLogs = (
  247. apiMessageData: MessageWithCustomMetaData | null | undefined,
  248. ): MessageLog[] => {
  249. if (apiMessageData?.data == null || !Array.isArray(apiMessageData.data)) {
  250. return [];
  251. }
  252. // Define a type for the items in apiMessageData.data for clarity
  253. type ApiMessageItem = (typeof apiMessageData.data)[number];
  254. return apiMessageData.data
  255. .slice() // Create a shallow copy before reversing
  256. .reverse()
  257. .filter((message: ApiMessageItem) => message.metadata?.shouldHideMessage !== 'true')
  258. .map((message: ApiMessageItem): MessageLog => {
  259. // Extract the first text content block, if any
  260. let messageTextContent = '';
  261. const textContentBlock = message.content?.find(contentBlock => contentBlock.type === 'text');
  262. if (textContentBlock != null && textContentBlock.type === 'text') {
  263. messageTextContent = textContentBlock.text.value;
  264. }
  265. return {
  266. id: message.id, // Use the actual message ID from OpenAI
  267. content: messageTextContent,
  268. isUserMessage: message.role === 'user',
  269. };
  270. });
  271. };
  272. export const useFetchAndSetMessageDataEffect = (
  273. setMessageLogs: Dispatch<SetStateAction<MessageLog[]>>,
  274. threadId?: string,
  275. ): void => {
  276. const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
  277. const { trigger: mutateMessageData } = useSWRMUTxMessages(
  278. aiAssistantSidebarData?.aiAssistantData?._id,
  279. threadId,
  280. );
  281. useEffect(() => {
  282. if (aiAssistantSidebarData?.isEditorAssistant) {
  283. return;
  284. }
  285. if (threadId == null) {
  286. setMessageLogs([]);
  287. return; // Early return if no threadId
  288. }
  289. const fetchAndSetLogs = async() => {
  290. try {
  291. // Assuming mutateMessageData() returns a Promise<MessageWithCustomMetaData | null | undefined>
  292. const rawApiMessageData: MessageWithCustomMetaData | null | undefined = await mutateMessageData();
  293. const fetchedLogs = transformApiMessagesToLogs(rawApiMessageData);
  294. setMessageLogs((currentLogs) => {
  295. // Preserve current logs if they represent a single, user-submitted message
  296. // AND the newly fetched logs are empty (common for new threads).
  297. const shouldPreserveCurrentMessage = currentLogs.length === 1
  298. && currentLogs[0].isUserMessage
  299. && fetchedLogs.length === 0;
  300. // Update with fetched logs, or preserve current if applicable
  301. return shouldPreserveCurrentMessage ? currentLogs : fetchedLogs;
  302. });
  303. }
  304. catch (error) {
  305. // console.error('Failed to fetch or process message data:', error); // Optional: for debugging
  306. setMessageLogs([]); // Clear logs on error to avoid inconsistent state
  307. }
  308. };
  309. fetchAndSetLogs();
  310. }, [threadId, mutateMessageData, setMessageLogs, aiAssistantSidebarData?.isEditorAssistant]); // Dependencies
  311. };