akseljoonas HF Staff commited on
Commit
9615e37
Β·
1 Parent(s): da6e393

Move model selector below chat input

Browse files
agent/core/session.py CHANGED
@@ -18,12 +18,16 @@ logger = logging.getLogger(__name__)
18
  # Local max-token lookup β€” avoids litellm.get_max_tokens() which can hang
19
  # on network calls for certain providers (known litellm issue).
20
  _MAX_TOKENS_MAP: dict[str, int] = {
 
21
  "anthropic/claude-opus-4-5-20251101": 200_000,
22
  "anthropic/claude-sonnet-4-5-20250929": 200_000,
23
  "anthropic/claude-sonnet-4-20250514": 200_000,
24
  "anthropic/claude-haiku-3-5-20241022": 200_000,
25
  "anthropic/claude-3-5-sonnet-20241022": 200_000,
26
  "anthropic/claude-3-opus-20240229": 200_000,
 
 
 
27
  }
28
  _DEFAULT_MAX_TOKENS = 200_000
29
 
@@ -36,10 +40,13 @@ def _get_max_tokens_safe(model_name: str) -> int:
36
  # Fallback: try litellm but with a short timeout via threading
37
  try:
38
  from litellm import get_max_tokens
 
39
  result = get_max_tokens(model_name)
40
  if result and isinstance(result, int):
41
  return result
42
- logger.warning(f"get_max_tokens returned {result} for {model_name}, using default")
 
 
43
  return _DEFAULT_MAX_TOKENS
44
  except Exception as e:
45
  logger.warning(f"get_max_tokens failed for {model_name}, using default: {e}")
 
18
  # Local max-token lookup β€” avoids litellm.get_max_tokens() which can hang
19
  # on network calls for certain providers (known litellm issue).
20
  _MAX_TOKENS_MAP: dict[str, int] = {
21
+ # Anthropic
22
  "anthropic/claude-opus-4-5-20251101": 200_000,
23
  "anthropic/claude-sonnet-4-5-20250929": 200_000,
24
  "anthropic/claude-sonnet-4-20250514": 200_000,
25
  "anthropic/claude-haiku-3-5-20241022": 200_000,
26
  "anthropic/claude-3-5-sonnet-20241022": 200_000,
27
  "anthropic/claude-3-opus-20240229": 200_000,
28
+ "huggingface/novita/MiniMaxAI/MiniMax-M2.1": 196_608,
29
+ "huggingface/novita/moonshotai/Kimi-K2.5": 262_144,
30
+ "huggingface/novita/zai-org/GLM-5": 200_000,
31
  }
32
  _DEFAULT_MAX_TOKENS = 200_000
33
 
 
40
  # Fallback: try litellm but with a short timeout via threading
41
  try:
42
  from litellm import get_max_tokens
43
+
44
  result = get_max_tokens(model_name)
45
  if result and isinstance(result, int):
46
  return result
47
+ logger.warning(
48
+ f"get_max_tokens returned {result} for {model_name}, using default"
49
+ )
50
  return _DEFAULT_MAX_TOKENS
51
  except Exception as e:
52
  logger.warning(f"get_max_tokens failed for {model_name}, using default: {e}")
backend/routes/agent.py CHANGED
@@ -89,9 +89,10 @@ async def llm_health_check() -> LLMHealthResponse:
89
 
90
 
91
  AVAILABLE_MODELS = [
92
- {"id": "anthropic/claude-opus-4-5-20251101", "label": "Claude Opus 4.5", "provider": "anthropic"},
93
- {"id": "huggingface/novita/deepseek-ai/DeepSeek-V3.1", "label": "DeepSeek V3.1", "provider": "huggingface"},
94
- {"id": "huggingface/novita/MiniMaxAI/MiniMax-M2.1", "label": "MiniMax M2.1", "provider": "huggingface"},
 
95
  ]
96
 
97
 
 
89
 
90
 
