diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 00000000..46e2a544 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2024-05-24 - Accessibility for Icon-Only Buttons +**Learning:** Icon-only buttons used across settings tabs often lack ARIA labels and proper focus visibility for keyboard navigation, and include SVG paths that screen readers redundantly announce. +**Action:** When updating or creating icon-only buttons, consistently apply `aria-label`, `title`, `aria-hidden="true"` on inner SVGs, and standard focus rings like `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#175B37]`. diff --git a/src/components/settings/OllamaTab.tsx b/src/components/settings/OllamaTab.tsx index a628a314..1c73cddf 100644 --- a/src/components/settings/OllamaTab.tsx +++ b/src/components/settings/OllamaTab.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'preact/hooks'; -import FormField from '../ui/FormField'; +import { useState, useEffect } from "preact/hooks"; +import FormField from "../ui/FormField"; type OllamaInstance = { id: number; @@ -21,46 +21,58 @@ type OllamaInstance = { export default function OllamaTab() { const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [compatibility, setCompatibility] = useState>({}); + const [error, setError] = useState(""); + const [compatibility, setCompatibility] = useState< + Record< + string, + { ok: boolean; testedAt?: string; disabledManually?: boolean } + > + >({}); const [testingModel, setTestingModel] = useState(null); - - const [newName, setNewName] = useState(''); - const [newUrl, setNewUrl] = useState(''); - const [newApiKey, setNewApiKey] = useState(''); + + const [newName, setNewName] = useState(""); + const [newUrl, setNewUrl] = useState(""); + const [newApiKey, setNewApiKey] = useState(""); const [adding, setAdding] = useState(false); const [batchTesting, setBatchTesting] = useState(false); - const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, modelName: '' }); + const [batchProgress, setBatchProgress] = useState({ + current: 0, + total: 0, + modelName: "", + }); const [fixingAgents, setFixingAgents] = useState(false); const runTest = async (model: string, origin: string) => { try { - const res = await fetch('/api/test-ollama-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/test-ollama-model", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, origin }), }); const data = await res.json(); - setCompatibility(prev => ({ + setCompatibility((prev) => ({ ...prev, - [model]: { ok: data.ok, testedAt: new Date().toISOString() } + [model]: { ok: data.ok, testedAt: new Date().toISOString() }, })); } catch (e) { console.error(`Error testing ${model}:`, e); } }; - const toggleManualDisable = async (model: string, currentlyDisabled: boolean) => { + const toggleManualDisable = async ( + model: string, + currentlyDisabled: boolean, + ) => { try { - const res = await fetch('/api/toggle-ollama-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/toggle-ollama-model", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, disabled: !currentlyDisabled }), }); if (res.ok) { - setCompatibility(prev => ({ + setCompatibility((prev) => ({ ...prev, - [model]: { ...prev[model], disabledManually: !currentlyDisabled } + [model]: { ...prev[model], disabledManually: !currentlyDisabled }, })); } } catch (e) { @@ -70,9 +82,9 @@ export default function OllamaTab() { const testAllModels = async () => { const allModels: Array<{ model: string; origin: string }> = []; - instances.forEach(inst => { + instances.forEach((inst) => { if (inst.enabled && inst.health?.ok && inst.health.models) { - inst.health.models.forEach(m => { + inst.health.models.forEach((m) => { allModels.push({ model: m, origin: inst.normalizedUrl || inst.url }); }); } @@ -81,11 +93,15 @@ export default function OllamaTab() { if (allModels.length === 0) return; setBatchTesting(true); - setBatchProgress({ current: 0, total: allModels.length, modelName: '' }); + setBatchProgress({ current: 0, total: allModels.length, modelName: "" }); for (let i = 0; i < allModels.length; i++) { const { model, origin } = allModels[i]; - setBatchProgress({ current: i + 1, total: allModels.length, modelName: model }); + setBatchProgress({ + current: i + 1, + total: allModels.length, + modelName: model, + }); await runTest(model, origin); } @@ -95,7 +111,7 @@ export default function OllamaTab() { const fixAgentModels = async () => { setFixingAgents(true); try { - const res = await fetch('/api/fix-agent-models', { method: 'POST' }); + const res = await fetch("/api/fix-agent-models", { method: "POST" }); const data = await res.json(); if (data.ok) { alert(data.message); @@ -108,19 +124,19 @@ export default function OllamaTab() { }; const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [editUrl, setEditUrl] = useState(''); - const [editApiKey, setEditApiKey] = useState(''); + const [editName, setEditName] = useState(""); + const [editUrl, setEditUrl] = useState(""); + const [editApiKey, setEditApiKey] = useState(""); const load = async () => { setLoading(true); try { const [instRes, compRes] = await Promise.all([ - fetch('/api/ollama-instances'), - fetch('/api/test-ollama-model') + fetch("/api/ollama-instances"), + fetch("/api/test-ollama-model"), ]); - - if (!instRes.ok) throw new Error('Échec du chargement des instances'); + + if (!instRes.ok) throw new Error("Échec du chargement des instances"); const instData = await instRes.json(); setInstances(instData); @@ -135,21 +151,23 @@ export default function OllamaTab() { } }; - useEffect(() => { load(); }, []); + useEffect(() => { + load(); + }, []); const addInstance = async () => { if (!newName || !newUrl) return; setAdding(true); try { - const res = await fetch('/api/ollama-instances', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/ollama-instances", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName, url: newUrl, apiKey: newApiKey }), }); - if (!res.ok) throw new Error('Échec de l\'ajout'); - setNewName(''); - setNewUrl(''); - setNewApiKey(''); + if (!res.ok) throw new Error("Échec de l'ajout"); + setNewName(""); + setNewUrl(""); + setNewApiKey(""); load(); } catch (e: any) { setError(e.message); @@ -162,18 +180,23 @@ export default function OllamaTab() { setEditingId(inst.id); setEditName(inst.name); setEditUrl(inst.url); - setEditApiKey(inst.apiKey || ''); + setEditApiKey(inst.apiKey || ""); }; const saveEdit = async () => { if (!editingId) return; try { - const res = await fetch('/api/ollama-instances', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: editingId, name: editName, url: editUrl, apiKey: editApiKey }), + const res = await fetch("/api/ollama-instances", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: editingId, + name: editName, + url: editUrl, + apiKey: editApiKey, + }), }); - if (!res.ok) throw new Error('Échec de la modification'); + if (!res.ok) throw new Error("Échec de la modification"); setEditingId(null); load(); } catch (e: any) { @@ -183,10 +206,13 @@ export default function OllamaTab() { const toggleInstance = async (instance: OllamaInstance) => { try { - await fetch('/api/ollama-instances', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: instance.id, enabled: instance.enabled ? 0 : 1 }), + await fetch("/api/ollama-instances", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: instance.id, + enabled: instance.enabled ? 0 : 1, + }), }); load(); } catch (e: any) { @@ -195,9 +221,9 @@ export default function OllamaTab() { }; const deleteInstance = async (id: number) => { - if (!confirm('Supprimer cette instance ?')) return; + if (!confirm("Supprimer cette instance ?")) return; try { - await fetch(`/api/ollama-instances?id=${id}`, { method: 'DELETE' }); + await fetch(`/api/ollama-instances?id=${id}`, { method: "DELETE" }); load(); } catch (e: any) { setError(e.message); @@ -207,9 +233,9 @@ export default function OllamaTab() { const testModel = async (model: string, origin: string) => { setTestingModel(model); try { - const res = await fetch('/api/test-ollama-model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/test-ollama-model", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, origin }), }); const data = await res.json(); @@ -230,7 +256,8 @@ export default function OllamaTab() {

Instances Ollama

- Gérez vos différents serveurs Ollama. Forge agrégera les modèles de toutes les instances actives pour votre matrice d'agents. + Gérez vos différents serveurs Ollama. Forge agrégera les modèles de + toutes les instances actives pour votre matrice d'agents.

@@ -242,11 +269,24 @@ export default function OllamaTab() { {batchTesting ? ( <> - Test : {batchProgress.modelName} ({batchProgress.current}/{batchProgress.total}) + Test : {batchProgress.modelName} ({batchProgress.current}/ + {batchProgress.total}) ) : ( <> - + + + Tester tous les modèles )} @@ -260,7 +300,19 @@ export default function OllamaTab() { {fixingAgents ? ( ) : ( - + + + )} Corriger l'équipe @@ -269,15 +321,19 @@ export default function OllamaTab() { {batchTesting && (
-
)}
-

Ajouter une instance

+

+ Ajouter une instance +

setNewApiKey((e.target as HTMLInputElement).value)} + onInput={(e) => + setNewApiKey((e.target as HTMLInputElement).value) + } class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-[#175B37] font-mono" /> @@ -312,36 +370,74 @@ export default function OllamaTab() { disabled={adding || !newName || !newUrl} class="bg-[#175B37] text-white px-4 py-2 rounded-full text-xs font-bold hover:bg-[#0f3d25] disabled:opacity-50 transition-colors" > - {adding ? 'Ajout...' : 'Ajouter l\'instance'} + {adding ? "Ajout..." : "Ajouter l'instance"}
-

Vos déploiements

+

+ Vos déploiements +

{loading ? (

Chargement...

) : instances.length === 0 ? ( -

Aucune instance configurée.

+

+ Aucune instance configurée. +

) : (
{instances.map((inst) => ( -
+
{editingId === inst.id ? (
- setEditName((e.target as HTMLInputElement).value)} class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-[#175B37]" /> + + setEditName((e.target as HTMLInputElement).value) + } + class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-[#175B37]" + /> - setEditUrl((e.target as HTMLInputElement).value)} class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-[#175B37] font-mono" /> + + setEditUrl((e.target as HTMLInputElement).value) + } + class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-[#175B37] font-mono" + /> - setEditApiKey((e.target as HTMLInputElement).value)} class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-[#175B37] font-mono" /> + + setEditApiKey((e.target as HTMLInputElement).value) + } + class="w-full bg-white border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-[#175B37] font-mono" + />
- - + +
) : ( @@ -351,98 +447,218 @@ export default function OllamaTab() { -

{inst.name}

+

+ {inst.name} +

{inst.apiKey && ( - Auth + + Auth + )}
-

{inst.normalizedUrl || inst.url}

+

+ {inst.normalizedUrl || inst.url} +

{inst.enabled ? ( <> -

+

{inst.health?.ok ? `OK via ${inst.health.endpoint} (HTTP ${inst.health.status})` - : inst.health?.error || 'Indisponible'} + : inst.health?.error || "Indisponible"}

- {inst.health?.ok && inst.health.models && inst.health.models.length > 0 && ( -
- {inst.health.models.map((m) => { - const comp = compatibility[m]; - const isTesting = batchTesting && batchProgress.modelName === m; - const isDisabled = comp?.disabledManually; + {inst.health?.ok && + inst.health.models && + inst.health.models.length > 0 && ( +
+ {inst.health.models.map((m) => { + const comp = compatibility[m]; + const isTesting = + batchTesting && + batchProgress.modelName === m; + const isDisabled = comp?.disabledManually; - return ( -
- {m} - - {comp && !isDisabled && ( - + - {comp.ok ? 'Forge OK' : 'Forge KO'} + {m} - )} - {isDisabled && ( - Désactivé - )} + {comp && !isDisabled && ( + + {comp.ok ? "Forge OK" : "Forge KO"} + + )} -
- - + {isDisabled && ( + + Désactivé + + )} + +
+ + +
-
- ); - })} -
- )} + ); + })} +
+ )} ) : ( -

Instance désactivée

+

+ Instance désactivée +

)}
- - -