akseljoonas's picture
akseljoonas HF Staff
deploy
83dee82
import { useState, useCallback, useEffect, KeyboardEvent } from 'react';
import { Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip } from '@mui/material';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import KeyIcon from '@mui/icons-material/Key';
import { useSessionStore } from '@/store/sessionStore';
import { useAuthStore } from '@/store/authStore';
import { useAgentStore } from '@/store/agentStore';
import { AnthropicKeyModal } from '@/components/Auth/AnthropicKeyModal';
// Model configuration
interface ModelOption {
id: string;
name: string;
description: string;
provider: 'huggingface' | 'anthropic';
modelPath: string; // Full path for API
avatarUrl: string; // Logo URL
recommended?: boolean;
requiresApiKey?: boolean;
}
// Helper to get HF avatar URL from model ID
const getHfAvatarUrl = (modelId: string) => {
const org = modelId.split('/')[0];
return `https://huggingface.co/api/avatars/${org}`;
};
// Curated model list
const MODEL_OPTIONS: ModelOption[] = [
{
id: 'minimax-m2.1',
name: 'MiniMax M2.1',
description: 'Via Novita',
provider: 'huggingface',
modelPath: 'huggingface/novita/MiniMaxAI/MiniMax-M2.1',
avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.1'),
recommended: true,
},
{
id: 'kimi-k2.5',
name: 'Kimi K2.5',
description: 'Via Novita',
provider: 'huggingface',
modelPath: 'huggingface/novita/moonshotai/Kimi-K2.5',
avatarUrl: getHfAvatarUrl('moonshotai/Kimi-K2.5'),
},
{
id: 'glm-4.7',
name: 'GLM 4.7',
description: 'Via Novita',
provider: 'huggingface',
modelPath: 'huggingface/novita/zai-org/GLM-4.7',
avatarUrl: getHfAvatarUrl('zai-org/GLM-4.7'),
},
{
id: 'deepseek-v3.2',
name: 'DeepSeek V3.2',
description: 'Via Novita',
provider: 'huggingface',
modelPath: 'huggingface/novita/deepseek-ai/DeepSeek-V3.2',
avatarUrl: getHfAvatarUrl('deepseek-ai/DeepSeek-V3.2'),
},
{
id: 'qwen3-coder-480b',
name: 'Qwen3 Coder 480B',
description: 'Via Nebius',
provider: 'huggingface',
modelPath: 'huggingface/nebius/Qwen/Qwen3-Coder-480B-A35B-Instruct',
avatarUrl: getHfAvatarUrl('Qwen/Qwen3-Coder-480B-A35B-Instruct'),
},
{
id: 'claude-opus',
name: 'Claude Opus 4.5',
description: 'Requires API Key',
provider: 'anthropic',
modelPath: 'anthropic/claude-opus-4-5-20251101',
avatarUrl: '/claude-logo.png',
recommended: true,
requiresApiKey: true,
},
];
// Find model by path (for syncing with backend)
const findModelByPath = (path: string): ModelOption | undefined => {
return MODEL_OPTIONS.find(m => m.modelPath === path || path?.includes(m.id));
};
interface ChatInputProps {
onSend: (text: string) => void;
disabled?: boolean;
}
export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
const [input, setInput] = useState('');
const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
const [pendingModel, setPendingModel] = useState<ModelOption | null>(null);
const { switchModel, createSession, activeModelName } = useSessionStore();
const { user } = useAuthStore();
const { clearMessages, setPlan, setPanelContent } = useAgentStore();
// Track selected model by ID
const [selectedModelId, setSelectedModelId] = useState<string>('minimax-m2.1');
// Sync selectedModelId with the active session's model
useEffect(() => {
if (activeModelName) {
const model = findModelByPath(activeModelName);
if (model) {
setSelectedModelId(model.id);
}
}
}, [activeModelName]);
const selectedModel = MODEL_OPTIONS.find(m => m.id === selectedModelId) || MODEL_OPTIONS[0];
const handleSend = useCallback(() => {
if (input.trim() && !disabled) {
// Check if current model requires API key and user doesn't have one
if (selectedModel.requiresApiKey && !user?.has_anthropic_key) {
setShowApiKeyModal(true);
return;
}
onSend(input);
setInput('');
}
}, [input, disabled, onSend, selectedModel, user?.has_anthropic_key]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handleModelClick = (event: React.MouseEvent<HTMLElement>) => {
setModelAnchorEl(event.currentTarget);
};
const handleModelClose = () => {
setModelAnchorEl(null);
};
const handleSwitchModel = async (model: ModelOption) => {
// Check if user has Anthropic API key when switching to Claude
if (model.requiresApiKey && !user?.has_anthropic_key) {
setPendingModel(model);
setShowApiKeyModal(true);
handleModelClose();
return;
}
await completeSwitchModel(model);
handleModelClose();
};
const completeSwitchModel = async (model: ModelOption) => {
const success = await switchModel(model.modelPath);
if (success) {
setSelectedModelId(model.id);
// Create a new session when switching models
const sessionId = await createSession();
if (sessionId) {
clearMessages();
setPlan([]);
setPanelContent(null);
}
}
};
const handleApiKeyModalClose = async () => {
setShowApiKeyModal(false);
// Get current user state (not the captured closure value)
const currentUser = useAuthStore.getState().user;
if (currentUser?.has_anthropic_key) {
if (pendingModel) {
// User was trying to switch to a model that required a key
await completeSwitchModel(pendingModel);
} else if (selectedModel.requiresApiKey) {
// User added key while already having a model selected that requires it
// Create a new session so they can use it right away
await completeSwitchModel(selectedModel);
}
}
setPendingModel(null);
};
return (
<Box
sx={{
pb: 4,
pt: 2,
position: 'relative',
zIndex: 10,
}}
>
<Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: 2 }}>
<Box
className="composer"
sx={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
bgcolor: 'rgba(255,255,255,0.01)',
borderRadius: 'var(--radius-md)',
p: '12px',
border: '1px solid rgba(255,255,255,0.03)',
transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
'&:focus-within': {
borderColor: 'var(--accent-yellow)',
boxShadow: 'var(--focus)',
}
}}
>
<TextField
fullWidth
multiline
maxRows={6}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything..."
disabled={disabled}
variant="standard"
slotProps={{
input: {
disableUnderline: true,
sx: {
color: 'var(--text)',
fontSize: '15px',
fontFamily: 'inherit',
padding: 0,
lineHeight: 1.5,
minHeight: '56px',
alignItems: 'flex-start',
}
}
}}
sx={{
flex: 1,
'& .MuiInputBase-root': {
p: 0,
backgroundColor: 'transparent',
},
'& textarea': {
resize: 'none',
padding: '0 !important',
}
}}
/>
<IconButton
onClick={handleSend}
disabled={disabled || !input.trim()}
sx={{
mt: 1,
p: 1,
borderRadius: '10px',
color: 'var(--muted-text)',
transition: 'all 0.2s',
'&:hover': {
color: 'var(--accent-yellow)',
bgcolor: 'rgba(255,255,255,0.05)',
},
'&.Mui-disabled': {
opacity: 0.3,
},
}}
>
{disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
</IconButton>
</Box>
{/* Powered By Badge */}
<Box
onClick={handleModelClick}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mt: 1.5,
gap: 0.8,
opacity: 0.6,
cursor: 'pointer',
transition: 'opacity 0.2s',
'&:hover': {
opacity: 1
}
}}
>
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
powered by
</Typography>
<img
src={selectedModel.avatarUrl}
alt={selectedModel.name}
style={{ height: '14px', width: '14px', objectFit: 'contain', borderRadius: '2px' }}
/>
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
{selectedModel.name}
</Typography>
<ArrowDropDownIcon sx={{ fontSize: '14px', color: 'var(--muted-text)' }} />
</Box>
{/* API Key prompt - shown below model switcher when needed */}
{selectedModel.requiresApiKey && !user?.has_anthropic_key && (
<Box
onClick={() => setShowApiKeyModal(true)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mt: 1,
gap: 1,
py: 0.5,
px: 1.5,
borderRadius: '6px',
bgcolor: 'rgba(255, 193, 7, 0.1)',
border: '1px solid rgba(255, 193, 7, 0.2)',
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(255, 193, 7, 0.15)',
borderColor: 'rgba(255, 193, 7, 0.4)',
}
}}
>
<KeyIcon sx={{ fontSize: '12px', color: 'var(--accent-yellow)' }} />
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--accent-yellow)', fontWeight: 500 }}>
Add API key to use this model
</Typography>
</Box>
)}
<Menu
anchorEl={modelAnchorEl}
open={Boolean(modelAnchorEl)}
onClose={handleModelClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
slotProps={{
paper: {
sx: {
bgcolor: 'var(--panel)',
border: '1px solid var(--divider)',
mb: 1,
maxHeight: '400px',
}
}
}}
>
{MODEL_OPTIONS.map((model) => {
const needsKey = model.requiresApiKey && !user?.has_anthropic_key;
return (
<MenuItem
key={model.id}
onClick={() => handleSwitchModel(model)}
selected={selectedModelId === model.id}
sx={{
py: 1.5,
'&.Mui-selected': {
bgcolor: 'rgba(255,255,255,0.05)',
}
}}
>
<ListItemIcon>
<img
src={model.avatarUrl}
alt={model.name}
style={{ width: 24, height: 24, borderRadius: '4px', objectFit: 'cover' }}
/>
</ListItemIcon>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{model.name}
{model.recommended && (
<Chip
label="Recommended"
size="small"
sx={{
height: '18px',
fontSize: '10px',
bgcolor: 'var(--accent-yellow)',
color: '#000',
fontWeight: 600,
}}
/>
)}
{needsKey && (
<Chip
icon={<KeyIcon sx={{ fontSize: '12px !important' }} />}
label="API Key Required"
size="small"
sx={{
height: '18px',
fontSize: '10px',
bgcolor: 'rgba(255,255,255,0.1)',
color: 'var(--muted-text)',
fontWeight: 500,
'& .MuiChip-icon': {
color: 'var(--muted-text)',
marginLeft: '4px',
}
}}
/>
)}
</Box>
}
secondary={model.description}
secondaryTypographyProps={{
sx: { fontSize: '12px', color: 'var(--muted-text)' }
}}
/>
</MenuItem>
);
})}
</Menu>
{/* API Key Modal */}
<AnthropicKeyModal
open={showApiKeyModal}
onClose={handleApiKeyModalClose}
/>
</Box>
</Box>
);
}