91
  AVAILABLE_MODELS = [
92
+ {"id": "huggingface/novita/MiniMaxAI/MiniMax-M2.1", "label": "MiniMax M2.1", "provider": "huggingface", "recommended": True},
93
+ {"id": "anthropic/claude-opus-4-5-20251101", "label": "Claude Opus 4.5", "provider": "anthropic", "recommended": True},
94
+ {"id": "huggingface/novita/moonshotai/Kimi-K2.5", "label": "Kimi K2.5", "provider": "huggingface"},
95
+ {"id": "huggingface/novita/zai-org/GLM-5", "label": "GLM 5", "provider": "huggingface"},
96
  ]
97
 
98
 
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -1,6 +1,60 @@
1
  import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
2
- import { Box, TextField, IconButton, CircularProgress } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  interface ChatInputProps {
6
  onSend: (text: string) => void;
@@ -10,8 +64,25 @@ interface ChatInputProps {
10
  export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
11
  const [input, setInput] = useState('');
12
  const inputRef = useRef<HTMLTextAreaElement>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- // Auto-focus the textarea when the session becomes ready (disabled β†’ false)
15
  useEffect(() => {
16
  if (!disabled && inputRef.current) {
17
  inputRef.current.focus();
@@ -35,6 +106,27 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
35
  [handleSend]
36
  );
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  return (
39
  <Box
40
  sx={{
@@ -118,6 +210,108 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
118
  {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
119
  </IconButton>
120
  </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  </Box>
122
  </Box>
123
  );
 
1
  import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
2
+ import { Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
4
+ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
5
+ import { apiFetch } from '@/utils/api';
6
+
7
+ // Model configuration
8
+ interface ModelOption {
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ modelPath: string;
13
+ avatarUrl: string;
14
+ recommended?: boolean;
15
+ }
16
+
17
+ const getHfAvatarUrl = (modelId: string) => {
18
+ const org = modelId.split('/')[0];
19
+ return `https://huggingface.co/api/avatars/${org}`;
20
+ };
21
+
22
+ const MODEL_OPTIONS: ModelOption[] = [
23
+ {
24
+ id: 'minimax-m2.1',
25
+ name: 'MiniMax M2.1',
26
+ description: 'Via Novita',
27
+ modelPath: 'huggingface/novita/MiniMaxAI/MiniMax-M2.1',
28
+ avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.1'),
29
+ recommended: true,
30
+ },
31
+ {
32
+ id: 'claude-opus',
33
+ name: 'Claude Opus 4.5',
34
+ description: 'Anthropic',
35
+ modelPath: 'anthropic/claude-opus-4-5-20251101',
36
+ avatarUrl: 'https://huggingface.co/api/avatars/Anthropic',
37
+ recommended: true,
38
+ },
39
+ {
40
+ id: 'kimi-k2.5',
41
+ name: 'Kimi K2.5',
42
+ description: 'Via Novita',
43
+ modelPath: 'huggingface/novita/moonshotai/Kimi-K2.5',
44
+ avatarUrl: getHfAvatarUrl('moonshotai/Kimi-K2.5'),
45
+ },
46
+ {
47
+ id: 'glm-5',
48
+ name: 'GLM 5',
49
+ description: 'Via Novita',
50
+ modelPath: 'huggingface/novita/zai-org/GLM-5',
51
+ avatarUrl: getHfAvatarUrl('zai-org/GLM-5'),
52
+ },
53
+ ];
54
+
55
+ const findModelByPath = (path: string): ModelOption | undefined => {
56
+ return MODEL_OPTIONS.find(m => m.modelPath === path || path?.includes(m.id));
57
+ };
58
 
59
  interface ChatInputProps {
60
  onSend: (text: string) => void;
 
64
  export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
65
  const [input, setInput] = useState('');
66
  const inputRef = useRef<HTMLTextAreaElement>(null);
67
+ const [selectedModelId, setSelectedModelId] = useState<string>(MODEL_OPTIONS[0].id);
68
+ const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
69
+
70
+ // Sync with backend on mount
71
+ useEffect(() => {
72
+ fetch('/api/config/model')
73
+ .then((res) => (res.ok ? res.json() : null))
74
+ .then((data) => {
75
+ if (data?.current) {
76
+ const model = findModelByPath(data.current);
77
+ if (model) setSelectedModelId(model.id);
78
+ }
79
+ })
80
+ .catch(() => { /* ignore */ });
81
+ }, []);
82
+
83
+ const selectedModel = MODEL_OPTIONS.find(m => m.id === selectedModelId) || MODEL_OPTIONS[0];
84
 
85
+ // Auto-focus the textarea when the session becomes ready (disabled -> false)
86
  useEffect(() => {
87
  if (!disabled && inputRef.current) {
88
  inputRef.current.focus();
 
106
  [handleSend]
107
  );
108
 
109
+ const handleModelClick = (event: React.MouseEvent<HTMLElement>) => {
110
+ setModelAnchorEl(event.currentTarget);
111
+ };
112
+
113
+ const handleModelClose = () => {
114
+ setModelAnchorEl(null);
115
+ };
116
+
117
+ const handleSelectModel = async (model: ModelOption) => {
118
+ handleModelClose();
119
+ try {
120
+ const res = await apiFetch('/api/config/model', {
121
+ method: 'POST',
122
+ body: JSON.stringify({ model: model.modelPath }),
123
+ });
124
+ if (res.ok) {
125
+ setSelectedModelId(model.id);
126
+ }
127
+ } catch { /* ignore */ }
128
+ };
129
+
130
  return (
131
  <Box
132
  sx={{
 
210
  {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
211
  </IconButton>
212
  </Box>
213
+
214
+ {/* Powered By Badge */}
215
+ <Box
216
+ onClick={handleModelClick}
217
+ sx={{
218
+ display: 'flex',
219
+ alignItems: 'center',
220
+ justifyContent: 'center',
221
+ mt: 1.5,
222
+ gap: 0.8,
223
+ opacity: 0.6,
224
+ cursor: 'pointer',
225
+ transition: 'opacity 0.2s',
226
+ '&:hover': {
227
+ opacity: 1
228
+ }
229
+ }}
230
+ >
231
+ <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
232
+ powered by
233
+ </Typography>
234
+ <img
235
+ src={selectedModel.avatarUrl}
236
+ alt={selectedModel.name}
237
+ style={{ height: '14px', width: '14px', objectFit: 'contain', borderRadius: '2px' }}
238
+ />
239
+ <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
240
+ {selectedModel.name}
241
+ </Typography>
242
+ <ArrowDropDownIcon sx={{ fontSize: '14px', color: 'var(--muted-text)' }} />
243
+ </Box>
244
+
245
+ {/* Model Selection Menu */}
246
+ <Menu
247
+ anchorEl={modelAnchorEl}
248
+ open={Boolean(modelAnchorEl)}
249
+ onClose={handleModelClose}
250
+ anchorOrigin={{
251
+ vertical: 'top',
252
+ horizontal: 'center',
253
+ }}
254
+ transformOrigin={{
255
+ vertical: 'bottom',
256
+ horizontal: 'center',
257
+ }}
258
+ slotProps={{
259
+ paper: {
260
+ sx: {
261
+ bgcolor: 'var(--panel)',
262
+ border: '1px solid var(--divider)',
263
+ mb: 1,
264
+ maxHeight: '400px',
265
+ }
266
+ }
267
+ }}
268
+ >
269
+ {MODEL_OPTIONS.map((model) => (
270
+ <MenuItem
271
+ key={model.id}
272
+ onClick={() => handleSelectModel(model)}
273
+ selected={selectedModelId === model.id}
274
+ sx={{
275
+ py: 1.5,
276
+ '&.Mui-selected': {
277
+ bgcolor: 'rgba(255,255,255,0.05)',
278
+ }
279
+ }}
280
+ >
281
+ <ListItemIcon>
282
+ <img
283
+ src={model.avatarUrl}
284
+ alt={model.name}
285
+ style={{ width: 24, height: 24, borderRadius: '4px', objectFit: 'cover' }}
286
+ />
287
+ </ListItemIcon>
288
+ <ListItemText
289
+ primary={
290
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
291
+ {model.name}
292
+ {model.recommended && (
293
+ <Chip
294
+ label="Recommended"
295
+ size="small"
296
+ sx={{
297
+ height: '18px',
298
+ fontSize: '10px',
299
+ bgcolor: 'var(--accent-yellow)',
300
+ color: '#000',
301
+ fontWeight: 600,
302
+ }}
303
+ />
304
+ )}
305
+ </Box>
306
+ }
307
+ secondary={model.description}
308
+ secondaryTypographyProps={{
309
+ sx: { fontSize: '12px', color: 'var(--muted-text)' }
310
+ }}
311
+ />
312
+ </MenuItem>
313
+ ))}
314
+ </Menu>
315
  </Box>
316
  </Box>
317
  );
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useEffect, useState } from 'react';
2
  import {
3
  Avatar,
4
  Box,
@@ -7,8 +7,6 @@ import {
7
  IconButton,
8
  Alert,
9
  AlertTitle,
10
- Select,
11
- MenuItem,
12
  useMediaQuery,
13
  useTheme,
14
  } from '@mui/material';
@@ -50,37 +48,6 @@ export default function AppLayout() {
50
  const theme = useTheme();
51
  const isMobile = useMediaQuery(theme.breakpoints.down('md'));
52
 
53
- // ── Model selector state ──────────────────────────────────────────
54
- const [currentModel, setCurrentModel] = useState('');
55
- const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
56
-
57
- useEffect(() => {
58
- // Use plain fetch (not apiFetch) β€” this is a public endpoint,
59
- // no auth needed, and we don't want 401 handling to trigger redirects.
60
- fetch('/api/config/model')
61
- .then((res) => (res.ok ? res.json() : null))
62
- .then((data) => {
63
- if (data) {
64
- setCurrentModel(data.current);
65
- setAvailableModels(data.available);
66
- }
67
- })
68
- .catch(() => { /* ignore */ });
69
- }, []);
70
-
71
- const handleModelChange = useCallback(async (modelId: string) => {
72
- try {
73
- const res = await apiFetch('/api/config/model', {
74
- method: 'POST',
75
- body: JSON.stringify({ model: modelId }),
76
- });
77
- if (res.ok) {
78
- setCurrentModel(modelId);
79
- logger.log('Model changed to', modelId);
80
- }
81
- } catch { /* ignore */ }
82
- }, []);
83
-
84
  const isResizing = useRef(false);
85
 
86
  const handleMouseMove = useCallback((e: MouseEvent) => {
@@ -335,41 +302,6 @@ export default function AppLayout() {
335
  </Box>
336
 
337
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
338
- {/* Model selector */}
339
- {availableModels.length > 0 && currentModel && (
340
- <Select
341
- value={currentModel}
342
- onChange={(e) => handleModelChange(e.target.value)}
343
- size="small"
344
- variant="outlined"
345
- renderValue={(val) => {
346
- const m = availableModels.find((x) => x.id === val);
347
- return m?.label || val;
348
- }}
349
- sx={{
350
- fontSize: '0.72rem',
351
- height: 30,
352
- minWidth: 120,
353
- color: 'var(--muted-text)',
354
- '& .MuiOutlinedInput-notchedOutline': {
355
- borderColor: 'var(--border)',
356
- },
357
- '&:hover .MuiOutlinedInput-notchedOutline': {
358
- borderColor: 'var(--border-hover)',
359
- },
360
- '& .MuiSelect-select': {
361
- py: 0.5,
362
- px: 1,
363
- },
364
- }}
365
- >
366
- {availableModels.map((m) => (
367
- <MenuItem key={m.id} value={m.id} sx={{ fontSize: '0.75rem' }}>
368
- {m.label}
369
- </MenuItem>
370
- ))}
371
- </Select>
372
- )}
373
  <IconButton
374
  onClick={toggleTheme}
375
  size="small"
 
1
+ import { useCallback, useRef, useEffect } from 'react';
2
  import {
3
  Avatar,
4
  Box,
 
7
  IconButton,
8
  Alert,
9
  AlertTitle,
 
 
10
  useMediaQuery,
11
  useTheme,
12
  } from '@mui/material';
 
48
  const theme = useTheme();
49
  const isMobile = useMediaQuery(theme.breakpoints.down('md'));
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  const isResizing = useRef(false);
52
 
53
  const handleMouseMove = useCallback((e: MouseEvent) => {
 
302
  </Box>
303
 
304
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  <IconButton
306
  onClick={toggleTheme}
307
  size="small"