feat: Add mini model menu (#686)

This commit is contained in:
lily-de
2025-01-22 10:36:43 -08:00
committed by GitHub
parent 88ba274070
commit c190b8883e
4 changed files with 190 additions and 52 deletions
+114 -2
View File
@@ -1,8 +1,54 @@
import React from "react";
import React, { useState, useEffect, useRef } from "react";
import { useModel } from "./settings/models/ModelContext";
import { useRecentModels } from "./settings/models/RecentModels"; // Hook for recent models
import { ChevronUp, ChevronDown, Sliders } from "lucide-react";
import { ModelRadioList } from "./settings/models/ModelRadioList";
import { useNavigate } from "react-router-dom";
export default function BottomMenu({ hasMessages }) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const { currentModel } = useModel();
const { recentModels } = useRecentModels(); // Get recent models
const navigate = useNavigate();
const dropdownRef = useRef<HTMLDivElement>(null);
// Add effect to handle clicks outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsModelMenuOpen(false);
}
};
if (isModelMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isModelMenuOpen]);
// Add effect to handle Escape key
useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsModelMenuOpen(false);
}
};
if (isModelMenuOpen) {
window.addEventListener('keydown', handleEsc);
}
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [isModelMenuOpen]);
return (
<div className="flex relative border-t dark:border-gray-700 text-bottom-menu dark:text-bottom-menu-dark pl-[15px] text-[10px] h-[30px] leading-[30px] align-middle bg-bottom-menu-background dark:bg-bottom-menu-background-dark rounded-b-2xl">
<div className="flex justify-between relative border-t dark:border-gray-700 text-bottom-menu dark:text-bottom-menu-dark pl-[15px] text-[10px] h-[30px] leading-[30px] align-middle bg-bottom-menu-background dark:bg-bottom-menu-background-dark rounded-b-2xl">
{/* Directory Chooser - Always visible */}
<span
className="cursor-pointer"
onClick={async () => {
@@ -16,6 +62,72 @@ export default function BottomMenu({ hasMessages }) {
>
Working in {window.appConfig.get("GOOSE_WORKING_DIR")}
</span>
{/* Model Selector Dropdown - Only in development */}
{process.env.NODE_ENV === "development" && (
<div className="relative flex items-center ml-auto mr-4" ref={dropdownRef}>
<div
className="flex items-center cursor-pointer"
onClick={() => setIsModelMenuOpen(!isModelMenuOpen)}
>
<span>{currentModel?.name || "Select Model"}</span>
{isModelMenuOpen ? (
<ChevronUp className="w-4 h-4 ml-1" />
) : (
<ChevronDown className="w-4 h-4 ml-1" />
)}
</div>
{/* Dropdown Menu */}
{isModelMenuOpen && (
<div className="absolute bottom-[30px] right-0 w-[300px] bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg">
<div className="p-2">
<ModelRadioList
className="divide-y divide-gray-100 dark:divide-gray-700"
renderItem={({ model, isSelected, onSelect }) => (
<label key={model.name} className="block cursor-pointer">
<div className="flex items-center justify-between p-3 transition-colors
hover:text-gray-900 dark:hover:text-white"
onClick={onSelect}>
<div>
<p className="text-sm font-semibold">{model.name}</p>
<p className="text-xs text-muted-foreground">{model.provider}</p>
</div>
<div className="relative">
<input
type="radio"
name="recentModels"
value={model.name}
checked={isSelected}
onChange={onSelect}
className="peer sr-only"
/>
<div className="h-4 w-4 rounded-full border border-gray-400 dark:border-gray-500
peer-checked:border-[6px] peer-checked:border-black dark:peer-checked:border-white
peer-checked:bg-white dark:peer-checked:bg-black
transition-all duration-200 ease-in-out">
</div>
</div>
</div>
</label>
)}
/>
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700
border-t-2 border-gray-200 dark:border-gray-600"
onClick={() => {
setIsModelMenuOpen(false);
navigate("/settings");
}}
>
<span className="text-sm">Tools and Settings</span>
<Sliders className="w-5 h-5 ml-2 rotate-90" />
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,49 @@
import React, { useState, useEffect } from "react";
import { Model } from "./ModelContext";
import { useModel } from "./ModelContext";
import { useHandleModelSelection } from "./utils";
import { useRecentModels } from "./RecentModels";
interface ModelRadioListProps {
renderItem: (props: {
model: Model,
isSelected: boolean,
onSelect: () => void,
}) => React.ReactNode;
className?: string;
}
export function ModelRadioList({ renderItem, className = "" }: ModelRadioListProps) {
const { recentModels } = useRecentModels();
const { currentModel } = useModel();
const handleModelSelection = useHandleModelSelection();
const [selectedModel, setSelectedModel] = useState<string | null>(null);
useEffect(() => {
if (currentModel) {
setSelectedModel(currentModel.name);
}
}, [currentModel]);
const handleRadioChange = async (model: Model) => {
if (selectedModel === model.name) {
console.log(`Model "${model.name}" is already active.`);
return;
}
setSelectedModel(model.name);
await handleModelSelection(model, "ModelList");
};
return (
<div className={className}>
{recentModels.map((model) => (
renderItem({
model,
isSelected: selectedModel === model.name,
onSelect: () => handleRadioChange(model),
})
))}
</div>
);
}
@@ -3,6 +3,7 @@ import { Clock } from 'lucide-react';
import { Model } from "./ModelContext"
import { useHandleModelSelection } from "./utils";
import { useModel } from "./ModelContext";
import { ModelRadioList } from "./ModelRadioList";
const MAX_RECENT_MODELS = 3
@@ -134,58 +135,34 @@ export function RecentModels() {
}
export function RecentModelsRadio() {
const { recentModels } = useRecentModels(); // Access recent models
const handleModelSelection = useHandleModelSelection(); // Access the model selection handler
const { currentModel } = useModel(); // Get the current selected model
const [selectedModel, setSelectedModel] = useState<string | null>(null); // Track the currently selected model
// Initialize selectedModel with the currentModel on component mount
useEffect(() => {
if (currentModel) {
setSelectedModel(currentModel.name);
}
}, [currentModel]);
const handleRadioChange = async (model: Model) => {
if (selectedModel === model.name) {
// Display feedback for already selected model
console.log(`Model "${model.name}" is already active.`);
return;
}
setSelectedModel(model.name); // Update the selected model locally
await handleModelSelection(model, "RecentModels"); // Switch the model using the handler
};
return (
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Recent Models</h2>
{recentModels.map((model) => (
<label
key={model.name}
className="flex items-center justify-between p-4 cursor-pointer"
>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{model.name}</p>
<p className="font-medium">{model.provider}</p>
</div>
<div className="relative">
<input
type="radio"
name="recentModels"
value={model.name}
checked={selectedModel === model.name}
onChange={() => handleRadioChange(model)}
className="peer sr-only" // Hide the default radio button
/>
<div className="h-4 w-4 rounded-full border border-gray-400 dark:border-gray-500
peer-checked:border-[6px] peer-checked:border-black dark:peer-checked:border-white
peer-checked:bg-white dark:peer-checked:bg-black
transition-all duration-200 ease-in-out">
<ModelRadioList
renderItem={({ model, isSelected, onSelect }) => (
<label key={model.name} className="flex items-center justify-between p-4 cursor-pointer">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{model.name}</p>
<p className="font-medium">{model.provider}</p>
</div>
</div>
</label>
))}
<div className="relative">
<input
type="radio"
name="recentModels"
value={model.name}
checked={isSelected}
onChange={onSelect}
className="peer sr-only"
/>
<div className="h-4 w-4 rounded-full border border-gray-400 dark:border-gray-500
peer-checked:border-[6px] peer-checked:border-black dark:peer-checked:border-white
peer-checked:bg-white dark:peer-checked:bg-black
transition-all duration-200 ease-in-out">
</div>
</div>
</label>
)}
/>
</div>
);
}
@@ -9,7 +9,7 @@ export function ToastSuccessModelSwitch(model: Model) {
<div>Switched to {model.name}</div>
</div>,
{
position: "bottom-right",
position: "top-right",
autoClose: 5000,
hideProgressBar: true,
closeOnClick: true,
@@ -25,7 +25,7 @@ export function ToastFailureGeneral(msg?: string) {
<div>{msg}</div>
</div>,
{
position: "bottom-right",
position: "top-right",
autoClose: 3000,
hideProgressBar: true,
closeOnClick: true,