| 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, |
| } |
| } |
|
|