Replace generic "An unexpected error occurred" messages with descriptive, user-friendly error messages when LLM operations fail. Errors like invalid API keys, wrong model names, and rate limits now surface clearly in the UI. Adds error classification utility, global FastAPI exception handlers, and frontend getApiErrorMessage() helper. Bumps version to 1.7.2.
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useEffect } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
import { getApiErrorMessage } from '@/lib/utils/error-handler'
|
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
|
import { chatApi } from '@/lib/api/chat'
|
|
import { QUERY_KEYS } from '@/lib/api/query-client'
|
|
import {
|
|
NotebookChatMessage,
|
|
CreateNotebookChatSessionRequest,
|
|
UpdateNotebookChatSessionRequest,
|
|
SourceListResponse,
|
|
NoteResponse
|
|
} from '@/lib/types/api'
|
|
import { ContextSelections } from '@/app/(dashboard)/notebooks/[id]/page'
|
|
|
|
interface UseNotebookChatParams {
|
|
notebookId: string
|
|
sources: SourceListResponse[]
|
|
notes: NoteResponse[]
|
|
contextSelections: ContextSelections
|
|
}
|
|
|
|
export function useNotebookChat({ notebookId, sources, notes, contextSelections }: UseNotebookChatParams) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
|
|
const [messages, setMessages] = useState<NotebookChatMessage[]>([])
|
|
const [isSending, setIsSending] = useState(false)
|
|
const [tokenCount, setTokenCount] = useState<number>(0)
|
|
const [charCount, setCharCount] = useState<number>(0)
|
|
// Pending model override for when user changes model before a session exists
|
|
const [pendingModelOverride, setPendingModelOverride] = useState<string | null>(null)
|
|
|
|
// Fetch sessions for this notebook
|
|
const {
|
|
data: sessions = [],
|
|
isLoading: loadingSessions,
|
|
refetch: refetchSessions
|
|
} = useQuery({
|
|
queryKey: QUERY_KEYS.notebookChatSessions(notebookId),
|
|
queryFn: () => chatApi.listSessions(notebookId),
|
|
enabled: !!notebookId
|
|
})
|
|
|
|
// Fetch current session with messages
|
|
const {
|
|
data: currentSession,
|
|
refetch: refetchCurrentSession
|
|
} = useQuery({
|
|
queryKey: QUERY_KEYS.notebookChatSession(currentSessionId!),
|
|
queryFn: () => chatApi.getSession(currentSessionId!),
|
|
enabled: !!notebookId && !!currentSessionId
|
|
})
|
|
|
|
// Update messages when current session changes
|
|
useEffect(() => {
|
|
if (currentSession?.messages) {
|
|
setMessages(currentSession.messages)
|
|
}
|
|
}, [currentSession])
|
|
|
|
// Auto-select most recent session when sessions are loaded
|
|
useEffect(() => {
|
|
if (sessions.length > 0 && !currentSessionId) {
|
|
// Sessions are sorted by created date desc from API
|
|
const mostRecentSession = sessions[0]
|
|
setCurrentSessionId(mostRecentSession.id)
|
|
}
|
|
}, [sessions, currentSessionId])
|
|
|
|
// Create session mutation
|
|
const createSessionMutation = useMutation({
|
|
mutationFn: (data: CreateNotebookChatSessionRequest) =>
|
|
chatApi.createSession(data),
|
|
onSuccess: (newSession) => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: QUERY_KEYS.notebookChatSessions(notebookId)
|
|
})
|
|
setCurrentSessionId(newSession.id)
|
|
toast.success(t.chat.sessionCreated)
|
|
},
|
|
onError: (err: unknown) => {
|
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
|
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
|
|
}
|
|
})
|
|
|
|
// Update session mutation
|
|
const updateSessionMutation = useMutation({
|
|
mutationFn: ({ sessionId, data }: {
|
|
sessionId: string
|
|
data: UpdateNotebookChatSessionRequest
|
|
}) => chatApi.updateSession(sessionId, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: QUERY_KEYS.notebookChatSessions(notebookId)
|
|
})
|
|
queryClient.invalidateQueries({
|
|
queryKey: QUERY_KEYS.notebookChatSession(currentSessionId!)
|
|
})
|
|
toast.success(t.chat.sessionUpdated)
|
|
},
|
|
onError: (err: unknown) => {
|
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
|
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession'))
|
|
}
|
|
})
|
|
|
|
// Delete session mutation
|
|
const deleteSessionMutation = useMutation({
|
|
mutationFn: (sessionId: string) =>
|
|
chatApi.deleteSession(sessionId),
|
|
onSuccess: (_, deletedId) => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: QUERY_KEYS.notebookChatSessions(notebookId)
|
|
})
|
|
if (currentSessionId === deletedId) {
|
|
setCurrentSessionId(null)
|
|
setMessages([])
|
|
}
|
|
toast.success(t.chat.sessionDeleted)
|
|
},
|
|
onError: (err: unknown) => {
|
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
|
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession'))
|
|
}
|
|
})
|
|
|
|
// Build context from sources and notes based on user selections
|
|
const buildContext = useCallback(async () => {
|
|
// Build context_config mapping IDs to selection modes
|
|
const context_config: { sources: Record<string, string>, notes: Record<string, string> } = {
|
|
sources: {},
|
|
notes: {}
|
|
}
|
|
|
|
// Map source selections
|
|
sources.forEach(source => {
|
|
const mode = contextSelections.sources[source.id]
|
|
if (mode === 'insights') {
|
|
context_config.sources[source.id] = 'insights'
|
|
} else if (mode === 'full') {
|
|
context_config.sources[source.id] = 'full content'
|
|
} else {
|
|
context_config.sources[source.id] = 'not in'
|
|
}
|
|
})
|
|
|
|
// Map note selections
|
|
notes.forEach(note => {
|
|
const mode = contextSelections.notes[note.id]
|
|
if (mode === 'full') {
|
|
context_config.notes[note.id] = 'full content'
|
|
} else {
|
|
context_config.notes[note.id] = 'not in'
|
|
}
|
|
})
|
|
|
|
// Call API to build context with actual content
|
|
const response = await chatApi.buildContext({
|
|
notebook_id: notebookId,
|
|
context_config
|
|
})
|
|
|
|
// Store token and char counts
|
|
setTokenCount(response.token_count)
|
|
setCharCount(response.char_count)
|
|
|
|
return response.context
|
|
}, [notebookId, sources, notes, contextSelections])
|
|
|
|
// Send message (synchronous, no streaming)
|
|
const sendMessage = useCallback(async (message: string, modelOverride?: string) => {
|
|
let sessionId = currentSessionId
|
|
|
|
// Auto-create session if none exists
|
|
if (!sessionId) {
|
|
try {
|
|
const defaultTitle = message.length > 30
|
|
? `${message.substring(0, 30)}...`
|
|
: message
|
|
const newSession = await chatApi.createSession({
|
|
notebook_id: notebookId,
|
|
title: defaultTitle,
|
|
// Include pending model override when creating session
|
|
model_override: pendingModelOverride ?? undefined
|
|
})
|
|
sessionId = newSession.id
|
|
setCurrentSessionId(sessionId)
|
|
// Clear pending model override now that it's applied to the session
|
|
setPendingModelOverride(null)
|
|
queryClient.invalidateQueries({
|
|
queryKey: QUERY_KEYS.notebookChatSessions(notebookId)
|
|
})
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
|
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Add user message optimistically
|
|
const userMessage: NotebookChatMessage = {
|
|
id: `temp-${Date.now()}`,
|
|
type: 'human',
|
|
content: message,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
setMessages(prev => [...prev, userMessage])
|
|
setIsSending(true)
|
|
|
|
try {
|
|
// Build context and send message
|
|
const context = await buildContext()
|
|
const response = await chatApi.sendMessage({
|
|
session_id: sessionId,
|
|
message,
|
|
context,
|
|
model_override: modelOverride ?? (currentSession?.model_override ?? undefined)
|
|
})
|
|
|
|
// Update messages with API response
|
|
setMessages(response.messages)
|
|
|
|
// Refetch current session to get updated data
|
|
await refetchCurrentSession()
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
|
console.error('Error sending message:', error)
|
|
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage'))
|
|
// Remove optimistic message on error
|
|
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
|
|
} finally {
|
|
setIsSending(false)
|
|
}
|
|
}, [
|
|
notebookId,
|
|
currentSessionId,
|
|
currentSession,
|
|
pendingModelOverride,
|
|
buildContext,
|
|
refetchCurrentSession,
|
|
queryClient,
|
|
t
|
|
])
|
|
|
|
// Switch session
|
|
const switchSession = useCallback((sessionId: string) => {
|
|
setCurrentSessionId(sessionId)
|
|
}, [])
|
|
|
|
// Create session
|
|
const createSession = useCallback((title?: string) => {
|
|
return createSessionMutation.mutate({
|
|
notebook_id: notebookId,
|
|
title
|
|
})
|
|
}, [createSessionMutation, notebookId])
|
|
|
|
// Update session
|
|
const updateSession = useCallback((sessionId: string, data: UpdateNotebookChatSessionRequest) => {
|
|
return updateSessionMutation.mutate({
|
|
sessionId,
|
|
data
|
|
})
|
|
}, [updateSessionMutation])
|
|
|
|
// Delete session
|
|
const deleteSession = useCallback((sessionId: string) => {
|
|
return deleteSessionMutation.mutate(sessionId)
|
|
}, [deleteSessionMutation])
|
|
|
|
// Set model override - handles both existing sessions and pending state
|
|
const setModelOverride = useCallback((model: string | null) => {
|
|
if (currentSessionId) {
|
|
// Session exists - update it directly
|
|
updateSessionMutation.mutate({
|
|
sessionId: currentSessionId,
|
|
data: { model_override: model }
|
|
})
|
|
} else {
|
|
// No session yet - store as pending
|
|
setPendingModelOverride(model)
|
|
}
|
|
}, [currentSessionId, updateSessionMutation])
|
|
|
|
// Update token/char counts when context selections change
|
|
useEffect(() => {
|
|
const updateContextCounts = async () => {
|
|
try {
|
|
await buildContext()
|
|
} catch (error) {
|
|
console.error('Error updating context counts:', error)
|
|
}
|
|
}
|
|
updateContextCounts()
|
|
}, [buildContext])
|
|
|
|
return {
|
|
// State
|
|
sessions,
|
|
currentSession: currentSession || sessions.find(s => s.id === currentSessionId),
|
|
currentSessionId,
|
|
messages,
|
|
isSending,
|
|
loadingSessions,
|
|
tokenCount,
|
|
charCount,
|
|
pendingModelOverride,
|
|
|
|
// Actions
|
|
createSession,
|
|
updateSession,
|
|
deleteSession,
|
|
switchSession,
|
|
sendMessage,
|
|
setModelOverride,
|
|
refetchSessions
|
|
}
|
|
}
|