mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: enhance model selector UI abd added github models icon
This commit is contained in:
parent
8fd7664f8f
commit
292fcb1a2c
4 changed files with 118 additions and 50 deletions
1
surfsense_web/components/icons/providers/github.svg
Normal file
1
surfsense_web/components/icons/providers/github.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 907 B |
|
|
@ -10,6 +10,7 @@ export { default as DeepInfraIcon } from "./deepinfra.svg";
|
|||
export { default as DeepSeekIcon } from "./deepseek.svg";
|
||||
export { default as FireworksAiIcon } from "./fireworksai.svg";
|
||||
export { default as GeminiIcon } from "./gemini.svg";
|
||||
export { default as GitHubModelsIcon } from "./github.svg";
|
||||
export { default as GroqIcon } from "./groq.svg";
|
||||
export { default as HuggingFaceIcon } from "./huggingface.svg";
|
||||
export { default as MiniMaxIcon } from "./minimax.svg";
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import {
|
|||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Edit3,
|
||||
Eye,
|
||||
ImageIcon,
|
||||
ScanEye,
|
||||
Layers,
|
||||
Plus,
|
||||
Search,
|
||||
|
|
@ -69,6 +72,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
|||
DEEPSEEK: "DeepSeek",
|
||||
MISTRAL: "Mistral",
|
||||
COHERE: "Cohere",
|
||||
GITHUB_MODELS: "GitHub Models",
|
||||
GROQ: "Groq",
|
||||
OLLAMA: "Ollama",
|
||||
TOGETHER_AI: "Together AI",
|
||||
|
|
@ -274,17 +278,40 @@ export function ModelSelector({
|
|||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("all");
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const [showScrollIndicator, setShowScrollIndicator] = useState(true);
|
||||
const [modelScrollPos, setModelScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const [sidebarScrollPos, setSidebarScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const providerSidebarRef = useRef<HTMLDivElement>(null);
|
||||
const modelListRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setModelScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const handleSidebarScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
if (isMobile) {
|
||||
const atStart = el.scrollLeft <= 2;
|
||||
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
||||
setSidebarScrollPos(atStart ? "top" : atEnd ? "bottom" : "middle");
|
||||
} else {
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setSidebarScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Reset search + provider when tab changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
|
||||
useEffect(() => {
|
||||
setSelectedProvider("all");
|
||||
setSearchQuery("");
|
||||
setFocusedIndex(-1);
|
||||
setModelScrollPos("top");
|
||||
}, [activeTab]);
|
||||
|
||||
// Reset on open
|
||||
|
|
@ -295,8 +322,9 @@ export function ModelSelector({
|
|||
}
|
||||
}, [open]);
|
||||
|
||||
// Cmd/Ctrl+M shortcut
|
||||
// Cmd/Ctrl+M shortcut (desktop only)
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "m") {
|
||||
e.preventDefault();
|
||||
|
|
@ -305,9 +333,10 @@ export function ModelSelector({
|
|||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
}, [isMobile]);
|
||||
|
||||
// Focus search input on open
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger to re-focus on tab switch
|
||||
useEffect(() => {
|
||||
if (open && !isMobile) {
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
|
|
@ -677,6 +706,7 @@ export function ModelSelector({
|
|||
);
|
||||
|
||||
// ─── Keyboard navigation ───
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: searchQuery and selectedProvider are intentional triggers to reset focus
|
||||
useEffect(() => {
|
||||
setFocusedIndex(-1);
|
||||
}, [searchQuery, selectedProvider]);
|
||||
|
|
@ -767,24 +797,35 @@ export function ModelSelector({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 border-border/50 relative flex flex-col",
|
||||
!isMobile && "w-10 border-r",
|
||||
"shrink-0 border-border/50 flex",
|
||||
isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r",
|
||||
)}
|
||||
>
|
||||
{!isMobile && sidebarScrollPos !== "top" && (
|
||||
<div className="flex items-center justify-center py-0.5 pointer-events-none">
|
||||
<ChevronUp className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{isMobile && sidebarScrollPos !== "top" && (
|
||||
<div className="flex items-center justify-center px-0.5 shrink-0 pointer-events-none">
|
||||
<ChevronLeft className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={providerSidebarRef}
|
||||
onScroll={(e) => {
|
||||
const t = e.currentTarget;
|
||||
setShowScrollIndicator(
|
||||
t.scrollHeight - t.scrollTop >
|
||||
t.clientHeight + 10,
|
||||
);
|
||||
}}
|
||||
onScroll={handleSidebarScroll}
|
||||
className={cn(
|
||||
isMobile
|
||||
? "flex flex-row gap-0.5 px-2 py-1.5 overflow-x-auto border-b border-border/40"
|
||||
? "flex flex-row gap-0.5 px-1 py-1.5 overflow-x-auto [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar-track]:bg-transparent"
|
||||
: "flex flex-col gap-0.5 p-1 overflow-y-auto flex-1 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
)}
|
||||
style={isMobile ? {
|
||||
maskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to right, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
} : {
|
||||
maskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${sidebarScrollPos === "top" ? "black" : "transparent"}, black 32px, black calc(100% - 32px), ${sidebarScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{activeProviders.map((provider, idx) => {
|
||||
const isAll = provider === "all";
|
||||
|
|
@ -849,18 +890,23 @@ export function ModelSelector({
|
|||
)}
|
||||
{isConfigured
|
||||
? ` (${count})`
|
||||
: " — not configured"}
|
||||
: " (not configured)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!isMobile && showScrollIndicator && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-background to-transparent pointer-events-none flex items-end justify-center pb-0.5">
|
||||
{!isMobile && sidebarScrollPos !== "bottom" && (
|
||||
<div className="flex items-center justify-center py-0.5 pointer-events-none">
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{isMobile && sidebarScrollPos !== "bottom" && (
|
||||
<div className="flex items-center justify-center px-0.5 shrink-0 pointer-events-none">
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -889,19 +935,26 @@ export function ModelSelector({
|
|||
key={`${activeTab}-${item.isGlobal ? "g" : "u"}-${config.id}`}
|
||||
data-model-index={index}
|
||||
role="option"
|
||||
tabIndex={isMobile ? -1 : 0}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
onKeyDown={isMobile ? undefined : (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectItem(item);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer",
|
||||
"transition-all duration-150 mx-1",
|
||||
"hover:bg-accent/40 active:scale-[0.99]",
|
||||
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
|
||||
"transition-all duration-150 mx-2",
|
||||
"hover:bg-accent/40",
|
||||
isSelected && "bg-primary/6 dark:bg-primary/8",
|
||||
isFocused && "bg-accent/50 ring-1 ring-primary/20",
|
||||
isFocused && "bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{/* Provider icon */}
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider as string, {
|
||||
isAutoMode,
|
||||
className: "size-5",
|
||||
|
|
@ -931,8 +984,8 @@ export function ModelSelector({
|
|||
</span>
|
||||
{!isAutoMode && hasCitations && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-primary/10 text-primary border-primary/20"
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
|
|
@ -981,7 +1034,7 @@ export function ModelSelector({
|
|||
: "Add Vision Model";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col w-full overflow-hidden">
|
||||
{/* Tab header */}
|
||||
<div className="border-b border-border/80 dark:border-neutral-800">
|
||||
<div className="w-full grid grid-cols-3 h-11">
|
||||
|
|
@ -999,7 +1052,7 @@ export function ModelSelector({
|
|||
},
|
||||
{
|
||||
value: "vision" as const,
|
||||
icon: Eye,
|
||||
icon: ScanEye,
|
||||
label: "Vision",
|
||||
},
|
||||
] as const
|
||||
|
|
@ -1028,7 +1081,7 @@ export function ModelSelector({
|
|||
"flex",
|
||||
isMobile
|
||||
? "flex-col h-[60vh]"
|
||||
: "flex-row h-[420px]",
|
||||
: "flex-row h-[380px]",
|
||||
)}
|
||||
>
|
||||
{/* Provider sidebar */}
|
||||
|
|
@ -1037,33 +1090,30 @@ export function ModelSelector({
|
|||
{/* Main content */}
|
||||
<div className="flex flex-col min-w-0 min-h-0 flex-1 overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="relative px-3 py-2">
|
||||
<Search className="absolute left-5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/100 pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search models..."
|
||||
placeholder="Search models"
|
||||
value={searchQuery}
|
||||
onChange={(e) =>
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={!isMobile}
|
||||
onKeyDown={isMobile ? undefined : handleKeyDown}
|
||||
role="combobox"
|
||||
aria-expanded={true}
|
||||
aria-controls="model-selector-list"
|
||||
className={cn(
|
||||
"w-full pl-8 pr-3 py-1.5 text-xs rounded-lg",
|
||||
"bg-secondary/30 border border-border/40",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40",
|
||||
"placeholder:text-muted-foreground/50",
|
||||
"transition-[box-shadow,border-color] duration-200",
|
||||
"w-full pl-8 pr-3 py-2.5 text-sm bg-transparent",
|
||||
"focus:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider header when filtered */}
|
||||
{selectedProvider !== "all" && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border/40">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
{getProviderIcon(selectedProvider, {
|
||||
className: "size-4",
|
||||
})}
|
||||
|
|
@ -1085,10 +1135,15 @@ export function ModelSelector({
|
|||
id="model-selector-list"
|
||||
ref={modelListRef}
|
||||
role="listbox"
|
||||
className="overflow-y-auto flex-1 py-1"
|
||||
className="overflow-y-auto flex-1 py-1 space-y-1 flex flex-col"
|
||||
onScroll={handleModelListScroll}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${modelScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${modelScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${modelScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${modelScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{currentDisplayItems.length === 0 ? (
|
||||
<div className="py-8 flex flex-col items-center gap-3 px-4">
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 px-4">
|
||||
{selectedProvider !== "all" &&
|
||||
!configuredProviderSet.has(
|
||||
selectedProvider,
|
||||
|
|
@ -1116,22 +1171,21 @@ export function ModelSelector({
|
|||
</p>
|
||||
{addHandler && (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-1 gap-2"
|
||||
className="mt-1"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
addHandler(selectedProvider !== "all" ? selectedProvider : undefined);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
) : searchQuery ? (
|
||||
<>
|
||||
<Search className="size-8 text-muted-foreground/40" />
|
||||
<Search className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No models found
|
||||
</p>
|
||||
|
|
@ -1140,13 +1194,22 @@ export function ModelSelector({
|
|||
term
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No models configured
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 text-center">
|
||||
Configure models in your search space settings
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{globalItems.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider">
|
||||
Global Models
|
||||
</div>
|
||||
{globalItems.map((item, i) =>
|
||||
|
|
@ -1163,7 +1226,7 @@ export function ModelSelector({
|
|||
)}
|
||||
{userItems.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold text-muted-foreground tracking-wider">
|
||||
Your Configurations
|
||||
</div>
|
||||
{userItems.map((item, i) =>
|
||||
|
|
@ -1180,7 +1243,7 @@ export function ModelSelector({
|
|||
|
||||
{/* Add model button */}
|
||||
{addHandler && (
|
||||
<div className="p-2 border-t border-border/40 bg-muted/20 dark:bg-neutral-900">
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -1271,7 +1334,7 @@ export function ModelSelector({
|
|||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Eye className="size-4 text-muted-foreground" />
|
||||
<ScanEye className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -1301,7 +1364,7 @@ export function ModelSelector({
|
|||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[340px] md:w-[440px] p-0 rounded-lg shadow-lg bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||
className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
DeepSeekIcon,
|
||||
FireworksAiIcon,
|
||||
GeminiIcon,
|
||||
GitHubModelsIcon,
|
||||
GroqIcon,
|
||||
HuggingFaceIcon,
|
||||
MiniMaxIcon,
|
||||
|
|
@ -82,6 +83,8 @@ export function getProviderIcon(
|
|||
return <FireworksAiIcon className={cn(className)} />;
|
||||
case "GOOGLE":
|
||||
return <GeminiIcon className={cn(className)} />;
|
||||
case "GITHUB_MODELS":
|
||||
return <GitHubModelsIcon className={cn(className)} />;
|
||||
case "GROQ":
|
||||
return <GroqIcon className={cn(className)} />;
|
||||
case "HUGGINGFACE":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue