11import { useState , useEffect , useCallback } from "react" ;
2- import { Key , Trash2 , Check , Loader2 , AlertCircle , Shield } from "lucide-react" ;
2+ import { Key , Trash2 , Check , Loader2 , AlertCircle , Shield , RefreshCw , Monitor } from "lucide-react" ;
33import clsx from "clsx" ;
4- import { api , type ProviderRegistryEntry } from "@/lib/api" ;
4+ import { api , type ProviderRegistryEntry , type LocalProviderInfo } from "@/lib/api" ;
55
66interface ProviderKeyManagerProps {
77 /** Called when a key is saved/deleted so parent can react */
@@ -10,17 +10,25 @@ interface ProviderKeyManagerProps {
1010
1111export function ProviderKeyManager ( { onProvidersChanged } : ProviderKeyManagerProps ) {
1212 const [ providers , setProviders ] = useState < ProviderRegistryEntry [ ] > ( [ ] ) ;
13+ const [ localProviders , setLocalProviders ] = useState < LocalProviderInfo [ ] > ( [ ] ) ;
1314 const [ loading , setLoading ] = useState ( true ) ;
1415 const [ editingProvider , setEditingProvider ] = useState < string | null > ( null ) ;
1516 const [ keyInput , setKeyInput ] = useState ( "" ) ;
1617 const [ saving , setSaving ] = useState ( false ) ;
1718 const [ error , setError ] = useState < string | null > ( null ) ;
1819 const [ successProvider , setSuccessProvider ] = useState < string | null > ( null ) ;
20+ const [ editingLocalUrl , setEditingLocalUrl ] = useState < string | null > ( null ) ;
21+ const [ localUrlInput , setLocalUrlInput ] = useState ( "" ) ;
22+ const [ refreshingLocal , setRefreshingLocal ] = useState < string | null > ( null ) ;
1923
2024 const fetchRegistry = useCallback ( async ( ) => {
2125 try {
22- const res = await api . getProviderRegistry ( ) ;
23- setProviders ( res . providers ) ;
26+ const [ regRes , localRes ] = await Promise . all ( [
27+ api . getProviderRegistry ( ) ,
28+ api . getLocalProviders ( ) . catch ( ( ) => ( { providers : [ ] } ) ) ,
29+ ] ) ;
30+ setProviders ( regRes . providers ) ;
31+ setLocalProviders ( localRes . providers ) ;
2432 } catch {
2533 // silent — may not be connected yet
2634 } finally {
@@ -61,6 +69,35 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro
6169 }
6270 } , [ fetchRegistry , onProvidersChanged ] ) ;
6371
72+ const handleRefreshLocal = useCallback ( async ( name : string ) => {
73+ setRefreshingLocal ( name ) ;
74+ try {
75+ const updated = await api . refreshLocalProvider ( name ) ;
76+ setLocalProviders ( ( prev ) =>
77+ prev . map ( ( lp ) => lp . name === name ? { ...lp , ...updated } : lp )
78+ ) ;
79+ } catch { }
80+ setRefreshingLocal ( null ) ;
81+ } , [ ] ) ;
82+
83+ const handleSaveLocalUrl = useCallback ( async ( name : string ) => {
84+ if ( ! localUrlInput . trim ( ) ) return ;
85+ setSaving ( true ) ;
86+ setError ( null ) ;
87+ try {
88+ const updated = await api . setLocalProviderUrl ( name , localUrlInput . trim ( ) ) ;
89+ setLocalProviders ( ( prev ) =>
90+ prev . map ( ( lp ) => lp . name === name ? { ...lp , ...updated } : lp )
91+ ) ;
92+ setEditingLocalUrl ( null ) ;
93+ setLocalUrlInput ( "" ) ;
94+ } catch ( err ) {
95+ setError ( err instanceof Error ? err . message : "Failed to update URL" ) ;
96+ } finally {
97+ setSaving ( false ) ;
98+ }
99+ } , [ localUrlInput ] ) ;
100+
64101 if ( loading ) {
65102 return (
66103 < div className = "glass-card-static p-3 flex items-center justify-center" >
@@ -80,6 +117,118 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro
80117 }
81118
82119 return (
120+ < div className = "space-y-3" >
121+ { /* Local providers */ }
122+ { localProviders . length > 0 && (
123+ < div className = "glass-card-static overflow-hidden" >
124+ < div className = "flex items-center gap-1.5 px-3 h-8 border-b border-border-light" >
125+ < Monitor size = { 12 } strokeWidth = { 1.75 } className = "text-text-tertiary" />
126+ < span className = "text-[11px] font-semibold text-text-secondary uppercase tracking-wider" > Local</ span >
127+ </ div >
128+ { localProviders . map ( ( lp , idx ) => {
129+ const isEditingUrl = editingLocalUrl === lp . name ;
130+ const isRefreshing = refreshingLocal === lp . name ;
131+ const isLast = idx === localProviders . length - 1 ;
132+
133+ return (
134+ < div key = { lp . name } >
135+ < div
136+ className = { clsx (
137+ "flex items-center gap-2.5 h-10 px-3 transition-colors" ,
138+ ! isEditingUrl && "cursor-pointer hover:bg-bg-row-hover" ,
139+ ! isLast && ! isEditingUrl && "border-b border-border-light"
140+ ) }
141+ onClick = { ( ) => {
142+ if ( ! isEditingUrl ) {
143+ setEditingLocalUrl ( lp . name ) ;
144+ setLocalUrlInput ( lp . baseUrl ) ;
145+ setError ( null ) ;
146+ }
147+ } }
148+ >
149+ < div
150+ className = { clsx (
151+ "w-1.5 h-1.5 rounded-full shrink-0" ,
152+ lp . isOnline ? "bg-accent-green" : "bg-text-tertiary/40"
153+ ) }
154+ />
155+ < span className = "text-[13px] text-text-primary flex-1" > { lp . name } </ span >
156+ < div className = "flex items-center gap-1.5" >
157+ { lp . isOnline ? (
158+ < span className = "text-[11px] text-text-tertiary" >
159+ { lp . modelCount } model{ lp . modelCount !== 1 ? "s" : "" }
160+ </ span >
161+ ) : (
162+ < span className = "text-[11px] text-text-tertiary" > Offline</ span >
163+ ) }
164+ < button
165+ onClick = { ( e ) => {
166+ e . stopPropagation ( ) ;
167+ handleRefreshLocal ( lp . name ) ;
168+ } }
169+ className = "flex items-center justify-center w-6 h-6 rounded-md hover:bg-bg-input transition-colors"
170+ title = "Refresh"
171+ >
172+ < RefreshCw
173+ size = { 12 }
174+ strokeWidth = { 1.75 }
175+ className = { clsx ( "text-text-tertiary" , isRefreshing && "animate-spin" ) }
176+ />
177+ </ button >
178+ </ div >
179+ </ div >
180+
181+ { isEditingUrl && (
182+ < div className = { clsx ( "px-3 pb-2.5 pt-1" , ! isLast && "border-b border-border-light" ) } >
183+ < div className = "flex items-center gap-2" >
184+ < input
185+ type = "text"
186+ value = { localUrlInput }
187+ onChange = { ( e ) => {
188+ setLocalUrlInput ( e . target . value ) ;
189+ setError ( null ) ;
190+ } }
191+ placeholder = { `Base URL (e.g. http://localhost:11434/v1)` }
192+ className = "input input-mono flex-1 !h-8 !text-[11px]"
193+ autoFocus
194+ onKeyDown = { ( e ) => {
195+ if ( e . key === "Enter" ) handleSaveLocalUrl ( lp . name ) ;
196+ if ( e . key === "Escape" ) {
197+ setEditingLocalUrl ( null ) ;
198+ setError ( null ) ;
199+ }
200+ } }
201+ />
202+ < button
203+ onClick = { ( ) => handleSaveLocalUrl ( lp . name ) }
204+ disabled = { saving || ! localUrlInput . trim ( ) }
205+ className = { clsx (
206+ "btn-primary !h-8 !text-[11px] !px-3 shrink-0" ,
207+ ( saving || ! localUrlInput . trim ( ) ) && "opacity-40 cursor-not-allowed"
208+ ) }
209+ >
210+ { saving ? (
211+ < Loader2 size = { 12 } strokeWidth = { 1.75 } className = "animate-spin" />
212+ ) : (
213+ "Save"
214+ ) }
215+ </ button >
216+ </ div >
217+ { error && editingLocalUrl === lp . name && (
218+ < div className = "flex items-center gap-1.5 mt-1.5" >
219+ < AlertCircle size = { 12 } strokeWidth = { 1.75 } className = "text-accent-red shrink-0" />
220+ < span className = "text-[11px] text-accent-red" > { error } </ span >
221+ </ div >
222+ ) }
223+ </ div >
224+ ) }
225+ </ div >
226+ ) ;
227+ } ) }
228+ </ div >
229+ ) }
230+
231+ { /* Cloud providers */ }
83232 < div className = "glass-card-static overflow-hidden" >
84233 { providers . map ( ( p , idx ) => {
85234 const isEditing = editingProvider === p . name ;
@@ -200,5 +349,6 @@ export function ProviderKeyManager({ onProvidersChanged }: ProviderKeyManagerPro
200349 ) ;
201350 } ) }
202351 </ div >
352+ </ div >
203353 ) ;
204354}
0 commit comments