feat: Add subfolder support for model specs (#9165)

- Add optional `folder` field to TModelSpec type and schema
- Create ModelSpecFolder component for hierarchical display
- Implement expand/collapse functionality for folders
- Update search results to show folder context
- Sort folders alphabetically and specs by order/label
- Maintain backward compatibility (specs without folders appear at root)

This enhancement allows organizing model specs into categories/folders
for better organization and improved user experience when dealing with
many model configurations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
constanttime 2025-08-24 23:37:53 +05:30
parent 543b617e1c
commit 4fe600990c
7 changed files with 439 additions and 3 deletions

View file

@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
import type { ModelSelectorProps } from '~/common';
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
import { renderModelSpecsWithFolders, renderEndpoints, renderSearchResults } from './components';
import { getSelectedIcon, getDisplayValue } from './utils';
import { CustomMenu as Menu } from './CustomMenu';
import DialogManager from './DialogManager';
@ -86,7 +86,7 @@ function ModelSelectorContent() {
renderSearchResults(searchResults, localize, searchValue)
) : (
<>
{renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')}
{renderModelSpecsWithFolders(modelSpecs, selectedValues.modelSpec || '')}
{renderEndpoints(mappedEndpoints ?? [])}
</>
)}

View file

@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react';
import type { TModelSpec } from 'librechat-data-provider';
import { ModelSpecItem } from './ModelSpecItem';
import { cn } from '~/utils';
interface ModelSpecFolderProps {
folderName: string;
specs: TModelSpec[];
selectedSpec: string;
level?: number;
}
export function ModelSpecFolder({
folderName,
specs,
selectedSpec,
level = 0
}: ModelSpecFolderProps) {
const [isExpanded, setIsExpanded] = useState(true);
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
const indent = level * 16;
return (
<div className="w-full">
<button
onClick={handleToggle}
className={cn(
'flex w-full items-center gap-1 rounded-lg px-2 py-1.5 text-sm hover:bg-surface-hover',
'text-text-secondary transition-colors'
)}
style={{ paddingLeft: `${8 + indent}px` }}
>
<span className="flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</span>
<span className="flex-shrink-0">
{isExpanded ? (
<FolderOpen className="h-3.5 w-3.5" />
) : (
<Folder className="h-3.5 w-3.5" />
)}
</span>
<span className="truncate text-left font-medium">{folderName}</span>
</button>
{isExpanded && (
<div className="mt-0.5">
{specs.map((spec) => (
<div key={spec.name} style={{ paddingLeft: `${indent}px` }}>
<ModelSpecItem spec={spec} isSelected={selectedSpec === spec.name} />
</div>
))}
</div>
)}
</div>
);
}
interface GroupedSpecs {
[folder: string]: TModelSpec[];
}
export function renderModelSpecsWithFolders(specs: TModelSpec[], selectedSpec: string) {
if (!specs || specs.length === 0) {
return null;
}
// Group specs by folder
const grouped: GroupedSpecs = {};
const rootSpecs: TModelSpec[] = [];
specs.forEach((spec) => {
if (spec.folder) {
if (!grouped[spec.folder]) {
grouped[spec.folder] = [];
}
grouped[spec.folder].push(spec);
} else {
rootSpecs.push(spec);
}
});
// Sort folders alphabetically
const sortedFolders = Object.keys(grouped).sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase())
);
// Sort specs within each folder by order or label
sortedFolders.forEach(folder => {
grouped[folder].sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
});
});
// Sort root specs
rootSpecs.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
});
return (
<>
{/* Render folders first */}
{sortedFolders.map((folder) => (
<ModelSpecFolder
key={folder}
folderName={folder}
specs={grouped[folder]}
selectedSpec={selectedSpec}
/>
))}
{/* Render root level specs */}
{rootSpecs.map((spec) => (
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
))}
</>
);
}

View file

@ -67,7 +67,12 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
</div>
)}
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate text-left">{spec.label}</span>
<span className="truncate text-left">
{spec.folder && (
<span className="text-xs text-text-tertiary">{spec.folder} / </span>
)}
{spec.label}
</span>
{spec.description && (
<span className="break-words text-xs font-normal">{spec.description}</span>
)}

View file

@ -1,4 +1,5 @@
export * from './ModelSpecItem';
export * from './ModelSpecFolder';
export * from './EndpointModelItem';
export * from './EndpointItem';
export * from './SearchResults';