Spaces:
Paused
Paused
| /** | |
| * 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<void>; | |
| clearMessages: () => void; | |
| stopGeneration: () => void; | |
| regenerateFrom: (messageId: string) => Promise<void>; | |
| editMessage: (messageId: string, newContent: string) => Promise<void>; | |
| } | |
| const ChatContext = createContext<ChatContextValue | null>(null); | |
| export function ChatProvider({ children }: { children: ReactNode }) { | |
| const [state, dispatch] = useReducer(chatReducer, { messages: [] }); | |
| const { settings, selectedModel } = useSettings(); | |
| const { t } = useI18n(); | |
| const abortControllerRef = useRef<AbortController | null>(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 ( | |
| <ChatContext.Provider | |
| value={{ | |
| messages: state.messages, | |
| isStreaming, | |
| currentStatus, | |
| sendMessage, | |
| clearMessages, | |
| stopGeneration, | |
| regenerateFrom, | |
| editMessage, | |
| }} | |
| > | |
| {children} | |
| </ChatContext.Provider> | |
| ); | |
| } | |
| export function useChat(): ChatContextValue { | |
| const context = useContext(ChatContext); | |
| if (!context) { | |
| throw new Error('useChat must be used within a ChatProvider'); | |
| } | |
| return context; | |
| } | |