OwnGPT.v2 / client /src /hooks /useChat.js
parthib07's picture
Upload 199 files
212c959 verified
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,
}
}