/** * Chat Context * Manages chat messages, streaming, and message editing/regeneration */ import { createContext, useContext, useReducer, useCallback, useRef, useState, type ReactNode, } from 'react'; import type { ChatMessage, Message, Tool } from '@/types'; import { streamChatCompletion } from '@/api'; import { useSettings } from './SettingsContext'; import { useI18n } from './I18nContext'; // Generate unique ID const generateId = () => Math.random().toString(36).substring(2, 11); // Actions type ChatAction = | { type: 'ADD_MESSAGE'; payload: ChatMessage } | { type: 'UPDATE_MESSAGE'; payload: { id: string; content: string } } | { type: 'UPDATE_THINKING'; payload: { id: string; thinkingContent: string } } | { type: 'SET_THINKING_DONE'; payload: { id: string } } | { type: 'SET_MESSAGE_CONTENT'; payload: { id: string; content: string } } | { type: 'SET_STREAMING'; payload: { id: string; isStreaming: boolean } } | { type: 'SET_ERROR'; payload: { id: string; error: string } } | { type: 'REMOVE_MESSAGES_FROM'; payload: string } | { type: 'CLEAR_MESSAGES' }; interface ChatState { messages: ChatMessage[]; } function chatReducer(state: ChatState, action: ChatAction): ChatState { switch (action.type) { case 'ADD_MESSAGE': return { ...state, messages: [...state.messages, action.payload] }; case 'UPDATE_MESSAGE': return { ...state, messages: state.messages.map(m => m.id === action.payload.id ? { ...m, content: m.content + action.payload.content } : m ), }; case 'UPDATE_THINKING': return { ...state, messages: state.messages.map(m => m.id === action.payload.id ? { ...m, thinkingContent: (m.thinkingContent || '') + action.payload.thinkingContent, isThinking: true, } : m ), }; case 'SET_THINKING_DONE': return { ...state, messages: state.messages.map(m => m.id === action.payload.id ? { ...m, isThinking: false } : m ), }; case 'SET_MESSAGE_CONTENT': return { ...state, messages: state.messages.map(m => m.id === action.payload.id ? { ...m, content: action.payload.content } : m ), }; case 'SET_STREAMING': return { ...state, messages: state.messages.map(m => m.id === action.payload.id ? { ...m, isStreaming: action.payload.isStreaming } : m ), }; case 'SET_ERROR': return { ...state, messages: state.messages.map(m => m.id === action.payload.id ? { ...m, error: action.payload.error, isStreaming: false, isThinking: false } : m ), }; case 'REMOVE_MESSAGES_FROM': { const index = state.messages.findIndex(m => m.id === action.payload); if (index === -1) return state; return { messages: state.messages.slice(0, index) }; } case 'CLEAR_MESSAGES': return { messages: [] }; default: return state; } } interface ChatContextValue { messages: ChatMessage[]; isStreaming: boolean; currentStatus: string; sendMessage: (content: string) => Promise; clearMessages: () => void; stopGeneration: () => void; regenerateFrom: (messageId: string) => Promise; editMessage: (messageId: string, newContent: string) => Promise; } const ChatContext = createContext(null); export function ChatProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(chatReducer, { messages: [] }); const { settings, selectedModel } = useSettings(); const { t } = useI18n(); const abortControllerRef = useRef(null); const [currentStatus, setCurrentStatus] = useState(''); const isStreaming = state.messages.some(m => m.isStreaming); const stopGeneration = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; } setCurrentStatus(''); }, []); // Build request from messages const buildRequest = useCallback((messages: ChatMessage[]) => { const apiMessages: Message[] = []; // Add system prompt if present if (settings.systemPrompt) { apiMessages.push({ role: 'system', content: settings.systemPrompt }); } // Add conversation history messages.forEach(m => { apiMessages.push({ role: m.role, content: m.content }); }); // Determine reasoning_effort based on model let reasoning_effort: 'minimal' | 'low' | 'medium' | 'high' | number | undefined; const modelId = selectedModel.toLowerCase(); if (modelId.includes('gemini-3')) { // Gemini 3: use level if (settings.thinkingLevel) { reasoning_effort = settings.thinkingLevel as 'minimal' | 'low' | 'medium' | 'high'; } } else if (modelId.includes('gemini-2.5') || modelId === 'gemini-flash-latest' || modelId === 'gemini-flash-lite-latest') { // Gemini 2.5 and flash-latest variants: use budget if enabled if (settings.enableThinking && settings.enableManualBudget) { reasoning_effort = settings.thinkingBudget; } } else if (modelId.includes('flash') && settings.enableThinking) { // Other flash models: use budget if thinking enabled if (settings.enableManualBudget) { reasoning_effort = settings.thinkingBudget; } } // Build tools array based on settings const tools: Tool[] = []; if (settings.enableGoogleSearch) { tools.push({ google_search_retrieval: {} }); } return { model: selectedModel, messages: apiMessages, temperature: settings.temperature, max_output_tokens: settings.maxOutputTokens, top_p: settings.topP, reasoning_effort, tools: tools.length > 0 ? tools : undefined, tool_choice: tools.length > 0 ? 'auto' : undefined, }; }, [selectedModel, settings]); const sendMessage = useCallback(async (content: string) => { if (!content.trim() || !selectedModel) return; // Add user message const userMessage: ChatMessage = { id: generateId(), role: 'user', content: content.trim(), timestamp: new Date(), }; dispatch({ type: 'ADD_MESSAGE', payload: userMessage }); // Prepare assistant message placeholder const assistantMessage: ChatMessage = { id: generateId(), role: 'assistant', content: '', timestamp: new Date(), isStreaming: true, }; dispatch({ type: 'ADD_MESSAGE', payload: assistantMessage }); // Build request with current messages + new user message const currentMessages = [...state.messages, userMessage]; const request = buildRequest(currentMessages); // Create abort controller abortControllerRef.current = new AbortController(); setCurrentStatus(t.chat.connecting); try { const stream = streamChatCompletion(request, abortControllerRef.current.signal); let hasReceivedContent = false; for await (const chunk of stream) { const delta = chunk.choices[0]?.delta; const reasoningContent = delta?.reasoning_content; const content = delta?.content; // Handle thinking content if (reasoningContent) { setCurrentStatus(t.chat.thinkingStatus); dispatch({ type: 'UPDATE_THINKING', payload: { id: assistantMessage.id, thinkingContent: reasoningContent }, }); } // Handle main content if (content) { // First content chunk marks end of thinking phase if (!hasReceivedContent) { hasReceivedContent = true; dispatch({ type: 'SET_THINKING_DONE', payload: { id: assistantMessage.id } }); setCurrentStatus(t.chat.generating); } dispatch({ type: 'UPDATE_MESSAGE', payload: { id: assistantMessage.id, content }, }); } } dispatch({ type: 'SET_STREAMING', payload: { id: assistantMessage.id, isStreaming: false }, }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { dispatch({ type: 'SET_STREAMING', payload: { id: assistantMessage.id, isStreaming: false }, }); } else { dispatch({ type: 'SET_ERROR', payload: { id: assistantMessage.id, error: error instanceof Error ? error.message : t.chat.unknownError, }, }); } } finally { abortControllerRef.current = null; setCurrentStatus(''); } }, [selectedModel, state.messages, buildRequest, t]); const regenerateFrom = useCallback(async (messageId: string) => { // Find the message to regenerate const index = state.messages.findIndex(m => m.id === messageId); if (index === -1) return; // Get messages before this one (for context) const previousMessages = state.messages.slice(0, index); // Remove this message and all after dispatch({ type: 'REMOVE_MESSAGES_FROM', payload: messageId }); // Add new assistant placeholder const assistantMessage: ChatMessage = { id: generateId(), role: 'assistant', content: '', timestamp: new Date(), isStreaming: true, }; dispatch({ type: 'ADD_MESSAGE', payload: assistantMessage }); // Build request const request = buildRequest(previousMessages); abortControllerRef.current = new AbortController(); setCurrentStatus(t.chat.regenerating); try { const stream = streamChatCompletion(request, abortControllerRef.current.signal); let hasReceivedContent = false; for await (const chunk of stream) { const delta = chunk.choices[0]?.delta; const reasoningContent = delta?.reasoning_content; const content = delta?.content; if (reasoningContent) { setCurrentStatus(t.chat.thinkingStatus); dispatch({ type: 'UPDATE_THINKING', payload: { id: assistantMessage.id, thinkingContent: reasoningContent }, }); } if (content) { if (!hasReceivedContent) { hasReceivedContent = true; dispatch({ type: 'SET_THINKING_DONE', payload: { id: assistantMessage.id } }); setCurrentStatus(t.chat.generating); } dispatch({ type: 'UPDATE_MESSAGE', payload: { id: assistantMessage.id, content }, }); } } dispatch({ type: 'SET_STREAMING', payload: { id: assistantMessage.id, isStreaming: false }, }); } catch (error) { if (!(error instanceof Error && error.name === 'AbortError')) { dispatch({ type: 'SET_ERROR', payload: { id: assistantMessage.id, error: error instanceof Error ? error.message : t.chat.unknownError, }, }); } } finally { abortControllerRef.current = null; setCurrentStatus(''); } }, [state.messages, buildRequest, t]); const editMessage = useCallback(async (messageId: string, newContent: string) => { // Find the message const index = state.messages.findIndex(m => m.id === messageId); if (index === -1) return; // Update the message content dispatch({ type: 'SET_MESSAGE_CONTENT', payload: { id: messageId, content: newContent } }); // If it's a user message, regenerate the response const message = state.messages[index]; if (message.role === 'user') { // Remove all messages after this one const messagesAfter = state.messages.slice(index + 1); messagesAfter.forEach(m => { dispatch({ type: 'REMOVE_MESSAGES_FROM', payload: m.id }); }); // Get updated messages const updatedMessages = state.messages.slice(0, index + 1); updatedMessages[index] = { ...updatedMessages[index], content: newContent }; // Add assistant placeholder const assistantMessage: ChatMessage = { id: generateId(), role: 'assistant', content: '', timestamp: new Date(), isStreaming: true, }; dispatch({ type: 'ADD_MESSAGE', payload: assistantMessage }); // Build and send request const request = buildRequest(updatedMessages); abortControllerRef.current = new AbortController(); setCurrentStatus(t.chat.generatingReply); try { const stream = streamChatCompletion(request, abortControllerRef.current.signal); let hasReceivedContent = false; for await (const chunk of stream) { const delta = chunk.choices[0]?.delta; const reasoningContent = delta?.reasoning_content; const content = delta?.content; if (reasoningContent) { setCurrentStatus(t.chat.thinkingStatus); dispatch({ type: 'UPDATE_THINKING', payload: { id: assistantMessage.id, thinkingContent: reasoningContent }, }); } if (content) { if (!hasReceivedContent) { hasReceivedContent = true; dispatch({ type: 'SET_THINKING_DONE', payload: { id: assistantMessage.id } }); setCurrentStatus(t.chat.generating); } dispatch({ type: 'UPDATE_MESSAGE', payload: { id: assistantMessage.id, content }, }); } } dispatch({ type: 'SET_STREAMING', payload: { id: assistantMessage.id, isStreaming: false }, }); } catch (error) { if (!(error instanceof Error && error.name === 'AbortError')) { dispatch({ type: 'SET_ERROR', payload: { id: assistantMessage.id, error: error instanceof Error ? error.message : t.chat.unknownError, }, }); } } finally { abortControllerRef.current = null; setCurrentStatus(''); } } }, [state.messages, buildRequest, t]); const clearMessages = useCallback(() => { stopGeneration(); dispatch({ type: 'CLEAR_MESSAGES' }); }, [stopGeneration]); return ( {children} ); } export function useChat(): ChatContextValue { const context = useContext(ChatContext); if (!context) { throw new Error('useChat must be used within a ChatProvider'); } return context; }