feat(ui): show recent used models in chat footer model picker (#11097)
Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
This commit is contained in:
@@ -29,6 +29,7 @@ vi.mock('../../../ConfigContext', () => ({
|
||||
|
||||
vi.mock('../modelInterface', () => ({
|
||||
getProviderMetadata: vi.fn().mockResolvedValue({ display_name: 'Config Provider' }),
|
||||
fetchModelReasoning: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('../predefinedModelsUtils', () => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Sliders, Bot, LoaderCircle, Settings } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Sliders, Bot, LoaderCircle, Settings, History } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import { SwitchModelModal } from '../subcomponents/SwitchModelModal';
|
||||
import { View } from '../../../../utils/navigationUtils';
|
||||
@@ -7,15 +7,20 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../../ui/dropdown-menu';
|
||||
import { getProviderMetadata } from '../modelInterface';
|
||||
import { getProviderMetadata, fetchModelReasoning } from '../modelInterface';
|
||||
import { getModelDisplayName } from '../predefinedModelsUtils';
|
||||
import { acpReadThinkingEffort } from '../../../../acp/providers';
|
||||
|
||||
import { ModelSettingsPanel } from '../../localInference/ModelSettingsPanel';
|
||||
import { ScrollArea } from '../../../ui/scroll-area';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
import type { Message } from '../../../../types/message';
|
||||
import type { RecentModel } from '../../../../utils/settings';
|
||||
import { addToRecentModels } from '../../../../utils/recentModels';
|
||||
import { trackModelChanged } from '../../../../utils/analytics';
|
||||
|
||||
const i18n = defineMessages({
|
||||
selectModel: {
|
||||
@@ -46,6 +51,10 @@ const i18n = defineMessages({
|
||||
id: 'modelsBottomBar.resolvedModel',
|
||||
defaultMessage: 'Resolved model',
|
||||
},
|
||||
recentModels: {
|
||||
id: 'modelsBottomBar.recentModels',
|
||||
defaultMessage: 'Recent',
|
||||
},
|
||||
});
|
||||
|
||||
interface ModelsBottomBarProps {
|
||||
@@ -71,7 +80,7 @@ export default function ModelsBottomBar({
|
||||
}: ModelsBottomBarProps) {
|
||||
// ChatInput owns the override state and passes effective model/provider as sessionModel/sessionProvider.
|
||||
// Fall back to config defaults when no session-specific model is available.
|
||||
const { currentModel: configModel, currentProvider: configProvider } = useModelAndProvider();
|
||||
const { currentModel: configModel, currentProvider: configProvider, changeModel } = useModelAndProvider();
|
||||
const currentModel = sessionModel ?? configModel;
|
||||
const currentProvider = sessionProvider ?? configProvider;
|
||||
|
||||
@@ -83,6 +92,16 @@ export default function ModelsBottomBar({
|
||||
const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false);
|
||||
const [isLocalModelSettingsOpen, setIsLocalModelSettingsOpen] = useState(false);
|
||||
const [providerDefaultModel, setProviderDefaultModel] = useState<string | null>(null);
|
||||
const [recentModels, setRecentModels] = useState<RecentModel[]>([]);
|
||||
|
||||
const loadRecentModels = useCallback(async () => {
|
||||
const stored = (await window.electron.getSetting('recentModels')) ?? [];
|
||||
setRecentModels(stored);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecentModels();
|
||||
}, [loadRecentModels]);
|
||||
|
||||
// Show a visible loading placeholder while session metadata is still being fetched,
|
||||
// rather than flashing the config default or leaving the footer blank.
|
||||
@@ -139,9 +158,41 @@ export default function ModelsBottomBar({
|
||||
);
|
||||
|
||||
const handleModelSelected = (model: string, provider: string) => {
|
||||
void loadRecentModels();
|
||||
onModelChanged({ model, provider });
|
||||
};
|
||||
|
||||
const handleRecentModelClick = async (recent: RecentModel) => {
|
||||
const previousModel = currentModel;
|
||||
const previousProvider = currentProvider;
|
||||
|
||||
const [reasoning, savedEffort] = await Promise.all([
|
||||
fetchModelReasoning(recent.provider, recent.model),
|
||||
acpReadThinkingEffort().catch(() => null),
|
||||
]);
|
||||
const modelArg = reasoning
|
||||
? {
|
||||
name: recent.model,
|
||||
provider: recent.provider,
|
||||
request_params: { thinking_effort: savedEffort ?? 'off' },
|
||||
}
|
||||
: { name: recent.model, provider: recent.provider };
|
||||
const success = await changeModel(sessionId, modelArg);
|
||||
if (success) {
|
||||
trackModelChanged(recent.provider, recent.model);
|
||||
if (previousModel && previousProvider) {
|
||||
const updated = addToRecentModels(recentModels, previousProvider, previousModel);
|
||||
await window.electron.setSetting('recentModels', updated);
|
||||
setRecentModels(updated);
|
||||
}
|
||||
onModelChanged({ model: recent.model, provider: recent.provider });
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRecentModels = recentModels.filter(
|
||||
(r) => !(r.model === currentModel && r.provider === currentProvider)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center" ref={dropdownRef}>
|
||||
<DropdownMenu>
|
||||
@@ -179,6 +230,23 @@ export default function ModelsBottomBar({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{filteredRecentModels.length > 0 && (
|
||||
<>
|
||||
<h6 className="text-xs text-text-primary mt-2 ml-2">
|
||||
{intl.formatMessage(i18n.recentModels)}
|
||||
</h6>
|
||||
{filteredRecentModels.map((recent) => (
|
||||
<DropdownMenuItem
|
||||
key={`${recent.provider}/${recent.model}`}
|
||||
onClick={() => void handleRecentModelClick(recent)}
|
||||
>
|
||||
<History className="mr-2 h-3.5 w-3.5 flex-shrink-0 text-text-secondary" />
|
||||
<span className="truncate">{getModelDisplayName(recent.model)} — {recent.provider}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setIsAddModelModalOpen(true)}>
|
||||
<span>{intl.formatMessage(i18n.changeModel)}</span>
|
||||
<Sliders className="ml-auto h-4 w-4 rotate-90" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import Model, {
|
||||
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
|
||||
import type { ProviderDetails, ProviderType, ThinkingEffort } from '../../../../types/providers';
|
||||
import { trackModelChanged } from '../../../../utils/analytics';
|
||||
import { addToRecentModels } from '../../../../utils/recentModels';
|
||||
|
||||
const i18n = defineMessages({
|
||||
thinkingEffortOff: {
|
||||
@@ -425,8 +426,15 @@ export const SwitchModelModal = ({
|
||||
|
||||
const success = await changeModel(sessionId, modelObj);
|
||||
if (success) {
|
||||
onModelSelected?.(modelObj.name, modelObj.provider || '');
|
||||
trackModelChanged(modelObj.provider || '', modelObj.name);
|
||||
if (currentModel && currentProvider) {
|
||||
const current = (await window.electron.getSetting('recentModels')) ?? [];
|
||||
await window.electron.setSetting(
|
||||
'recentModels',
|
||||
addToRecentModels(current, currentProvider, currentModel)
|
||||
);
|
||||
}
|
||||
onModelSelected?.(modelObj.name, modelObj.provider || '');
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Bild aus Nachricht entfernen"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Zuletzt verwendet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2330,6 +2330,9 @@
|
||||
"modelsBottomBar.localModelSettingsTitle": {
|
||||
"defaultMessage": "Local Model Settings — {modelName}"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Recent"
|
||||
},
|
||||
"modelsBottomBar.resolvedModel": {
|
||||
"defaultMessage": "Resolved model"
|
||||
},
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Eliminar imagen del mensaje"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Recientes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Supprimer l'image du message"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Récents"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "संदेश से छवि हटाएँ"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "हाल के"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Hapus gambar dari pesan"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Terbaru"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Rimuovi immagine dal messaggio"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Recenti"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "メッセージから画像を削除"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "最近"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "메시지에서 이미지 제거"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "최근"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Alih keluar imej daripada mesej"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Terkini"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Remover imagem da mensagem"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Recentes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Удалить изображение из сообщения"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Недавние"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Görseli mesajdan kaldır"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Son kullanılan"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "Xóa hình ảnh khỏi tin nhắn"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "Gần đây"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "从消息中移除图片"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "最近"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4621,5 +4621,8 @@
|
||||
},
|
||||
"userMessage.removeImageFromEdit": {
|
||||
"defaultMessage": "從訊息中移除圖片"
|
||||
},
|
||||
"modelsBottomBar.recentModels": {
|
||||
"defaultMessage": "最近"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1962,6 +1962,7 @@ const validSettingKeys: Set<string> = new Set([
|
||||
'showPricing',
|
||||
'seenAnnouncementIds',
|
||||
'disableAutoDownload',
|
||||
'recentModels',
|
||||
]);
|
||||
|
||||
ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { RecentModel } from './settings';
|
||||
|
||||
const MAX_RECENT_MODELS = 5;
|
||||
|
||||
export function addToRecentModels(
|
||||
current: RecentModel[],
|
||||
provider: string,
|
||||
model: string
|
||||
): RecentModel[] {
|
||||
const filtered = current.filter((m) => !(m.provider === provider && m.model === model));
|
||||
return [{ provider, model }, ...filtered].slice(0, MAX_RECENT_MODELS);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
export type RecentModel = {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export interface ExternalBackendConfig {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
@@ -49,6 +54,7 @@ export interface Settings {
|
||||
responseStyle: string;
|
||||
showPricing: boolean;
|
||||
seenAnnouncementIds: string[];
|
||||
recentModels: RecentModel[];
|
||||
}
|
||||
|
||||
export type SettingKey = keyof Settings;
|
||||
@@ -89,6 +95,7 @@ export const defaultSettings: Settings = {
|
||||
responseStyle: 'concise',
|
||||
showPricing: true,
|
||||
seenAnnouncementIds: [],
|
||||
recentModels: [],
|
||||
};
|
||||
|
||||
export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts {
|
||||
|
||||
Reference in New Issue
Block a user