Spaces:
Runtime error
Runtime error
| import { useState, useCallback, useEffect, useRef, 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 { apiFetch } from '@/utils/api'; | |
| // 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 MODEL_OPTIONS: ModelOption[] = [ | |
| { | |
| id: 'minimax-m2.1', | |
| name: 'MiniMax M2.1', | |
| description: 'Via Novita', | |
| modelPath: 'huggingface/novita/MiniMaxAI/MiniMax-M2.1', | |
| avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.1'), | |
| recommended: true, | |
| }, | |
| { | |
| id: 'claude-opus', | |
| name: 'Claude Opus 4.5', | |
| description: 'Anthropic', | |
| modelPath: 'anthropic/claude-opus-4-5-20251101', | |
| avatarUrl: 'https://huggingface.co/api/avatars/Anthropic', | |
| recommended: true, | |
| }, | |
| { | |
| id: 'kimi-k2.5', | |
| name: 'Kimi K2.5', | |
| description: 'Via Novita', | |
| modelPath: 'huggingface/novita/moonshotai/Kimi-K2.5', | |
| avatarUrl: getHfAvatarUrl('moonshotai/Kimi-K2.5'), | |
| }, | |
| { | |
| id: 'glm-5', | |
| name: 'GLM 5', | |
| description: 'Via Novita', | |
| modelPath: 'huggingface/novita/zai-org/GLM-5', | |
| avatarUrl: getHfAvatarUrl('zai-org/GLM-5'), | |
| }, | |
| ]; | |
| 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 inputRef = useRef<HTMLTextAreaElement>(null); | |
| const [selectedModelId, setSelectedModelId] = useState<string>(MODEL_OPTIONS[0].id); | |
| const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null); | |
| // Sync with backend on mount | |
| useEffect(() => { | |
| fetch('/api/config/model') | |
| .then((res) => (res.ok ? res.json() : null)) | |
| .then((data) => { | |
| if (data?.current) { | |
| const model = findModelByPath(data.current); | |
| if (model) setSelectedModelId(model.id); | |
| } | |
| }) | |
| .catch(() => { /* ignore */ }); | |
| }, []); | |
| const selectedModel = MODEL_OPTIONS.find(m => m.id === selectedModelId) || MODEL_OPTIONS[0]; | |
| // Auto-focus the textarea when the session becomes ready (disabled -> false) | |
| useEffect(() => { | |
| if (!disabled && inputRef.current) { | |
| inputRef.current.focus(); | |
| } | |
| }, [disabled]); | |
| const handleSend = useCallback(() => { | |
| if (input.trim() && !disabled) { | |
| onSend(input); | |
| setInput(''); | |
| } | |
| }, [input, disabled, onSend]); | |
| 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 handleSelectModel = async (model: ModelOption) => { | |
| handleModelClose(); | |
| try { | |
| const res = await apiFetch('/api/config/model', { | |
| method: 'POST', | |
| body: JSON.stringify({ model: model.modelPath }), | |
| }); | |
| if (res.ok) { | |
| setSelectedModelId(model.id); | |
| } | |
| } catch { /* ignore */ } | |
| }; | |
| return ( | |
| <Box | |
| sx={{ | |
| pb: { xs: 2, md: 4 }, | |
| pt: { xs: 1, md: 2 }, | |
| position: 'relative', | |
| zIndex: 10, | |
| }} | |
| > | |
| <Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: { xs: 0, sm: 1, md: 2 } }}> | |
| <Box | |
| className="composer" | |
| sx={{ | |
| display: 'flex', | |
| gap: '10px', | |
| alignItems: 'flex-start', | |
| bgcolor: 'var(--composer-bg)', | |
| borderRadius: 'var(--radius-md)', | |
| p: '12px', | |
| border: '1px solid var(--border)', | |
| 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" | |
| 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={{ | |
| 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: 'var(--hover-bg)', | |
| }, | |
| '&.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> | |
| {/* Model Selection Menu */} | |
| <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) => ( | |
| <MenuItem | |
| key={model.id} | |
| onClick={() => handleSelectModel(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, | |
| }} | |
| /> | |
| )} | |
| </Box> | |
| } | |
| secondary={model.description} | |
| secondaryTypographyProps={{ | |
| sx: { fontSize: '12px', color: 'var(--muted-text)' } | |
| }} | |
| /> | |
| </MenuItem> | |
| ))} | |
| </Menu> | |
| </Box> | |
| </Box> | |
| ); | |
| } | |