Compare commits

..

1 Commits

Author SHA1 Message Date
Willie Zutz
3ed7f6ad17 Merge 8241c87784 into 68e151b2bd 2025-05-03 22:03:08 +00:00
10 changed files with 232 additions and 426 deletions

View File

@@ -19,8 +19,6 @@ const Chat = ({
setFiles, setFiles,
optimizationMode, optimizationMode,
setOptimizationMode, setOptimizationMode,
focusMode,
setFocusMode,
}: { }: {
messages: Message[]; messages: Message[];
sendMessage: ( sendMessage: (
@@ -40,15 +38,13 @@ const Chat = ({
setFiles: (files: File[]) => void; setFiles: (files: File[]) => void;
optimizationMode: string; optimizationMode: string;
setOptimizationMode: (mode: string) => void; setOptimizationMode: (mode: string) => void;
focusMode: string;
setFocusMode: (mode: string) => void;
}) => { }) => {
const [dividerWidth, setDividerWidth] = useState(0); const [dividerWidth, setDividerWidth] = useState(0);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const [manuallyScrolledUp, setManuallyScrolledUp] = useState(false); const [manuallyScrolledUp, setManuallyScrolledUp] = useState(false);
const dividerRef = useRef<HTMLDivElement | null>(null); const dividerRef = useRef<HTMLDivElement | null>(null);
const messageEnd = useRef<HTMLDivElement | null>(null); const messageEnd = useRef<HTMLDivElement | null>(null);
const SCROLL_THRESHOLD = 250; // pixels from bottom to consider "at bottom" const SCROLL_THRESHOLD = 200; // pixels from bottom to consider "at bottom"
// Check if user is at bottom of page // Check if user is at bottom of page
useEffect(() => { useEffect(() => {
@@ -150,6 +146,7 @@ const Chat = ({
const position = window.innerHeight + window.scrollY; const position = window.innerHeight + window.scrollY;
const height = document.body.scrollHeight; const height = document.body.scrollHeight;
const atBottom = position >= height - SCROLL_THRESHOLD; const atBottom = position >= height - SCROLL_THRESHOLD;
console.log('scrollTrigger', scrollTrigger);
setIsAtBottom(atBottom); setIsAtBottom(atBottom);
if (isAtBottom && !manuallyScrolledUp && messages.length > 0) { if (isAtBottom && !manuallyScrolledUp && messages.length > 0) {
@@ -158,7 +155,7 @@ const Chat = ({
}, [scrollTrigger, isAtBottom, messages.length, manuallyScrolledUp]); }, [scrollTrigger, isAtBottom, messages.length, manuallyScrolledUp]);
return ( return (
<div className="flex flex-col space-y-6 pt-8 pb-48 sm:mx-4 md:mx-8"> <div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-32 sm:mx-4 md:mx-8">
{messages.map((msg, i) => { {messages.map((msg, i) => {
const isLast = i === messages.length - 1; const isLast = i === messages.length - 1;
@@ -220,7 +217,6 @@ const Chat = ({
)} )}
<MessageInput <MessageInput
firstMessage={messages.length === 0}
loading={loading} loading={loading}
sendMessage={sendMessage} sendMessage={sendMessage}
fileIds={fileIds} fileIds={fileIds}
@@ -229,8 +225,6 @@ const Chat = ({
setFiles={setFiles} setFiles={setFiles}
optimizationMode={optimizationMode} optimizationMode={optimizationMode}
setOptimizationMode={setOptimizationMode} setOptimizationMode={setOptimizationMode}
focusMode={focusMode}
setFocusMode={setFocusMode}
/> />
</div> </div>
)} )}

View File

@@ -531,15 +531,6 @@ const ChatWindow = ({ id }: { id?: string }) => {
const ollamaContextWindow = const ollamaContextWindow =
localStorage.getItem('ollamaContextWindow') || '2048'; localStorage.getItem('ollamaContextWindow') || '2048';
// Get the latest model selection from localStorage
const currentChatModelProvider = localStorage.getItem('chatModelProvider');
const currentChatModel = localStorage.getItem('chatModel');
// Use the most current model selection from localStorage, falling back to the state if not available
const modelProvider =
currentChatModelProvider || chatModelProvider.provider;
const modelName = currentChatModel || chatModelProvider.name;
const res = await fetch('/api/chat', { const res = await fetch('/api/chat', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -558,8 +549,8 @@ const ChatWindow = ({ id }: { id?: string }) => {
optimizationMode: optimizationMode, optimizationMode: optimizationMode,
history: messageChatHistory, history: messageChatHistory,
chatModel: { chatModel: {
name: modelName, name: chatModelProvider.name,
provider: modelProvider, provider: chatModelProvider.provider,
...(chatModelProvider.provider === 'ollama' && { ...(chatModelProvider.provider === 'ollama' && {
ollamaContextWindow: parseInt(ollamaContextWindow), ollamaContextWindow: parseInt(ollamaContextWindow),
}), }),
@@ -654,8 +645,6 @@ const ChatWindow = ({ id }: { id?: string }) => {
setFiles={setFiles} setFiles={setFiles}
optimizationMode={optimizationMode} optimizationMode={optimizationMode}
setOptimizationMode={setOptimizationMode} setOptimizationMode={setOptimizationMode}
focusMode={focusMode}
setFocusMode={setFocusMode}
/> />
</> </>
) : ( ) : (

View File

@@ -1,8 +1,8 @@
import { Settings } from 'lucide-react'; import { Settings } from 'lucide-react';
import EmptyChatMessageInput from './EmptyChatMessageInput';
import { useState } from 'react'; import { useState } from 'react';
import { File } from './ChatWindow'; import { File } from './ChatWindow';
import Link from 'next/link'; import Link from 'next/link';
import MessageInput from './MessageInput';
const EmptyChat = ({ const EmptyChat = ({
sendMessage, sendMessage,
@@ -38,9 +38,7 @@ const EmptyChat = ({
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium -mt-8"> <h2 className="text-black/70 dark:text-white/70 text-3xl font-medium -mt-8">
Research begins here. Research begins here.
</h2> </h2>
<MessageInput <EmptyChatMessageInput
firstMessage={true}
loading={false}
sendMessage={sendMessage} sendMessage={sendMessage}
focusMode={focusMode} focusMode={focusMode}
setFocusMode={setFocusMode} setFocusMode={setFocusMode}

View File

@@ -0,0 +1,114 @@
import { ArrowRight } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import CopilotToggle from './MessageInputActions/Copilot';
import Focus from './MessageInputActions/Focus';
import Optimization from './MessageInputActions/Optimization';
import Attach from './MessageInputActions/Attach';
import { File } from './ChatWindow';
const EmptyChatMessageInput = ({
sendMessage,
focusMode,
setFocusMode,
optimizationMode,
setOptimizationMode,
fileIds,
setFileIds,
files,
setFiles,
}: {
sendMessage: (message: string) => void;
focusMode: string;
setFocusMode: (mode: string) => void;
optimizationMode: string;
setOptimizationMode: (mode: string) => void;
fileIds: string[];
setFileIds: (fileIds: string[]) => void;
files: File[];
setFiles: (files: File[]) => void;
}) => {
const [copilotEnabled, setCopilotEnabled] = useState(false);
const [message, setMessage] = useState('');
const inputRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement;
const isInputFocused =
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'TEXTAREA' ||
activeElement?.hasAttribute('contenteditable');
if (e.key === '/' && !isInputFocused) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
inputRef.current?.focus();
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(message);
setMessage('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(message);
setMessage('');
}
}}
className="w-full"
>
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-5 pt-5 pb-2 rounded-lg w-full border border-light-200 dark:border-dark-200">
<TextareaAutosize
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
minRows={2}
className="bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
placeholder="Ask anything..."
/>
<div className="flex flex-row items-center justify-between mt-4">
<div className="flex flex-row items-center space-x-2 lg:space-x-4">
<Focus focusMode={focusMode} setFocusMode={setFocusMode} />
<Attach
fileIds={fileIds}
setFileIds={setFileIds}
files={files}
setFiles={setFiles}
showText
/>
</div>
<div className="flex flex-row items-center space-x-1 sm:space-x-4">
<Optimization
optimizationMode={optimizationMode}
setOptimizationMode={setOptimizationMode}
/>
<button
disabled={message.trim().length === 0}
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
>
<ArrowRight className="bg-background" size={17} />
</button>
</div>
</div>
</div>
</form>
);
};
export default EmptyChatMessageInput;

View File

@@ -1,11 +1,12 @@
import { ArrowRight, ArrowUp } from 'lucide-react'; import { cn } from '@/lib/utils';
import { ArrowUp } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize'; import TextareaAutosize from 'react-textarea-autosize';
import { File } from './ChatWindow';
import Attach from './MessageInputActions/Attach'; import Attach from './MessageInputActions/Attach';
import Focus from './MessageInputActions/Focus'; import CopilotToggle from './MessageInputActions/Copilot';
import ModelSelector from './MessageInputActions/ModelSelector';
import Optimization from './MessageInputActions/Optimization'; import Optimization from './MessageInputActions/Optimization';
import { File } from './ChatWindow';
import AttachSmall from './MessageInputActions/AttachSmall';
const MessageInput = ({ const MessageInput = ({
sendMessage, sendMessage,
@@ -16,9 +17,6 @@ const MessageInput = ({
setFiles, setFiles,
optimizationMode, optimizationMode,
setOptimizationMode, setOptimizationMode,
focusMode,
setFocusMode,
firstMessage,
}: { }: {
sendMessage: (message: string) => void; sendMessage: (message: string) => void;
loading: boolean; loading: boolean;
@@ -28,28 +26,19 @@ const MessageInput = ({
setFiles: (files: File[]) => void; setFiles: (files: File[]) => void;
optimizationMode: string; optimizationMode: string;
setOptimizationMode: (mode: string) => void; setOptimizationMode: (mode: string) => void;
focusMode: string;
setFocusMode: (mode: string) => void;
firstMessage: boolean;
}) => { }) => {
const [copilotEnabled, setCopilotEnabled] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [selectedModel, setSelectedModel] = useState<{ const [textareaRows, setTextareaRows] = useState(1);
provider: string; const [mode, setMode] = useState<'multi' | 'single'>('single');
model: string;
} | null>(null);
useEffect(() => { useEffect(() => {
// Load saved model preferences from localStorage if (textareaRows >= 2 && message && mode === 'single') {
const chatModelProvider = localStorage.getItem('chatModelProvider'); setMode('multi');
const chatModel = localStorage.getItem('chatModel'); } else if (!message && mode === 'multi') {
setMode('single');
if (chatModelProvider && chatModel) {
setSelectedModel({
provider: chatModelProvider,
model: chatModel,
});
} }
}, []); }, [textareaRows, mode, message]);
const inputRef = useRef<HTMLTextAreaElement | null>(null); const inputRef = useRef<HTMLTextAreaElement | null>(null);
@@ -71,74 +60,117 @@ const MessageInput = ({
}; };
}, []); }, []);
// Function to handle message submission return (
const handleSubmitMessage = () => {
// Only submit if we have a non-empty message and not currently loading
if (loading || message.trim().length === 0) return;
// Make sure the selected model is used when sending a message
if (selectedModel) {
localStorage.setItem('chatModelProvider', selectedModel.provider);
localStorage.setItem('chatModel', selectedModel.model);
}
sendMessage(message);
setMessage('');
};
return (
<form <form
onSubmit={(e) => { onSubmit={(e) => {
if (loading) return;
e.preventDefault(); e.preventDefault();
handleSubmitMessage(); sendMessage(message);
setMessage('');
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey && !loading) {
e.preventDefault(); e.preventDefault();
handleSubmitMessage(); sendMessage(message);
setMessage('');
} }
}} }}
className="w-full" className={cn(
'bg-light-secondary dark:bg-dark-secondary p-4 flex items-center border border-light-200 dark:border-dark-200',
mode === 'multi'
? 'flex-col rounded-lg'
: 'flex-col md:flex-row rounded-lg md:rounded-full',
)}
> >
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-5 pt-5 pb-2 rounded-lg w-full border border-light-200 dark:border-dark-200"> {mode === 'single' && (
<TextareaAutosize <div className="flex flex-row items-center justify-between w-full mb-2 md:mb-0 md:w-auto">
ref={inputRef} <div className="flex flex-row items-center space-x-2">
value={message} <AttachSmall
onChange={(e) => setMessage(e.target.value)}
minRows={2}
className="bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
placeholder={firstMessage ? "Ask anything..." :"Ask a follow-up"}
/>
<div className="flex flex-row items-center justify-between mt-4">
<div className="flex flex-row items-center space-x-2 lg:space-x-4">
<Focus focusMode={focusMode} setFocusMode={setFocusMode} />
<Attach
fileIds={fileIds} fileIds={fileIds}
setFileIds={setFileIds} setFileIds={setFileIds}
files={files} files={files}
setFiles={setFiles} setFiles={setFiles}
showText={firstMessage}
/> />
<ModelSelector
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
/>
</div>
<div className="flex flex-row items-center space-x-1 sm:space-x-4">
<Optimization <Optimization
optimizationMode={optimizationMode} optimizationMode={optimizationMode}
setOptimizationMode={setOptimizationMode} setOptimizationMode={setOptimizationMode}
/> />
</div>
<div className="md:hidden">
<CopilotToggle
copilotEnabled={copilotEnabled}
setCopilotEnabled={setCopilotEnabled}
/>
</div>
</div>
)}
<div className="flex flex-row items-center w-full">
<TextareaAutosize
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onHeightChange={(height, props) => {
setTextareaRows(Math.ceil(height / props.rowHeight));
}}
className="transition bg-transparent dark:placeholder:text-white/50 placeholder:text-sm text-sm dark:text-white resize-none focus:outline-none w-full px-2 max-h-24 lg:max-h-36 xl:max-h-48 flex-grow flex-shrink"
placeholder="Ask a follow-up"
/>
{mode === 'single' && (
<div className="flex flex-row items-center space-x-4">
<div className="hidden md:block">
<CopilotToggle
copilotEnabled={copilotEnabled}
setCopilotEnabled={setCopilotEnabled}
/>
</div>
<button <button
disabled={message.trim().length === 0} disabled={message.trim().length === 0 || loading}
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2" className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
type="submit"
> >
{firstMessage ? <ArrowRight className="bg-background" size={17} /> : <ArrowUp className="bg-background" size={17} />} <ArrowUp className="bg-background" size={17} />
</button>
</div>
)}
</div>
{mode === 'multi' && (
<div className="flex flex-col md:flex-row items-start md:items-center justify-between w-full pt-2">
<div className="flex flex-row items-center justify-between w-full md:w-auto mb-2 md:mb-0">
<div className="flex flex-row items-center space-x-2">
<AttachSmall
fileIds={fileIds}
setFileIds={setFileIds}
files={files}
setFiles={setFiles}
/>
<Optimization
optimizationMode={optimizationMode}
setOptimizationMode={setOptimizationMode}
/>
</div>
<div className="md:hidden">
<CopilotToggle
copilotEnabled={copilotEnabled}
setCopilotEnabled={setCopilotEnabled}
/>
</div>
</div>
<div className="flex flex-row items-center space-x-4 self-end">
<div className="hidden md:block">
<CopilotToggle
copilotEnabled={copilotEnabled}
setCopilotEnabled={setCopilotEnabled}
/>
</div>
<button
disabled={message.trim().length === 0 || loading}
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
>
<ArrowUp className="bg-background" size={17} />
</button> </button>
</div> </div>
</div> </div>
</div> )}
</form> </form>
); );
}; };

View File

@@ -5,7 +5,7 @@ import {
PopoverPanel, PopoverPanel,
Transition, Transition,
} from '@headlessui/react'; } from '@headlessui/react';
import { File, LoaderCircle, Paperclip, Plus, Trash } from 'lucide-react'; import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
import { Fragment, useRef, useState } from 'react'; import { Fragment, useRef, useState } from 'react';
import { File as FileType } from '../ChatWindow'; import { File as FileType } from '../ChatWindow';
@@ -176,10 +176,8 @@ const Attach = ({
multiple multiple
hidden hidden
/> />
<Paperclip size="18" /> <CopyPlus size={showText ? 18 : undefined} />
{showText && ( {showText && <p className="text-xs font-medium pl-[1px]">Attach</p>}
<p className="text-xs font-medium pl-[1px] hidden lg:block">Attach</p>
)}
</button> </button>
); );
}; };

View File

@@ -93,13 +93,13 @@ const Focus = ({
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-150" enter="transition ease-out duration-150"
enterFrom="opacity-0 -translate-y-1" enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150" leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<PopoverPanel className="absolute z-10 w-64 md:w-[500px] left-0 bottom-full mb-2"> <PopoverPanel className="absolute z-10 w-64 md:w-[500px] left-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
{focusModes.map((mode, i) => ( {focusModes.map((mode, i) => (
<PopoverButton <PopoverButton

View File

@@ -1,305 +0,0 @@
import { useEffect, useState } from 'react';
import { Cpu, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { Fragment } from 'react';
interface ModelOption {
provider: string;
model: string;
displayName: string;
}
interface ProviderModelMap {
[provider: string]: {
displayName: string;
models: ModelOption[];
};
}
const ModelSelector = ({
selectedModel,
setSelectedModel,
}: {
selectedModel: { provider: string; model: string } | null;
setSelectedModel: (model: { provider: string; model: string }) => void;
}) => {
const [providerModels, setProviderModels] = useState<ProviderModelMap>({});
const [providersList, setProvidersList] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [selectedModelDisplay, setSelectedModelDisplay] = useState<string>('');
const [selectedProviderDisplay, setSelectedProviderDisplay] =
useState<string>('');
const [expandedProviders, setExpandedProviders] = useState<
Record<string, boolean>
>({});
useEffect(() => {
const fetchModels = async () => {
try {
const response = await fetch('/api/models', {
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status}`);
}
const data = await response.json();
const providersData: ProviderModelMap = {};
// Organize models by provider
Object.entries(data.chatModelProviders).forEach(
([provider, models]: [string, any]) => {
const providerDisplayName =
provider.charAt(0).toUpperCase() + provider.slice(1);
providersData[provider] = {
displayName: providerDisplayName,
models: [],
};
Object.entries(models).forEach(
([modelKey, modelData]: [string, any]) => {
providersData[provider].models.push({
provider,
model: modelKey,
displayName: modelData.displayName || modelKey,
});
},
);
},
);
// Filter out providers with no models
Object.keys(providersData).forEach((provider) => {
if (providersData[provider].models.length === 0) {
delete providersData[provider];
}
});
// Sort providers by name (only those that have models)
const sortedProviders = Object.keys(providersData).sort();
setProvidersList(sortedProviders);
// Initialize expanded state for all providers
const initialExpandedState: Record<string, boolean> = {};
sortedProviders.forEach((provider) => {
initialExpandedState[provider] = selectedModel?.provider === provider;
});
// Expand the first provider if none is selected
if (sortedProviders.length > 0 && !selectedModel) {
initialExpandedState[sortedProviders[0]] = true;
}
setExpandedProviders(initialExpandedState);
setProviderModels(providersData);
// Find the current model in our options to display its name
if (selectedModel) {
const provider = providersData[selectedModel.provider];
if (provider) {
const currentModel = provider.models.find(
(option) => option.model === selectedModel.model,
);
if (currentModel) {
setSelectedModelDisplay(currentModel.displayName);
setSelectedProviderDisplay(provider.displayName);
}
}
}
setLoading(false);
} catch (error) {
console.error('Error fetching models:', error);
setLoading(false);
}
};
fetchModels();
}, [selectedModel, setSelectedModel]);
const toggleProviderExpanded = (provider: string) => {
setExpandedProviders((prev) => ({
...prev,
[provider]: !prev[provider],
}));
};
const handleSelectModel = (option: ModelOption) => {
setSelectedModel({
provider: option.provider,
model: option.model,
});
setSelectedModelDisplay(option.displayName);
setSelectedProviderDisplay(
providerModels[option.provider]?.displayName || option.provider,
);
// Save to localStorage for persistence
localStorage.setItem('chatModelProvider', option.provider);
localStorage.setItem('chatModel', option.model);
};
const getDisplayText = () => {
if (loading) return 'Loading...';
if (!selectedModelDisplay) return 'Select model';
return `${selectedModelDisplay} (${selectedProviderDisplay})`;
};
return (
<Popover className="relative">
{({ open }) => (
<>
<div className="relative">
<PopoverButton className="group flex items-center justify-center text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white">
<Cpu size={18} />
<span className="mx-2 text-xs font-medium overflow-hidden text-ellipsis whitespace-nowrap max-w-44 hidden lg:block">
{getDisplayText()}
</span>
<ChevronDown
size={16}
className={cn(
'transition-transform',
open ? 'rotate-180' : 'rotate-0',
)}
/>
</PopoverButton>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-10 w-72 transform bottom-full mb-2">
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/5 bg-white dark:bg-dark-secondary divide-y divide-light-200 dark:divide-dark-200">
<div className="px-4 py-3">
<h3 className="text-sm font-medium text-black/90 dark:text-white/90">
Select Model
</h3>
<p className="text-xs text-black/60 dark:text-white/60 mt-1">
Choose a provider and model for your conversation
</p>
</div>
<div className="max-h-72 overflow-y-auto">
{loading ? (
<div className="px-4 py-3 text-sm text-black/70 dark:text-white/70">
Loading available models...
</div>
) : providersList.length === 0 ? (
<div className="px-4 py-3 text-sm text-black/70 dark:text-white/70">
No models available
</div>
) : (
<div className="py-1">
{providersList.map((providerKey) => {
const provider = providerModels[providerKey];
const isExpanded = expandedProviders[providerKey];
return (
<div
key={providerKey}
className="border-t border-light-200 dark:border-dark-200 first:border-t-0"
>
{/* Provider header */}
<button
className={cn(
'w-full flex items-center justify-between px-4 py-2 text-sm text-left',
'hover:bg-light-100 dark:hover:bg-dark-100',
selectedModel?.provider === providerKey
? 'bg-light-50 dark:bg-dark-50'
: '',
)}
onClick={() =>
toggleProviderExpanded(providerKey)
}
>
<div className="font-medium flex items-center">
<Cpu
size={14}
className="mr-2 text-black/70 dark:text-white/70"
/>
{provider.displayName}
{selectedModel?.provider === providerKey && (
<span className="ml-2 text-xs text-[#24A0ED]">
(active)
</span>
)}
</div>
<ChevronRight
size={14}
className={cn(
'transition-transform',
isExpanded ? 'rotate-90' : '',
)}
/>
</button>
{/* Models list */}
{isExpanded && (
<div className="pl-6">
{provider.models.map((modelOption) => (
<button
key={`${modelOption.provider}-${modelOption.model}`}
className={cn(
'w-full text-left px-4 py-2 text-sm flex items-center',
selectedModel?.provider ===
modelOption.provider &&
selectedModel?.model ===
modelOption.model
? 'bg-light-100 dark:bg-dark-100 text-black dark:text-white'
: 'text-black/70 dark:text-white/70 hover:bg-light-100 dark:hover:bg-dark-100',
)}
onClick={() =>
handleSelectModel(modelOption)
}
>
<div className="flex flex-col flex-1">
<span className="font-medium">
{modelOption.displayName}
</span>
</div>
{/* Active indicator */}
{selectedModel?.provider ===
modelOption.provider &&
selectedModel?.model ===
modelOption.model && (
<div className="ml-auto bg-[#24A0ED] text-white text-xs px-1.5 py-0.5 rounded">
Active
</div>
)}
</button>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</PopoverPanel>
</Transition>
</>
)}
</Popover>
);
};
export default ModelSelector;

View File

@@ -56,12 +56,12 @@ const Optimization = ({
OptimizationModes.find((mode) => mode.key === optimizationMode) OptimizationModes.find((mode) => mode.key === optimizationMode)
?.icon ?.icon
} }
{/* <p className="text-xs font-medium hidden lg:block"> <p className="text-xs font-medium">
{ {
OptimizationModes.find((mode) => mode.key === optimizationMode) OptimizationModes.find((mode) => mode.key === optimizationMode)
?.title ?.title
} }
</p> */} </p>
<ChevronDown size={20} /> <ChevronDown size={20} />
</div> </div>
</PopoverButton> </PopoverButton>

View File

@@ -96,14 +96,7 @@ export const getAvailableChatModelProviders = async () => {
for (const provider in chatModelProviders) { for (const provider in chatModelProviders) {
const providerModels = await chatModelProviders[provider](); const providerModels = await chatModelProviders[provider]();
if (Object.keys(providerModels).length > 0) { if (Object.keys(providerModels).length > 0) {
// Sort models alphabetically by their keys models[provider] = providerModels;
const sortedModels: Record<string, ChatModel> = {};
Object.keys(providerModels)
.sort()
.forEach((key) => {
sortedModels[key] = providerModels[key];
});
models[provider] = sortedModels;
} }
} }
@@ -138,14 +131,7 @@ export const getAvailableEmbeddingModelProviders = async () => {
for (const provider in embeddingModelProviders) { for (const provider in embeddingModelProviders) {
const providerModels = await embeddingModelProviders[provider](); const providerModels = await embeddingModelProviders[provider]();
if (Object.keys(providerModels).length > 0) { if (Object.keys(providerModels).length > 0) {
// Sort embedding models alphabetically by their keys models[provider] = providerModels;
const sortedModels: Record<string, EmbeddingModel> = {};
Object.keys(providerModels)
.sort()
.forEach((key) => {
sortedModels[key] = providerModels[key];
});
models[provider] = sortedModels;
} }
} }