Some checks failed
- Fix MissingGreenlet: sync_engine now uses psycopg2 instead of asyncpg - Fix bcrypt/passlib compat: pin bcrypt<4.1 in requirements - Fix legacy password_hash NOT NULL: alter column to nullable in migration - Add frontend password validation (uppercase + lowercase + digit) - Add forgot-password and reset-password backend endpoints - Add forgot-password and reset-password frontend pages - Add email_service.py (SMTP via admin settings) - Add reset_token/reset_token_expires columns to User model - Migrate legacy JSON-only users to DB on password reset request - Mount data/ volume in docker-compose.local.yml for persistence - Add production deployment config (Dockerfile, nginx, deploy.sh) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
|
import { Search, List, Loader2, Check } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
export interface ModelOption {
|
|
id: string;
|
|
name?: string;
|
|
owned_by?: string;
|
|
context_length?: number;
|
|
pricing?: { prompt?: string; completion?: string };
|
|
}
|
|
|
|
interface ModelComboboxProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
models: ModelOption[];
|
|
isLoading: boolean;
|
|
onFetchModels: () => void;
|
|
providerLabel: string;
|
|
placeholder?: string;
|
|
}
|
|
|
|
export function ModelCombobox({
|
|
value,
|
|
onChange,
|
|
models,
|
|
isLoading,
|
|
onFetchModels,
|
|
providerLabel,
|
|
placeholder = "nom-du-modele",
|
|
}: ModelComboboxProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [filter, setFilter] = useState("");
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const listRef = useRef<HTMLUListElement>(null);
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
|
|
const filtered = React.useMemo(() => {
|
|
if (!filter.trim()) return models;
|
|
const q = filter.toLowerCase();
|
|
return models.filter(
|
|
(m) =>
|
|
m.id.toLowerCase().includes(q) ||
|
|
(m.name && m.name.toLowerCase().includes(q))
|
|
);
|
|
}, [models, filter]);
|
|
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, []);
|
|
|
|
useEffect(() => { setHighlightIndex(-1); }, [filter]);
|
|
|
|
useEffect(() => {
|
|
if (highlightIndex >= 0 && listRef.current) {
|
|
(listRef.current.children[highlightIndex] as HTMLElement)?.scrollIntoView({ block: "nearest" });
|
|
}
|
|
}, [highlightIndex]);
|
|
|
|
const handleBrowse = useCallback(() => {
|
|
if (!isOpen) onFetchModels();
|
|
setIsOpen(!isOpen);
|
|
}, [isOpen, onFetchModels]);
|
|
|
|
const handleSelect = useCallback(
|
|
(model: ModelOption) => {
|
|
onChange(model.id);
|
|
setFilter("");
|
|
setIsOpen(false);
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (!isOpen || filtered.length === 0) return;
|
|
if (e.key === "ArrowDown") { e.preventDefault(); setHighlightIndex((p) => Math.min(p + 1, filtered.length - 1)); }
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightIndex((p) => Math.max(p - 1, 0)); }
|
|
else if (e.key === "Enter" && highlightIndex >= 0) { e.preventDefault(); handleSelect(filtered[highlightIndex]); }
|
|
else if (e.key === "Escape") { setIsOpen(false); }
|
|
},
|
|
[isOpen, filtered, highlightIndex, handleSelect]
|
|
);
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<Input
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onFocus={() => { if (models.length > 0) setIsOpen(true); }}
|
|
onKeyDown={handleKeyDown}
|
|
leftIcon={<Search className="size-4" />}
|
|
loading={isLoading}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleBrowse}
|
|
disabled={isLoading}
|
|
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border-subtle bg-surface text-sm text-muted-foreground hover:bg-surface-elevated hover:text-foreground transition-colors disabled:opacity-50"
|
|
>
|
|
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <List className="size-4" />}
|
|
<span className="hidden sm:inline">Parcourir</span>
|
|
</button>
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border-subtle bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
|
|
{/* Filter */}
|
|
<div className="p-2 border-b border-border-subtle">
|
|
<Input
|
|
placeholder={`Filtrer...`}
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
leftIcon={<Search className="size-3.5" />}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* List */}
|
|
<ul ref={listRef} className="overflow-y-auto flex-1">
|
|
{filtered.length === 0 ? (
|
|
<li className="px-3 py-4 text-xs text-muted-foreground text-center">
|
|
{models.length === 0 ? "Aucun modele recupere" : "Aucun modele ne correspond"}
|
|
</li>
|
|
) : (
|
|
filtered.map((model, i) => (
|
|
<li
|
|
key={model.id}
|
|
onClick={() => handleSelect(model)}
|
|
onMouseEnter={() => setHighlightIndex(i)}
|
|
className={`flex items-center justify-between px-3 py-2 text-xs cursor-pointer transition-colors ${
|
|
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-surface-elevated"
|
|
} ${value === model.id ? "bg-primary/5" : ""}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-mono truncate text-foreground">{model.id}</div>
|
|
{model.name && model.name !== model.id && (
|
|
<div className="text-muted-foreground truncate mt-0.5">{model.name}</div>
|
|
)}
|
|
{model.context_length && (
|
|
<span className="text-muted-foreground">ctx: {(model.context_length / 1000).toFixed(0)}k</span>
|
|
)}
|
|
</div>
|
|
{value === model.id && <Check className="size-3.5 text-primary flex-shrink-0 ml-2" />}
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
|
|
{models.length > 0 && (
|
|
<div className="px-3 py-1.5 border-t border-border-subtle text-xs text-muted-foreground text-center bg-card">
|
|
{filtered.length} modele{filtered.length !== 1 ? "s" : ""}
|
|
{filter ? ` sur ${models.length}` : ""} — nom libre possible
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|