import { useMemo, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api, streamChat } from '../utils/api' import { CHAT_MODES, encodeSharePayload, useAppStore } from '../store/useAppStore' function createId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID() } return `msg-${Math.random().toString(36).slice(2, 11)}` } function historyKey(token, sessionId) { return ['history', token || 'guest', sessionId || 'draft'] } function normalizeMessage(message, index = 0) { return { id: message.id || message._id || `${message.created_at || 'msg'}-${index}-${message.role}`, role: message.role, content: message.content || '', attachments: message.attachments || [], created_at: message.created_at || new Date().toISOString(), model_used: message.model_used, provider: message.provider, streaming: Boolean(message.streaming), } } function deriveTitle(text, attachments) { const trimmed = (text || '').trim() if (trimmed) { return trimmed.split('\n')[0].slice(0, 48) } if (attachments?.length) { return `File chat - ${attachments[0].filename}`.slice(0, 48) } return 'New Chat' } function stripMarkdown(markdown) { return markdown .replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, '')) .replace(/`([^`]+)`/g, '$1') .replace(/\[(.*?)\]\((.*?)\)/g, '$1 ($2)') .replace(/[#>*_-]/g, '') .trim() } function buildMarkdownTranscript(title, messages) { const lines = [`# ${title}`, ''] messages.forEach((message) => { const role = message.role === 'assistant' ? 'Assistant' : 'User' lines.push(`## ${role}`) lines.push('') lines.push(message.content || '') if (message.attachments?.length) { lines.push('') lines.push('Attachments:') message.attachments.forEach((attachment) => { lines.push(`- ${attachment.filename} (${attachment.kind})`) }) } lines.push('') }) return lines.join('\n') } function downloadBlob(filename, blob) { const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() URL.revokeObjectURL(url) } function sanitizeFilename(input) { return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'owngpt-chat' } function resolveDraftTitle(forceNewSession, currentSessionId, currentSessionTitle) { if (forceNewSession || currentSessionId) return '' const trimmed = (currentSessionTitle || '').trim() if (!trimmed || trimmed === 'New Chat') return '' return trimmed.slice(0, 80) } export function useChat({ token, user }) { const queryClient = useQueryClient() const abortRef = useRef(null) const [isStreaming, setIsStreaming] = useState(false) const [streamingSessionId, setStreamingSessionId] = useState(null) const currentSessionId = useAppStore((state) => state.currentSessionId) const currentSessionTitle = useAppStore((state) => state.currentSessionTitle) const setCurrentSession = useAppStore((state) => state.setCurrentSession) const renameCurrentSession = useAppStore((state) => state.renameCurrentSession) const startNewChat = useAppStore((state) => state.startNewChat) const attachments = useAppStore((state) => state.attachments) const addAttachment = useAppStore((state) => state.addAttachment) const clearAttachments = useAppStore((state) => state.clearAttachments) const previewFile = useAppStore((state) => state.previewFile) const provider = useAppStore((state) => state.provider) const model = useAppStore((state) => state.model) const mode = useAppStore((state) => state.mode) const openAuth = useAppStore((state) => state.openAuth) const addToast = useAppStore((state) => state.addToast) const setComposerText = useAppStore((state) => state.setComposerText) const sharedConversation = useAppStore((state) => state.sharedConversation) const speakingMessageId = useAppStore((state) => state.speakingMessageId) const setSpeakingMessageId = useAppStore((state) => state.setSpeakingMessageId) const historyQuery = useQuery({ queryKey: historyKey(token, currentSessionId), queryFn: async () => { const payload = await api.get(`/history/${encodeURIComponent(currentSessionId)}`, { token }) return (payload.messages || []).map(normalizeMessage) }, enabled: Boolean( token && currentSessionId && !sharedConversation && !(isStreaming && streamingSessionId === currentSessionId), ), staleTime: 60_000, refetchOnWindowFocus: false, }) const messages = useMemo(() => { if (sharedConversation?.messages) { return sharedConversation.messages.map(normalizeMessage) } return historyQuery.data || [] }, [historyQuery.data, sharedConversation]) const uploadFiles = async (files) => { if (!user) { openAuth('login') return } for (const file of Array.from(files)) { try { const formData = new FormData() formData.append('file', file) const attachment = await api.postForm('/upload', formData, { token }) addAttachment(attachment) addToast({ title: `${attachment.filename} added`, description: 'The extracted content is ready for the next prompt.', variant: 'success', }) } catch (error) { addToast({ title: `Upload failed for ${file.name}`, description: error.message, variant: 'danger', }) } } } const sendMessage = async ({ text, attachments: messageAttachments = attachments, forceNewSession = false, }) => { if (!user) { openAuth('login') return } if (!text?.trim() && !messageAttachments.length) return const sessionId = forceNewSession || !currentSessionId ? createId() : currentSessionId const draftTitle = resolveDraftTitle(forceNewSession, currentSessionId, currentSessionTitle) const title = draftTitle || deriveTitle(text, messageAttachments) const key = historyKey(token, sessionId) setStreamingSessionId(sessionId) setIsStreaming(true) await queryClient.cancelQueries({ queryKey: key, exact: true }) if (forceNewSession || !currentSessionId) { setCurrentSession(sessionId, title) } const userMessage = normalizeMessage({ id: createId(), role: 'user', content: text, attachments: messageAttachments, created_at: new Date().toISOString(), }) const assistantMessage = normalizeMessage({ id: createId(), role: 'assistant', content: '', attachments: [], created_at: new Date().toISOString(), provider, model_used: model, streaming: true, }) queryClient.setQueryData(key, (previous = []) => [...previous, userMessage, assistantMessage]) if (messageAttachments === attachments) { clearAttachments() } abortRef.current?.abort() abortRef.current = new AbortController() let streamFailure = null let streamCompleted = false let streamedContent = '' try { await streamChat({ payload: { message: text, session_id: sessionId, session_title: draftTitle || undefined, provider, model, agent_mode: mode !== 'chat', mode, attachments: messageAttachments.map(({ localId, ...attachment }) => attachment), }, token, signal: abortRef.current.signal, onChunk: (chunk) => { streamedContent += chunk queryClient.setQueryData(key, (previous = []) => previous.map((message) => message.id === assistantMessage.id ? { ...message, content: `${message.content || ''}${chunk}` } : message, ), ) }, onDone: (data) => { streamCompleted = true streamedContent = data.response || streamedContent queryClient.setQueryData(key, (previous = []) => previous.map((message) => message.id === assistantMessage.id ? { ...message, content: data.response || message.content, model_used: data.model_used, provider: data.provider, streaming: false, } : message, ), ) }, onError: (detail) => { streamFailure = detail }, }) if (streamFailure) { throw new Error(streamFailure) } if (!streamCompleted) { queryClient.setQueryData(key, (previous = []) => previous.map((message) => message.id === assistantMessage.id ? { ...message, content: streamedContent || message.content, streaming: false, } : message, ), ) } queryClient.invalidateQueries({ queryKey: ['sessions'] }) } catch (error) { if (error.name === 'AbortError') return queryClient.setQueryData(key, (previous = []) => previous.map((message) => message.id === assistantMessage.id ? { ...message, content: `Error: ${error.message}`, streaming: false, } : message, ), ) addToast({ title: 'Message failed', description: error.message, variant: 'danger', }) } finally { setIsStreaming(false) setStreamingSessionId(null) } } const renameSessionMutation = useMutation({ mutationFn: ({ sessionId, title }) => api.patch(`/session/${encodeURIComponent(sessionId)}/title`, { title }, { token }), onSuccess: (_, variables) => { if (variables.sessionId === currentSessionId) { renameCurrentSession(variables.title) } queryClient.invalidateQueries({ queryKey: ['sessions'] }) addToast({ title: 'Session renamed', description: `Updated to "${variables.title}".`, variant: 'success', }) }, }) const deleteSessionMutation = useMutation({ mutationFn: (sessionId) => api.delete(`/session/${encodeURIComponent(sessionId)}`, { token }), onSuccess: (_, sessionId) => { queryClient.removeQueries({ queryKey: historyKey(token, sessionId) }) queryClient.invalidateQueries({ queryKey: ['sessions'] }) if (sessionId === currentSessionId) { startNewChat() } addToast({ title: 'Session deleted', description: 'The conversation was removed from the sidebar.', variant: 'success', }) }, }) const feedbackMutation = useMutation({ mutationFn: (payload) => api.post('/feedback', payload, { token }), onSuccess: () => { addToast({ title: 'Feedback sent', description: 'Thanks for helping improve OwnGPT.', variant: 'success', }) }, }) const selectSession = (session) => { setCurrentSession(session.session_id, session.title || 'New Chat') } const branchFromUserMessage = async (message, nextText = message.content) => { addToast({ title: 'Starting a new branch', description: 'The edited prompt will run in a fresh conversation.', variant: 'info', }) await sendMessage({ text: nextText, attachments: message.attachments || [], forceNewSession: true, }) } const regenerateFromAssistant = async (assistantId) => { const index = messages.findIndex((message) => message.id === assistantId) if (index < 1) return const previousUserMessage = [...messages.slice(0, index)] .reverse() .find((message) => message.role === 'user') if (!previousUserMessage) return addToast({ title: 'Regenerating response', description: 'A fresh branch is being created from the previous prompt.', variant: 'info', }) await sendMessage({ text: previousUserMessage.content, attachments: previousUserMessage.attachments || [], forceNewSession: true, }) } const askAboutFile = (attachment) => { previewFile(attachment) setComposerText(`Please analyze "${attachment.filename}" and give me the key insights.`) } const exportChatAsMarkdown = async () => { const markdown = buildMarkdownTranscript(currentSessionTitle, messages) downloadBlob( `${sanitizeFilename(currentSessionTitle)}.md`, new Blob([markdown], { type: 'text/markdown;charset=utf-8' }), ) addToast({ title: 'Markdown exported', description: 'Your conversation has been downloaded.', variant: 'success', }) } const exportChatAsPdf = async () => { const { jsPDF } = await import('jspdf') const doc = new jsPDF({ unit: 'pt', format: 'a4' }) const contentWidth = 515 let cursorY = 48 const lines = buildMarkdownTranscript(currentSessionTitle, messages).split('\n') doc.setFont('helvetica', 'bold') doc.setFontSize(18) doc.text(currentSessionTitle, 40, cursorY) cursorY += 24 doc.setFont('helvetica', 'normal') doc.setFontSize(11) lines.forEach((line) => { const wrapped = doc.splitTextToSize(line || ' ', contentWidth) if (cursorY + wrapped.length * 16 > 780) { doc.addPage() cursorY = 48 } doc.text(wrapped, 40, cursorY) cursorY += wrapped.length * 16 }) doc.save(`${sanitizeFilename(currentSessionTitle)}.pdf`) addToast({ title: 'PDF exported', description: 'Your conversation PDF is ready.', variant: 'success', }) } const shareConversation = async () => { const payload = { title: currentSessionTitle, messages, createdAt: new Date().toISOString(), } const hash = `#share=${encodeSharePayload(payload)}` const link = `${window.location.origin}${window.location.pathname}${hash}` try { await navigator.clipboard.writeText(link) addToast({ title: 'Share link copied', description: 'The link contains a read-only snapshot of this chat.', variant: 'success', }) } catch (error) { addToast({ title: 'Share failed', description: error.message || 'Clipboard access is unavailable in this browser.', variant: 'danger', }) } } const toggleSpeech = (message) => { if (typeof window === 'undefined' || !window.speechSynthesis) { addToast({ title: 'Speech unavailable', description: 'This browser does not support speech playback.', variant: 'danger', }) return } if (speakingMessageId === message.id) { window.speechSynthesis.cancel() setSpeakingMessageId(null) return } window.speechSynthesis.cancel() const utterance = new SpeechSynthesisUtterance(stripMarkdown(message.content)) utterance.onend = () => setSpeakingMessageId(null) utterance.onerror = () => setSpeakingMessageId(null) setSpeakingMessageId(message.id) window.speechSynthesis.speak(utterance) } return { messages, isLoadingHistory: historyQuery.isLoading && !messages.length, isStreaming, currentMode: CHAT_MODES.find((entry) => entry.id === mode) || CHAT_MODES[0], uploadFiles, sendMessage, selectSession, renameSession: renameSessionMutation.mutateAsync, deleteSession: deleteSessionMutation.mutateAsync, submitFeedback: feedbackMutation.mutateAsync, feedbackPending: feedbackMutation.isPending, askAboutFile, branchFromUserMessage, regenerateFromAssistant, exportChatAsMarkdown, exportChatAsPdf, shareConversation, toggleSpeech, speakingMessageId, } }