import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react'; import { Alert, Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip, LinearProgress, Snackbar, Tooltip, } from '@mui/material'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import StopIcon from '@mui/icons-material/Stop'; import AddIcon from '@mui/icons-material/Add'; import { apiFetch, apiUpload } from '@/utils/api'; import { useUserQuota } from '@/hooks/useUserQuota'; import ClaudeCapDialog from '@/components/ClaudeCapDialog'; import JobsUpgradeDialog from '@/components/JobsUpgradeDialog'; import { useAgentStore } from '@/store/agentStore'; import { useSessionStore } from '@/store/sessionStore'; import { CLAUDE_MODEL_PATH, FIRST_FREE_MODEL_PATH, GPT_55_MODEL_PATH, isClaudePath, isPremiumPath, } from '@/utils/model'; // Model configuration interface ModelOption { id: string; name: string; description: string; modelPath: string; avatarUrl: string; recommended?: boolean; } const getHfAvatarUrl = (modelId: string) => { const org = modelId.split('/')[0]; return `https://huggingface.co/api/avatars/${org}`; }; const DEFAULT_MODEL_OPTIONS: ModelOption[] = [ { id: 'kimi-k2.6', name: 'Kimi K2.6', description: 'Novita', modelPath: 'moonshotai/Kimi-K2.6', avatarUrl: getHfAvatarUrl('moonshotai/Kimi-K2.6'), recommended: true, }, { id: 'claude-opus', name: 'Claude Opus 4.6', description: 'Anthropic', modelPath: CLAUDE_MODEL_PATH, avatarUrl: 'https://huggingface.co/api/avatars/Anthropic', recommended: true, }, { id: 'gpt-5.5', name: 'GPT-5.5', description: 'OpenAI', modelPath: GPT_55_MODEL_PATH, avatarUrl: 'https://huggingface.co/api/avatars/openai', }, { id: 'minimax-m2.7', name: 'MiniMax M2.7', description: 'Novita', modelPath: 'MiniMaxAI/MiniMax-M2.7', avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.7'), }, { id: 'glm-5.1', name: 'GLM 5.1', description: 'Together', modelPath: 'zai-org/GLM-5.1', avatarUrl: getHfAvatarUrl('zai-org/GLM-5.1'), }, { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', description: 'DeepInfra', modelPath: 'deepseek-ai/DeepSeek-V4-Pro:deepinfra', avatarUrl: getHfAvatarUrl('deepseek-ai/DeepSeek-V4-Pro'), }, ]; const findModelByPath = (path: string, options: ModelOption[]): ModelOption | undefined => { if (isClaudePath(path)) { const claude = options.find(isClaudeModel); if (claude) return claude; } return options.find(m => m.modelPath === path || path?.includes(m.id)); }; const readApiErrorMessage = async (res: Response, fallback: string): Promise => { try { const data = await res.json(); const detail = data?.detail; if (typeof detail === 'string') return detail; if (detail && typeof detail.message === 'string') return detail.message; if (detail && typeof detail.error === 'string') return detail.error; } catch { /* ignore malformed error bodies */ } return fallback; }; interface ChatInputProps { sessionId?: string; initialModelPath?: string | null; onSend: (text: string) => void; onStop?: () => void; onDatasetUploaded?: () => Promise | boolean; isProcessing?: boolean; disabled?: boolean; placeholder?: string; } interface DatasetUploadResponse { session_id: string; repo_id: string; repo_type: 'dataset'; private: true; upload_id: string; config_name: string; filename: string; path_in_repo: string; size_bytes: number; format: 'csv' | 'json' | 'jsonl'; hub_url: string; load_dataset_snippet: string; } const MAX_DATASET_UPLOAD_BYTES = 100 * 1024 * 1024; const DATASET_UPLOAD_ACCEPT = '.csv,.json,.jsonl'; const DATASET_UPLOAD_EXTENSIONS = new Set(['csv', 'json', 'jsonl']); const isClaudeModel = (m: ModelOption) => isClaudePath(m.modelPath); const isPremiumModel = (m: ModelOption) => isPremiumPath(m.modelPath); const firstFreeModel = (options: ModelOption[]) => options.find(m => !isPremiumModel(m)) ?? options[0]; const formatBytes = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const datasetRepoUrl = (repoId: string) => ( `https://huggingface.co/datasets/${repoId.split('/').map(encodeURIComponent).join('/')}` ); export default function ChatInput({ sessionId, initialModelPath, onSend, onStop, onDatasetUploaded, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) { const [input, setInput] = useState(''); const inputRef = useRef(null); const fileInputRef = useRef(null); const [modelOptions, setModelOptions] = useState(DEFAULT_MODEL_OPTIONS); const modelOptionsRef = useRef(DEFAULT_MODEL_OPTIONS); const sessionIdRef = useRef(sessionId); const [selectedModelId, setSelectedModelId] = useState( () => findModelByPath(initialModelPath ?? '', DEFAULT_MODEL_OPTIONS)?.id ?? DEFAULT_MODEL_OPTIONS[0].id, ); const [modelAnchorEl, setModelAnchorEl] = useState(null); const { quota, refresh: refreshQuota } = useUserQuota(); // The daily-cap dialog is triggered from two places: (a) a 429 returned // from the chat transport when the user tries to send on a premium model over cap — // surfaced via the agent-store flag — and (b) nothing else right now // (switching models is free). Keeping the open state in the store means // the hook layer can flip it without threading props through. const claudeQuotaExhausted = useAgentStore((s) => s.claudeQuotaExhausted); const setClaudeQuotaExhausted = useAgentStore((s) => s.setClaudeQuotaExhausted); const jobsUpgradeRequired = useAgentStore((s) => s.jobsUpgradeRequired); const setJobsUpgradeRequired = useAgentStore((s) => s.setJobsUpgradeRequired); const updateSessionModel = useSessionStore((s) => s.updateSessionModel); const [awaitingTopUp, setAwaitingTopUp] = useState(false); const [modelSwitchError, setModelSwitchError] = useState(null); const [datasetUploadError, setDatasetUploadError] = useState(null); const [datasetUploadSuccess, setDatasetUploadSuccess] = useState(null); const [uploadedDatasets, setUploadedDatasets] = useState([]); const [isUploadingDataset, setIsUploadingDataset] = useState(false); const [datasetUploadProgress, setDatasetUploadProgress] = useState(null); const lastSentRef = useRef(''); useEffect(() => { modelOptionsRef.current = modelOptions; }, [modelOptions]); useEffect(() => { sessionIdRef.current = sessionId; }, [sessionId]); useEffect(() => { let cancelled = false; apiFetch('/api/config/model') .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (cancelled || !data?.available) return; const claude = data.available.find((m: { provider?: string; id?: string }) => ( m.provider === 'anthropic' && m.id )); if (!claude?.id) return; const next = DEFAULT_MODEL_OPTIONS.map((option) => ( isClaudeModel(option) ? { ...option, modelPath: claude.id, name: claude.label ?? option.name } : option )); modelOptionsRef.current = next; setModelOptions(next); if (!sessionIdRef.current) { const current = data.current ? findModelByPath(data.current, next) : null; if (current) setSelectedModelId(current.id); } }) .catch(() => { /* ignore */ }); return () => { cancelled = true; }; }, []); // Model is per-session: fetch this tab's current model every time the // session changes. Other tabs keep their own selections independently. useEffect(() => { if (!sessionId) return; let cancelled = false; apiFetch(`/api/session/${sessionId}`) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (cancelled) return; if (data?.model) { const model = findModelByPath(data.model, modelOptionsRef.current); if (model) setSelectedModelId(model.id); updateSessionModel(sessionId, data.model); } }) .catch(() => { /* ignore */ }); return () => { cancelled = true; }; }, [sessionId, updateSessionModel]); const selectedModel = modelOptions.find(m => m.id === selectedModelId) || modelOptions[0]; // Auto-focus the textarea when the session becomes ready useEffect(() => { if (!disabled && !isProcessing && inputRef.current) { inputRef.current.focus(); } }, [disabled, isProcessing]); const handleSend = useCallback(() => { if (input.trim() && !disabled && !isUploadingDataset) { lastSentRef.current = input; onSend(input); setInput(''); } }, [input, disabled, isUploadingDataset, onSend]); const handleDatasetUploadClick = useCallback(() => { fileInputRef.current?.click(); }, []); const handleDatasetFileChange = useCallback( async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; if (!sessionId) { setDatasetUploadError('Start a session before uploading a dataset.'); return; } const extension = file.name.split('.').pop()?.toLowerCase() || ''; if (!DATASET_UPLOAD_EXTENSIONS.has(extension)) { setDatasetUploadError('Only CSV, JSON, and JSONL dataset files are supported.'); return; } if (file.size > MAX_DATASET_UPLOAD_BYTES) { setDatasetUploadError( `Dataset files must be 100 MB or smaller. ${file.name} is ${formatBytes(file.size)}.` ); return; } if (file.size === 0) { setDatasetUploadError('Uploaded dataset file is empty.'); return; } const formData = new FormData(); formData.append('file', file); setIsUploadingDataset(true); setDatasetUploadProgress(0); setDatasetUploadError(null); setDatasetUploadSuccess(null); try { const res = await apiUpload(`/api/session/${sessionId}/datasets`, formData, { onProgress: ({ percent }) => { setDatasetUploadProgress(percent !== null && percent < 100 ? percent : null); }, }); if (!res.ok) { setDatasetUploadError(await readApiErrorMessage(res, 'Dataset upload failed.')); return; } const payload = await res.json() as DatasetUploadResponse; setUploadedDatasets((previous) => [payload, ...previous]); setDatasetUploadSuccess(`Uploaded ${payload.filename} to ${payload.repo_id}`); await onDatasetUploaded?.(); } catch (error) { setDatasetUploadError( error instanceof Error ? error.message : 'Dataset upload failed.' ); } finally { setIsUploadingDataset(false); setDatasetUploadProgress(null); } }, [sessionId, onDatasetUploaded], ); // When the chat transport reports a premium-model quota 429, restore the typed // text so the user doesn't lose their message. useEffect(() => { if (claudeQuotaExhausted && lastSentRef.current) { setInput(lastSentRef.current); } }, [claudeQuotaExhausted]); useEffect(() => { if (!datasetUploadError) return; const timeout = window.setTimeout(() => setDatasetUploadError(null), 7000); return () => window.clearTimeout(timeout); }, [datasetUploadError]); useEffect(() => { if (!datasetUploadSuccess) return; const timeout = window.setTimeout(() => setDatasetUploadSuccess(null), 5000); return () => window.clearTimeout(timeout); }, [datasetUploadSuccess]); // Refresh the quota display whenever the session changes (user might // have started another tab that spent quota). useEffect(() => { if (sessionId) refreshQuota(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend] ); const handleModelClick = (event: React.MouseEvent) => { setModelAnchorEl(event.currentTarget); }; const handleModelClose = () => { setModelAnchorEl(null); }; const handleSelectModel = async (model: ModelOption) => { handleModelClose(); if (!sessionId) return; try { const res = await apiFetch(`/api/session/${sessionId}/model`, { method: 'POST', body: JSON.stringify({ model: model.modelPath }), }); if (res.ok) { setSelectedModelId(model.id); updateSessionModel(sessionId, model.modelPath); setModelSwitchError(null); return; } setModelSwitchError(await readApiErrorMessage(res, 'Could not switch model.')); } catch (error) { setModelSwitchError(error instanceof Error ? error.message : 'Could not switch model.'); } }; // Dialog close: just clear the flag. The typed text is already restored. const handleCapDialogClose = useCallback(() => { setClaudeQuotaExhausted(false); }, [setClaudeQuotaExhausted]); // "Use a free model" — switch the current session to Kimi (or the first // non-premium option) and auto-retry the send that tripped the cap. const handleUseFreeModel = useCallback(async () => { setClaudeQuotaExhausted(false); if (!sessionId) return; const free = modelOptions.find(m => m.modelPath === FIRST_FREE_MODEL_PATH) ?? firstFreeModel(modelOptions); try { const res = await apiFetch(`/api/session/${sessionId}/model`, { method: 'POST', body: JSON.stringify({ model: free.modelPath }), }); if (res.ok) { setSelectedModelId(free.id); updateSessionModel(sessionId, free.modelPath); const retryText = lastSentRef.current; if (retryText) { onSend(retryText); setInput(''); lastSentRef.current = ''; } } } catch { /* ignore */ } }, [sessionId, onSend, setClaudeQuotaExhausted, modelOptions, updateSessionModel]); const handlePremiumUpgradeClick = useCallback(async () => { if (!sessionId) return; try { await apiFetch(`/api/pro-click/${sessionId}`, { method: 'POST', body: JSON.stringify({ source: 'premium_cap_dialog', target: 'pro_pricing' }), }); } catch { /* tracking is best-effort */ } }, [sessionId]); const handleJobsUpgradeClose = useCallback(() => { setJobsUpgradeRequired(null); setAwaitingTopUp(false); }, [setJobsUpgradeRequired]); const handleJobsUpgradeClick = useCallback(async () => { setAwaitingTopUp(true); if (!sessionId || !jobsUpgradeRequired) return; try { await apiFetch(`/api/pro-click/${sessionId}`, { method: 'POST', body: JSON.stringify({ source: 'hf_jobs_billing_dialog', target: 'hf_billing' }), }); } catch { /* tracking is best-effort */ } }, [sessionId, jobsUpgradeRequired]); const handleJobsRetry = useCallback(() => { const namespace = jobsUpgradeRequired?.namespace; setJobsUpgradeRequired(null); setAwaitingTopUp(false); const msg = namespace ? `I just added credits to the \`${namespace}\` namespace. Please retry the previous job.` : "I just added credits. Please retry the previous job."; onSend(msg); }, [jobsUpgradeRequired, setJobsUpgradeRequired, onSend]); // Auto-retry when the user comes back to this tab after clicking "Add credits". // Browsers fire visibilitychange when the tab regains focus from a sibling tab. useEffect(() => { if (!awaitingTopUp || !jobsUpgradeRequired) return; const onVisible = () => { if (document.visibilityState === 'visible') { handleJobsRetry(); } }; document.addEventListener('visibilitychange', onVisible); return () => document.removeEventListener('visibilitychange', onVisible); }, [awaitingTopUp, jobsUpgradeRequired, handleJobsRetry]); // Hide the chip until the user has actually burned quota; opening a // premium-model session without sending should not populate a counter. const premiumChip = (() => { if (!quota || quota.premiumUsedToday === 0) return null; if (quota.plan === 'free') { return quota.premiumRemaining > 0 ? 'Free today' : 'Pro only'; } return `${quota.premiumUsedToday}/${quota.premiumDailyCap} today`; })(); return ( setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder={placeholder} disabled={disabled || isProcessing} variant="standard" inputRef={inputRef} InputProps={{ disableUnderline: true, sx: { color: 'var(--text)', fontSize: '15px', fontFamily: 'inherit', padding: 0, lineHeight: 1.5, minHeight: { xs: '44px', md: '56px' }, alignItems: 'flex-start', } }} sx={{ gridColumn: '1 / -1', '& .MuiInputBase-root': { p: 0, backgroundColor: 'transparent', }, '& textarea': { resize: 'none', padding: '0 !important', } }} /> {isProcessing ? ( ) : ( )} {isUploadingDataset && ( )} {(datasetUploadError || datasetUploadSuccess) && ( { setDatasetUploadError(null); setDatasetUploadSuccess(null); }} sx={{ fontSize: '0.8rem', maxWidth: 520, width: '100%' }} > {datasetUploadError ?? datasetUploadSuccess} )} {uploadedDatasets.length > 0 && ( {uploadedDatasets.map((dataset) => ( ))} )} {/* Powered By Badge */} powered by {selectedModel.name} {selectedModel.name} {/* Model Selection Menu */} {modelOptions.map((model) => ( handleSelectModel(model)} selected={selectedModelId === model.id} sx={{ py: 1.5, '&.Mui-selected': { bgcolor: 'rgba(255,255,255,0.05)', } }} > {model.name} {model.name} {model.recommended && ( )} {isPremiumModel(model) && premiumChip && ( )} } secondary={model.description} secondaryTypographyProps={{ sx: { fontSize: '12px', color: 'var(--muted-text)' } }} /> ))} setModelSwitchError(null)} autoHideDuration={6000} > setModelSwitchError(null)} sx={{ fontSize: '0.8rem', maxWidth: 480 }} > {modelSwitchError} ); }