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 type { UserQuota } from '@/hooks/useUserQuota'; import JobsUpgradeDialog from '@/components/JobsUpgradeDialog'; import { useAgentStore } from '@/store/agentStore'; import { useSessionStore } from '@/store/sessionStore'; import { CLAUDE_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: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Hugging Face', modelPath: CLAUDE_MODEL_PATH, avatarUrl: getHfAvatarUrl(CLAUDE_MODEL_PATH), recommended: true, }, { id: 'gpt-5.5', name: 'GPT-5.5', description: 'Hugging Face', modelPath: GPT_55_MODEL_PATH, avatarUrl: getHfAvatarUrl(GPT_55_MODEL_PATH), }, { id: 'kimi-k2.6', name: 'Kimi K2.6', description: 'Hugging Face', modelPath: 'moonshotai/Kimi-K2.6', avatarUrl: getHfAvatarUrl('moonshotai/Kimi-K2.6'), }, { id: 'minimax-m2.7', name: 'MiniMax M2.7', description: 'Hugging Face', modelPath: 'MiniMaxAI/MiniMax-M2.7', avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.7'), }, { id: 'glm-5.1', name: 'GLM 5.1', description: 'Hugging Face', modelPath: 'zai-org/GLM-5.1', avatarUrl: getHfAvatarUrl('zai-org/GLM-5.1'), }, { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', description: 'Hugging Face', modelPath: 'deepseek-ai/DeepSeek-V4-Pro:deepinfra', avatarUrl: getHfAvatarUrl('deepseek-ai/DeepSeek-V4-Pro'), }, ]; const normalizeModelPath = (path: string | undefined) => ( (path ?? '') .toLowerCase() .replace(/^huggingface\//, '') .replace(/claude-opus-4\.(\d)/g, 'claude-opus-4-$1') ); const findModelByPath = (path: string, options: ModelOption[]): ModelOption | undefined => { const normalizedPath = normalizeModelPath(path); const matched = options.find((m) => { const normalizedModelPath = normalizeModelPath(m.modelPath); const normalizedId = normalizeModelPath(m.id); return ( m.modelPath === path || normalizedModelPath === normalizedPath || normalizedPath.includes(normalizedId) ); }); if (matched) return matched; if (isClaudePath(path)) { const claude = options.find(isClaudeModel); if (claude) return claude; } return undefined; }; const modelOptionId = (modelPath: string) => ( normalizeModelPath(modelPath) .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') ); const modelOptionFromApi = (model: { id?: string; label?: string; provider?: string; recommended?: boolean; }): ModelOption | null => { if (!model.id) return null; return { id: modelOptionId(model.id), name: model.label ?? model.id, description: 'Hugging Face', modelPath: model.id, avatarUrl: getHfAvatarUrl(model.id.replace(/^huggingface\//, '')), recommended: Boolean(model.recommended), }; }; 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; quota: UserQuota | null; refreshQuota: () => Promise | void; } 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 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...', quota, refreshQuota }: 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 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); 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 next = data.available .map(modelOptionFromApi) .filter((model: ModelOption | null): model is ModelOption => model !== null); if (!next.length) return; 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) { 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], ); 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); void refreshQuota(); }; 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.'); } }; 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]); // Show the remaining subsidized premium-session allowance for today. const premiumChip = (() => { if (!quota) return null; const remaining = Math.max(0, quota.premiumRemaining); if (remaining === 0) { return quota.plan === 'pro' ? '0 left – using HF billing' : '0 left – enable billing'; } return `${remaining} left 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} ); }