Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views. - Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts. - Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface. - Added loading and error states in ContactsListView for better user feedback during data fetching. - Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list. - Updated package.json to include @formkit/auto-animate for improved UI animations.
230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "react"
|
|
import { Plus, Trash2 } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
useLLMSettings,
|
|
useUpdateLLMSettings,
|
|
} from "@/lib/api/hooks/use-contact-discovery"
|
|
import type { ApiLLMProvider, ApiLLMSettings } from "@/lib/contacts/discovery-types"
|
|
import { LLMModelSuggestInput } from "@/components/gmail/settings/automation/llm-model-suggest-input"
|
|
import {
|
|
CONTACTS_MUTED_TEXT,
|
|
CONTACTS_PRIMARY_BTN_CLASS,
|
|
} from "@/lib/contacts-chrome-classes"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
function emptyProvider(): ApiLLMProvider {
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
name: "",
|
|
base_url: "https://api.openai.com/v1",
|
|
api_key: "",
|
|
default_model: "gpt-4o-mini",
|
|
}
|
|
}
|
|
|
|
export function LLMProvidersPanel() {
|
|
const { data: remote, isLoading } = useLLMSettings()
|
|
const updateSettings = useUpdateLLMSettings()
|
|
const [draft, setDraft] = useState<ApiLLMSettings>({
|
|
default_provider_id: "",
|
|
providers: [],
|
|
})
|
|
const [saved, setSaved] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (remote) {
|
|
setDraft({
|
|
...remote,
|
|
providers: remote.providers ?? [],
|
|
})
|
|
}
|
|
}, [remote])
|
|
|
|
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
|
|
setDraft((prev) => {
|
|
const providers = [...prev.providers]
|
|
providers[index] = { ...providers[index], ...patch }
|
|
return { ...prev, providers }
|
|
})
|
|
}
|
|
|
|
function addProvider() {
|
|
const p = emptyProvider()
|
|
setDraft((prev) => ({
|
|
...prev,
|
|
providers: [...prev.providers, p],
|
|
default_provider_id: prev.default_provider_id || p.id,
|
|
}))
|
|
}
|
|
|
|
function removeProvider(index: number) {
|
|
setDraft((prev) => {
|
|
const removed = prev.providers[index]
|
|
const providers = prev.providers.filter((_, i) => i !== index)
|
|
let defaultId = prev.default_provider_id
|
|
if (defaultId === removed?.id) {
|
|
defaultId = providers[0]?.id ?? ""
|
|
}
|
|
return { ...prev, providers, default_provider_id: defaultId }
|
|
})
|
|
}
|
|
|
|
async function handleSave() {
|
|
await updateSettings.mutateAsync(draft)
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 2000)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>Chargement…</p>
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl space-y-6">
|
|
<div>
|
|
<h3 className="text-base font-medium">Fournisseurs LLM</h3>
|
|
<p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
|
|
API OpenAI-compatibles pour l'enrichissement des contacts et le tri par règles.
|
|
</p>
|
|
</div>
|
|
|
|
{draft.providers.map((provider, index) => (
|
|
<div key={provider.id} className="space-y-3 rounded-lg border border-border p-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">{provider.name || `Fournisseur ${index + 1}`}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeProvider(index)}
|
|
aria-label="Supprimer"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<Label className="text-xs">Nom</Label>
|
|
<Input
|
|
className="mt-1 h-9"
|
|
value={provider.name}
|
|
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
|
placeholder="OpenAI"
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Label className="text-xs">URL de base</Label>
|
|
<Input
|
|
className="mt-1 h-9"
|
|
value={provider.base_url}
|
|
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
|
placeholder="https://api.openai.com/v1"
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Label className="text-xs">Clé API</Label>
|
|
<Input
|
|
className="mt-1 h-9"
|
|
type="password"
|
|
value={provider.api_key ?? ""}
|
|
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
|
placeholder="sk-…"
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Label className="text-xs">Modèle par défaut</Label>
|
|
<LLMModelSuggestInput
|
|
className="mt-1"
|
|
baseUrl={provider.base_url}
|
|
apiKey={provider.api_key}
|
|
value={provider.default_model}
|
|
onChange={(default_model) => updateProvider(index, { default_model })}
|
|
placeholder="gpt-4o-mini"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<Button variant="outline" onClick={addProvider}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Ajouter un fournisseur
|
|
</Button>
|
|
|
|
<div className="space-y-3 rounded-lg border border-border p-4">
|
|
<h4 className="text-sm font-medium">Découverte de contacts</h4>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<Label className="text-xs">Fournisseur par défaut</Label>
|
|
<Select
|
|
value={draft.default_provider_id}
|
|
onValueChange={(v) => setDraft((p) => ({ ...p, default_provider_id: v }))}
|
|
>
|
|
<SelectTrigger className="mt-1 h-9">
|
|
<SelectValue placeholder="Choisir…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{draft.providers.map((p) => (
|
|
<SelectItem key={p.id} value={p.id}>
|
|
{p.name || p.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Modèle pour l'enrichissement</Label>
|
|
<Select
|
|
value={draft.contact_discovery_provider_id ?? draft.default_provider_id}
|
|
onValueChange={(v) =>
|
|
setDraft((p) => ({ ...p, contact_discovery_provider_id: v }))
|
|
}
|
|
>
|
|
<SelectTrigger className="mt-1 h-9">
|
|
<SelectValue placeholder="Même que défaut" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{draft.providers.map((p) => (
|
|
<SelectItem key={p.id} value={p.id}>
|
|
{p.name || p.id}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Label className="text-xs">Modèle LLM</Label>
|
|
<Input
|
|
className="mt-1 h-9"
|
|
value={draft.contact_discovery_model ?? ""}
|
|
onChange={(e) =>
|
|
setDraft((p) => ({ ...p, contact_discovery_model: e.target.value }))
|
|
}
|
|
placeholder="Laisser vide pour utiliser le modèle par défaut du fournisseur"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={updateSettings.isPending}
|
|
className={CONTACTS_PRIMARY_BTN_CLASS}
|
|
>
|
|
{updateSettings.isPending ? "Enregistrement…" : saved ? "Enregistré ✓" : "Enregistrer"}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|