- Replace Proxy-based useTranslation hook with thin react-i18next wrapper
- Convert all t.section.key property access to t('section.key') function calls across 84 files
- Migrate TranslationKeys type parameters to TFunction from i18next
- Update test setup mock and test assertions for new pattern
- Preserve setLanguage with language change events for loading overlay
Closes #579
255 lines
9.2 KiB
TypeScript
255 lines
9.2 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useRef, 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 { sourceChatApi } from '@/lib/api/source-chat'
|
|
import {
|
|
SourceChatSession,
|
|
SourceChatMessage,
|
|
SourceChatContextIndicator,
|
|
CreateSourceChatSessionRequest,
|
|
UpdateSourceChatSessionRequest
|
|
} from '@/lib/types/api'
|
|
|
|
export function useSourceChat(sourceId: string) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)
|
|
const [messages, setMessages] = useState<SourceChatMessage[]>([])
|
|
const [isStreaming, setIsStreaming] = useState(false)
|
|
const [contextIndicators, setContextIndicators] = useState<SourceChatContextIndicator | null>(null)
|
|
const abortControllerRef = useRef<AbortController | null>(null)
|
|
|
|
// Fetch sessions
|
|
const { data: sessions = [], isLoading: loadingSessions, refetch: refetchSessions } = useQuery<SourceChatSession[]>({
|
|
queryKey: ['sourceChatSessions', sourceId],
|
|
queryFn: () => sourceChatApi.listSessions(sourceId),
|
|
enabled: !!sourceId
|
|
})
|
|
|
|
// Fetch current session with messages
|
|
const { data: currentSession, refetch: refetchCurrentSession } = useQuery({
|
|
queryKey: ['sourceChatSession', sourceId, currentSessionId],
|
|
queryFn: () => sourceChatApi.getSession(sourceId, currentSessionId!),
|
|
enabled: !!sourceId && !!currentSessionId
|
|
})
|
|
|
|
// Update messages when 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) {
|
|
// Find most recent session (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: Omit<CreateSourceChatSessionRequest, 'source_id'>) =>
|
|
sourceChatApi.createSession(sourceId, data),
|
|
onSuccess: (newSession) => {
|
|
queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })
|
|
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: UpdateSourceChatSessionRequest }) =>
|
|
sourceChatApi.updateSession(sourceId, sessionId, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })
|
|
queryClient.invalidateQueries({ queryKey: ['sourceChatSession', sourceId, 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) =>
|
|
sourceChatApi.deleteSession(sourceId, sessionId),
|
|
onSuccess: (_, deletedId) => {
|
|
queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })
|
|
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'))
|
|
}
|
|
})
|
|
|
|
// Send message with 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 sourceChatApi.createSession(sourceId, { title: defaultTitle })
|
|
sessionId = newSession.id
|
|
setCurrentSessionId(sessionId)
|
|
queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
|
console.error('Failed to create chat session:', error)
|
|
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Add user message optimistically
|
|
const userMessage: SourceChatMessage = {
|
|
id: `temp-${Date.now()}`,
|
|
type: 'human',
|
|
content: message,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
setMessages(prev => [...prev, userMessage])
|
|
setIsStreaming(true)
|
|
|
|
try {
|
|
const response = await sourceChatApi.sendMessage(sourceId, sessionId, {
|
|
message,
|
|
model_override: modelOverride
|
|
})
|
|
|
|
if (!response) {
|
|
throw new Error('No response body')
|
|
}
|
|
|
|
const reader = response.getReader()
|
|
const decoder = new TextDecoder()
|
|
let aiMessage: SourceChatMessage | null = null
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
const text = decoder.decode(value)
|
|
const lines = text.split('\n')
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try {
|
|
const data = JSON.parse(line.slice(6))
|
|
|
|
if (data.type === 'ai_message') {
|
|
// Create AI message on first content chunk to avoid empty bubble
|
|
if (!aiMessage) {
|
|
aiMessage = {
|
|
id: `ai-${Date.now()}`,
|
|
type: 'ai',
|
|
content: data.content || '',
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
setMessages(prev => [...prev, aiMessage!])
|
|
} else {
|
|
aiMessage.content += data.content || ''
|
|
setMessages(prev =>
|
|
prev.map(msg => msg.id === aiMessage!.id
|
|
? { ...msg, content: aiMessage!.content }
|
|
: msg
|
|
)
|
|
)
|
|
}
|
|
} else if (data.type === 'context_indicators') {
|
|
setContextIndicators(data.data)
|
|
} else if (data.type === 'error') {
|
|
throw new Error(data.message || 'Stream error')
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof SyntaxError) {
|
|
console.error('Error parsing SSE data:', e)
|
|
} else {
|
|
throw e
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} 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 messages on error
|
|
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
|
|
} finally {
|
|
setIsStreaming(false)
|
|
// Refetch session to get persisted messages
|
|
refetchCurrentSession()
|
|
}
|
|
}, [sourceId, currentSessionId, refetchCurrentSession, queryClient, t])
|
|
|
|
// Cancel streaming
|
|
const cancelStreaming = useCallback(() => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort()
|
|
setIsStreaming(false)
|
|
}
|
|
}, [])
|
|
|
|
// Switch session
|
|
const switchSession = useCallback((sessionId: string) => {
|
|
setCurrentSessionId(sessionId)
|
|
setContextIndicators(null)
|
|
}, [])
|
|
|
|
// Create session
|
|
const createSession = useCallback((data: Omit<CreateSourceChatSessionRequest, 'source_id'>) => {
|
|
return createSessionMutation.mutate(data)
|
|
}, [createSessionMutation])
|
|
|
|
// Update session
|
|
const updateSession = useCallback((sessionId: string, data: UpdateSourceChatSessionRequest) => {
|
|
return updateSessionMutation.mutate({ sessionId, data })
|
|
}, [updateSessionMutation])
|
|
|
|
// Delete session
|
|
const deleteSession = useCallback((sessionId: string) => {
|
|
return deleteSessionMutation.mutate(sessionId)
|
|
}, [deleteSessionMutation])
|
|
|
|
return {
|
|
// State
|
|
sessions,
|
|
currentSession: sessions.find(s => s.id === currentSessionId),
|
|
currentSessionId,
|
|
messages,
|
|
isStreaming,
|
|
contextIndicators,
|
|
loadingSessions,
|
|
|
|
// Actions
|
|
createSession,
|
|
updateSession,
|
|
deleteSession,
|
|
switchSession,
|
|
sendMessage,
|
|
cancelStreaming,
|
|
refetchSessions
|
|
}
|
|
}
|