import * as React from 'react'; import { Box, Stack, useTheme } from '@mui/joy'; import { SxProps } from '@mui/joy/styles/types'; import { ApiChatInput } from '../pages/api/chat'; import { ApiExportResponse } from '../pages/api/export'; import { ApplicationBar } from '@/components/ApplicationBar'; import { ChatMessageList } from '@/components/ChatMessageList'; import { Composer } from '@/components/Composer'; import { ConfirmationModal } from '@/components/dialogs/ConfirmationModal'; import { DMessage, useActiveConfiguration, useChatStore } from '@/lib/store-chats'; import { ExportOutcomeDialog } from '@/components/dialogs/ExportedModal'; import { Link } from '@/components/util/Link'; import { SystemPurposes } from '@/lib/data'; import { exportConversation } from '@/lib/export'; import { useSettingsStore } from '@/lib/store-settings'; function createDMessage(role: DMessage['role'], text: string): DMessage { return { id: Math.random().toString(36).substring(2, 15), // use uuid4 !! text: text, sender: role === 'user' ? 'You' : 'Bot', avatar: null, typing: false, role: role, created: Date.now(), updated: null, }; } /** * Main function to send the chat to the assistant and receive a response (streaming) */ async function _streamAssistantResponseMessage( conversationId: string, history: DMessage[], apiKey: string | undefined, apiHost: string | undefined, chatModelId: string, modelTemperature: number, modelMaxResponseTokens: number, abortSignal: AbortSignal, addMessage: (conversationId: string, message: DMessage) => void, editMessage: (conversationId: string, messageId: string, updatedMessage: Partial, touch: boolean) => void, ) { const assistantMessage: DMessage = createDMessage('assistant', '...'); assistantMessage.typing = true; assistantMessage.modelId = chatModelId; assistantMessage.purposeId = history[0].purposeId; addMessage(conversationId, assistantMessage); const messageId = assistantMessage.id; const payload: ApiChatInput = { ...(apiKey ? { apiKey } : {}), ...(apiHost ? { apiHost } : {}), model: chatModelId, messages: history.map(({ role, text }) => ({ role: role, content: text, })), temperature: modelTemperature, max_tokens: modelMaxResponseTokens, }; try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: abortSignal, }); if (response.body) { const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); // loop forever until the read is done, or the abort controller is triggered let incrementalText = ''; let parsedFirstPacket = false; while (true) { const { value, done } = await reader.read(); if (done) break; incrementalText += decoder.decode(value); // there may be a JSON object at the beginning of the message, which contains the model name (streaming workaround) if (!parsedFirstPacket && incrementalText.startsWith('{')) { const endOfJson = incrementalText.indexOf('}'); if (endOfJson > 0) { const json = incrementalText.substring(0, endOfJson + 1); incrementalText = incrementalText.substring(endOfJson + 1); try { const parsed = JSON.parse(json); editMessage(conversationId, messageId, { modelId: parsed.model }, false); parsedFirstPacket = true; } catch (e) { // error parsing JSON, ignore console.log('Error parsing JSON: ' + e); } } } editMessage(conversationId, messageId, { text: incrementalText }, false); } } } catch (error: any) { if (error?.name === 'AbortError') { // expected, the user clicked the "stop" button } else { // TODO: show an error to the UI console.error('Fetch request error:', error); } } // finally, stop the typing animation editMessage(conversationId, messageId, { typing: false }, false); } export function Chat(props: { onShowSettings: () => void, sx?: SxProps }) { // state const [clearConfirmationId, setClearConfirmationId] = React.useState(null); const [exportConfirmationId, setExportConfirmationId] = React.useState(null); const [exportResponse, setExportResponse] = React.useState(null); const [abortController, setAbortController] = React.useState(null); // external state const theme = useTheme(); const setMessages = useChatStore(state => state.setMessages); const { conversationId: activeConversationId, chatModelId, systemPurposeId } = useActiveConfiguration(); const runAssistant = async (conversationId: string, history: DMessage[]) => { // update the purpose of the system message (if not manually edited), and create if needed { const systemMessageIndex = history.findIndex(m => m.role === 'system'); const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', ''); if (!systemMessage.updated) { systemMessage.purposeId = systemPurposeId; systemMessage.text = SystemPurposes[systemPurposeId].systemMessage .replaceAll('{{Today}}', new Date().toISOString().split('T')[0]); } history.unshift(systemMessage); } // use the new history setMessages(conversationId, history); // when an abort controller is set, the UI switches to the "stop" mode const controller = new AbortController(); setAbortController(controller); const { apiKey, modelTemperature, modelMaxResponseTokens, modelApiHost } = useSettingsStore.getState(); const { appendMessage, editMessage } = useChatStore.getState(); await _streamAssistantResponseMessage(conversationId, history, apiKey, modelApiHost, chatModelId, modelTemperature, modelMaxResponseTokens, controller.signal, appendMessage, editMessage); // clear to send, again setAbortController(null); }; const handleStopGeneration = () => abortController?.abort(); const findConversation = (conversationId: string) => (conversationId ? useChatStore.getState().conversations.find(c => c.id === conversationId) : null) ?? null; const handleSendMessage = async (userText: string, conversationId: string | null) => { const conversation = findConversation(conversationId || activeConversationId); if (conversation) await runAssistant(conversation.id, [...conversation.messages, createDMessage('user', userText)]); }; const handleExportConversation = (conversationId: string | null) => setExportConfirmationId(conversationId || activeConversationId || null); const handleConfirmedExportConversation = async () => { if (exportConfirmationId) { const conversation = findConversation(exportConfirmationId); setExportConfirmationId(null); if (conversation) setExportResponse(await exportConversation('paste.gg', conversation)); } }; const handleClearConversation = (conversationId: string | null) => setClearConfirmationId(conversationId || activeConversationId || null); const handleConfirmedClearConversation = () => { if (clearConfirmationId) { setMessages(clearConfirmationId, []); setClearConfirmationId(null); } }; return ( {/* Confirmation for Export */} setExportConfirmationId(null)} onPositive={handleConfirmedExportConversation} confirmationText={<> Share your conversation anonymously on paste.gg? It will be unlisted and available to share and read for 30 days. Keep in mind, deletion may not be possible. Are you sure you want to proceed? } positiveActionText={'Understood, Export to paste.gg'} /> {/* Confirmation for Delete */} setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation} confirmationText={'Are you sure you want to discard all the messages?'} positiveActionText={'Clear conversation'} /> {/* Show the link/key from a chat Export */} {!!exportResponse && ( setExportResponse(null)} response={exportResponse} /> )} ); }