peijun1's picture
Deploy AI Studio Proxy API to Hugging Face Spaces
a5784e9
Raw
History Blame Contribute Delete
15.1 kB
/**
* 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;
}