diff --git a/apps/gui/src/app/config/page.tsx b/apps/gui/src/app/config/page.tsx index 6c743f3..f87ce26 100644 --- a/apps/gui/src/app/config/page.tsx +++ b/apps/gui/src/app/config/page.tsx @@ -9,6 +9,7 @@ import { setActiveProviderInstance, getProviderMappings, setProviderMapping, + updateProviderInstance, } from "@/app/play/actions"; import type { LLMProviderInstance } from "@/lib/provider-manager"; @@ -26,9 +27,28 @@ export default function ConfigPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [newName, setNewName] = useState(""); - const [newProvider, setNewProvider] = useState("google-genai"); - const [newKey, setNewKey] = useState(""); + const [selectedInstanceId, setSelectedInstanceId] = useState("new"); + const [editName, setEditName] = useState(""); + const [editProvider, setEditProvider] = useState("google-genai"); + const [editKey, setEditKey] = useState(""); + const [editIsActive, setEditIsActive] = useState(false); + + useEffect(() => { + if (selectedInstanceId === "new") { + setEditName(""); + setEditProvider("google-genai"); + setEditKey(""); + setEditIsActive(false); + } else { + const inst = instances.find((i) => i.id === selectedInstanceId); + if (inst) { + setEditName(inst.name); + setEditProvider(inst.providerName); + setEditKey(""); + setEditIsActive(inst.isActive); + } + } + }, [selectedInstanceId, instances]); const loadInstances = useCallback(async () => { try { @@ -67,27 +87,35 @@ export default function ConfigPage() { loadAll(); }, [loadAll]); - const handleAddInstance = async (e: React.FormEvent) => { + const handleSave = async (e: React.FormEvent) => { e.preventDefault(); - if (!newName.trim() || !newKey.trim()) return; - try { - setLoading(true); - await createProviderInstance(newName, newProvider, newKey); - setNewName(""); - setNewKey(""); - await loadInstances(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); + if (!editName.trim()) { + setError("Name is required."); + return; } - }; - const handleDeleteInstance = async (id: string) => { - if (!confirm("Are you sure you want to delete this provider instance?")) return; try { setLoading(true); - await deleteProviderInstance(id); + setError(""); + + if (selectedInstanceId === "new") { + if (!editKey.trim()) { + setError("API Key is required for new instances."); + setLoading(false); + return; + } + const created = await createProviderInstance(editName, editProvider, editKey); + if (editIsActive) { + await setActiveProviderInstance(created.id); + } + setSelectedInstanceId(created.id); + } else { + await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined); + if (editIsActive) { + await setActiveProviderInstance(selectedInstanceId); + } + } + await loadInstances(); await loadMappings(); } catch (err) { @@ -97,11 +125,17 @@ export default function ConfigPage() { } }; - const handleSetActiveInstance = async (id: string) => { + const handleDelete = async () => { + if (selectedInstanceId === "new") return; + if (!confirm("Are you sure you want to delete this provider instance?")) return; + try { setLoading(true); - await setActiveProviderInstance(id); + setError(""); + await deleteProviderInstance(selectedInstanceId); + setSelectedInstanceId("new"); await loadInstances(); + await loadMappings(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -132,99 +166,125 @@ export default function ConfigPage() { <>

LLM Provider Instances

-
- {instances.length === 0 ? ( -
- No custom LLM provider instances configured. Defaulting to the environment GOOGLE_API_KEY if present. -
- ) : ( - - - - - - - - - - - - {instances.map((inst) => ( - - - - - - - - ))} - -
Friendly NameProvider TypeAPI Key PreviewStatusActions
{inst.name}{inst.providerName} - - {inst.apiKey.substring(0, 10)}...{inst.apiKey.substring(inst.apiKey.length - 4)} - - - {inst.isActive ? ( - Active - ) : ( - Inactive - )} - -
- {!inst.isActive && ( - - )} - -
-
- )} -
- -
-

Add LLM Provider Instance

-
-
- - setNewName(e.target.value)} - placeholder="e.g. Gemini - Production" - required - /> -
-
- - + + Add +
-
- - setNewKey(e.target.value)} - placeholder="AIzaSy..." - required - /> +
+ {instances.length === 0 ? ( +
No instances configured
+ ) : ( + instances.map((inst) => ( +
setSelectedInstanceId(inst.id)} + className={`instance-list-item ${selectedInstanceId === inst.id ? "active" : ""}`} + > +
{inst.name}
+
+ {inst.providerName} + {inst.isActive && Active} +
+
+ )) + )}
-
- + + {/* 70% area */} +
+
+
+

+ {selectedInstanceId === "new" + ? "Create New Provider Instance" + : `Configure: ${editName}`} +

+ +
+ + setEditName(e.target.value)} + placeholder="e.g. Gemini - Production" + required + /> +
+ +
+ + +
+ +
+ + setEditKey(e.target.value)} + placeholder={ + selectedInstanceId === "new" + ? "AIzaSy..." + : "•••••••• (unchanged)" + } + required={selectedInstanceId === "new"} + /> +
+ +
+ setEditIsActive(e.target.checked)} + /> + +
+
+ +
+
+ {selectedInstanceId !== "new" && ( + + )} +
+
+ +
+
+
+
+ +
@@ -451,57 +511,207 @@ export default function ConfigPage() { .btn-sm.delete-btn:hover { background: #dc2626; } - .add-instance-form { - margin-top: 1.5rem; - background: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 8px; - padding: 1.25rem; - } - .add-instance-form h3 { - font-size: 0.95rem; - margin-bottom: 0.75rem; - color: #111; - } - .form-fields { + /* Split container */ + .provider-split-container { display: grid; grid-template-columns: 1fr; - gap: 1rem; - align-items: flex-end; + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; + background: #fff; + margin-top: 1rem; + min-height: 400px; } @media (min-width: 768px) { - .form-fields { - grid-template-columns: 1fr 1fr 1.5fr auto; + .provider-split-container { + grid-template-columns: 30% 70%; } } - .form-fields .field { + + /* 30% List Pane */ + .provider-list-pane { + border-right: 1px solid #e5e7eb; + background: #f9fafb; display: flex; flex-direction: column; - gap: 0.25rem; } - .form-fields .field label { - font-size: 0.75rem; + .pane-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid #e5e7eb; + background: #f3f4f6; + } + .pane-header h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: #111; + } + .btn-add-inst { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; font-weight: 500; - } - .form-fields .field input, - .form-fields .field select { - padding: 0.375rem 0.5rem; - font-size: 0.8125rem; - border: 1px solid #ccc; - border-radius: 4px; - background: #fff; - } - .btn-add { - padding: 0.375rem 1rem; - font-size: 0.8125rem; background: #10b981; color: #fff; border: none; - border-radius: 4px; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; + } + .btn-add-inst:hover { + background: #059669; + } + .pane-list { + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + } + .no-instances-msg { + padding: 2rem 1rem; + text-align: center; + color: #6b7280; + font-size: 0.8125rem; + } + .instance-list-item { + padding: 1rem; + border-bottom: 1px solid #e5e7eb; + cursor: pointer; + transition: background 0.15s, border-left 0.15s; + border-left: 3px solid transparent; + } + .instance-list-item:hover { + background: #f3f4f6; + } + .instance-list-item.active { + background: #eff6ff; + border-left: 3px solid #3b82f6; + } + .item-name { + font-weight: 500; + font-size: 0.875rem; + color: #111; + } + .item-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.25rem; + font-size: 0.75rem; + color: #6b7280; + } + .active-pill { + background: #dcfce7; + color: #15803d; + font-weight: 600; + padding: 0.0625rem 0.375rem; + border-radius: 9999px; + } + + /* 70% Form Pane */ + .provider-form-pane { + background: #fff; + display: flex; + flex-direction: column; + } + .provider-config-form { + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + } + .form-scroll-content { + padding: 1.5rem; + flex: 1; + display: flex; + flex-direction: column; + gap: 1.25rem; + } + .form-scroll-content h3 { + margin: 0 0 0.5rem 0; + font-size: 1.125rem; + font-weight: 600; + color: #111; + } + .form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + .form-group label { + font-size: 0.8125rem; + font-weight: 500; + color: #374151; + } + .form-group input[type="text"], + .form-group input[type="password"], + .form-group select { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + } + .form-group input:focus, + .form-group select:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } + .checkbox-group { + flex-direction: row; + align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; + } + .checkbox-group label { cursor: pointer; } - .btn-add:hover { - background: #059669; + .checkbox-group input { + width: 1rem; + height: 1rem; + cursor: pointer; + } + + /* Action bar */ + .form-actions-bar { + padding: 1rem 1.5rem; + border-top: 1px solid #e5e7eb; + background: #f9fafb; + display: flex; + justify-content: space-between; + align-items: center; + } + .btn-delete-pane { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + background: #ef4444; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; + } + .btn-delete-pane:hover { + background: #dc2626; + } + .btn-save-pane { + padding: 0.5rem 1.25rem; + font-size: 0.875rem; + font-weight: 500; + background: #2563eb; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; + } + .btn-save-pane:hover { + background: #1d4ed8; } /* Task Provider Routing Styles */ diff --git a/apps/gui/src/app/play/actions.ts b/apps/gui/src/app/play/actions.ts index b200722..47c464c 100644 --- a/apps/gui/src/app/play/actions.ts +++ b/apps/gui/src/app/play/actions.ts @@ -254,6 +254,15 @@ export async function setActiveProviderInstance(id: string): Promise { ProviderManager.setActive(id); } +export async function updateProviderInstance( + id: string, + name: string, + providerName: string, + apiKey?: string, +): Promise { + ProviderManager.update(id, name, providerName, apiKey); +} + export async function getProviderMappings(): Promise> { return ProviderManager.getMappings(); } diff --git a/apps/gui/src/lib/provider-manager.ts b/apps/gui/src/lib/provider-manager.ts index b81b482..9eac049 100644 --- a/apps/gui/src/lib/provider-manager.ts +++ b/apps/gui/src/lib/provider-manager.ts @@ -94,6 +94,27 @@ export class ProviderManager { } } + static update(id: string, name: string, providerName: string, apiKey?: string): void { + const db = getSettingsDb(); + try { + if (apiKey && apiKey.trim()) { + db.prepare(` + UPDATE provider_instances + SET name = ?, providerName = ?, apiKey = ? + WHERE id = ? + `).run(name, providerName, apiKey, id); + } else { + db.prepare(` + UPDATE provider_instances + SET name = ?, providerName = ? + WHERE id = ? + `).run(name, providerName, id); + } + } finally { + db.close(); + } + } + static getActive(): LLMProviderInstance | null { const db = getSettingsDb(); try {