11import { useState , useEffect , useMemo , useCallback } from 'react' ;
22import { AIConfig , AIConfigPopupProps , KeyProtectionLevel } from '../types/components/AIAssistant.types' ;
3+ import { useDebounce } from 'use-debounce' ;
34import useAppStore from '../store/store' ;
45import {
56 isWebAuthnPRFSupported ,
67 encryptAndStoreApiKey ,
78 loadAndDecryptApiKey ,
89 clearStoredKey ,
910} from '../utils/secureKeyStorage' ;
11+ import { AiOutlineEye , AiOutlineEyeInvisible } from "react-icons/ai" ;
1012
1113const AIConfigPopup = ( { isOpen, onClose } : AIConfigPopupProps ) => {
1214 const { backgroundColor, keyProtectionLevel, setKeyProtectionLevel } = useAppStore ( ( state ) => ( {
@@ -115,18 +117,150 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
115117 }
116118 } , [ setKeyProtectionLevel ] ) ;
117119
120+ const [ availableModels , setAvailableModels ] = useState < string [ ] > ( [ ] ) ;
121+ const [ showApiKey , setShowApiKey ] = useState < boolean > ( false ) ;
122+ const [ debouncedApiKey ] = useDebounce ( apiKey , 1000 ) ;
123+
118124 useEffect ( ( ) => {
119125 if ( isOpen ) {
120126 setSecurityMessage ( '' ) ;
121127 loadConfig ( ) . catch ( console . warn ) ;
122128 }
123129 } , [ isOpen , loadConfig ] ) ;
124130
125- const handleSave = async ( ) => {
126- setIsEncrypting ( true ) ;
127- setSecurityMessage ( '' ) ;
131+ useEffect ( ( ) => {
132+ setAvailableModels ( [ ] ) ;
133+ setModel ( '' ) ;
134+ if ( ! provider || ! debouncedApiKey ) return ;
135+
136+ const controller = new AbortController ( ) ;
137+ const signal = controller . signal ;
128138
129- // Save non-sensitive settings to localStorage (no encryption needed)
139+ const fetchModels = async ( ) => {
140+ try {
141+ switch ( provider ) {
142+ case 'openai' :
143+ case 'openai-compatible' :
144+ if ( ! apiKey ) return ;
145+
146+ let endpoint = provider === 'openai-compatible' ? customEndpoint : 'https://api.openai.com/v1' ;
147+ if ( ! endpoint ) return ;
148+ endpoint = endpoint . replace ( / \/ $ / , '' ) ;
149+ const url = `${ endpoint } /models` ;
150+
151+ const res = await fetch ( url , {
152+ headers : { Authorization : `Bearer ${ apiKey } ` } ,
153+ signal,
154+ } ) ;
155+
156+ if ( ! res . ok ) {
157+ console . error ( `Fetch error (${ res . status } ): ${ res . statusText } ` ) ;
158+ return ;
159+ }
160+
161+ const data = await res . json ( ) ;
162+ if ( ! signal . aborted ) setAvailableModels ( data . data ?. map ( ( m : any ) => m . id ) || [ ] ) ;
163+ break ;
164+
165+ case 'anthropic' :
166+ if ( ! apiKey ) return ;
167+ {
168+ const res = await fetch ( 'https://api.anthropic.com/v1/models' , {
169+ headers : {
170+ 'x-api-key' : apiKey ,
171+ 'anthropic-version' : '2023-06-01' ,
172+ 'content-type' : 'application/json' ,
173+ } ,
174+ signal,
175+ } ) ;
176+ if ( ! res . ok ) {
177+ console . error ( `Fetch error (${ res . status } ): ${ res . statusText } ` ) ;
178+ return ;
179+ }
180+ const data = await res . json ( ) ;
181+ if ( ! signal . aborted ) setAvailableModels ( data . models ?. map ( ( m : any ) => m . name ) || [ ] ) ;
182+ }
183+ break ;
184+
185+ case 'google' :
186+ if ( ! apiKey ) return ;
187+ {
188+ const res = await fetch ( 'https://generativelanguage.googleapis.com/v1beta2/models' , {
189+ headers : { 'x-goog-api-key' : apiKey } ,
190+ signal,
191+ } ) ;
192+ if ( ! res . ok ) {
193+ console . error ( `Fetch error (${ res . status } ): ${ res . statusText } ` ) ;
194+ return ;
195+ }
196+ const data = await res . json ( ) ;
197+ if ( ! signal . aborted ) setAvailableModels ( data . models ?. map ( ( m : any ) => m . name ) || [ ] ) ;
198+ }
199+ break ;
200+
201+ case 'mistral' :
202+ if ( ! apiKey ) return ;
203+ {
204+ const res = await fetch ( 'https://api.mistral.ai/v1/models' , {
205+ headers : { Authorization : `Bearer ${ apiKey } ` } ,
206+ signal,
207+ } ) ;
208+ if ( ! res . ok ) {
209+ console . error ( `Fetch error (${ res . status } ): ${ res . statusText } ` ) ;
210+ return ;
211+ }
212+ const data = await res . json ( ) ;
213+ if ( ! signal . aborted ) setAvailableModels ( data . models ?. map ( ( m : any ) => m . name ) || [ ] ) ;
214+ }
215+ break ;
216+
217+ case 'ollama' :
218+ {
219+ const res = await fetch ( 'http://localhost:11434/api/tags' , { signal } ) ;
220+ if ( ! res . ok ) {
221+ console . error ( `Ollama fetch failed: ${ res . statusText } ` ) ;
222+ return ;
223+ }
224+ const data = await res . json ( ) ;
225+ if ( ! signal . aborted ) setAvailableModels ( data . models ?. map ( ( m : any ) => m . name || m . model ) || [ ] ) ;
226+ }
227+ break ;
228+
229+ case 'openrouter' :
230+ if ( ! apiKey ) return ;
231+ {
232+ const res = await fetch ( 'https://openrouter.ai/api/v1/models' , {
233+ headers : { Authorization : `Bearer ${ apiKey } ` } ,
234+ signal,
235+ } ) ;
236+ if ( ! res . ok ) {
237+ console . error ( `Fetch error (${ res . status } ): ${ res . statusText } ` ) ;
238+ return ;
239+ }
240+ const data = await res . json ( ) ;
241+ if ( ! signal . aborted ) setAvailableModels ( data . models ?. map ( ( m : any ) => m . name ) || [ ] ) ;
242+ }
243+ break ;
244+
245+ default :
246+ setAvailableModels ( [ ] ) ;
247+ }
248+ } catch ( err : any ) {
249+ if ( err . name !== 'AbortError' ) {
250+ console . error ( 'Failed to fetch models:' , err ) ;
251+ setAvailableModels ( [ ] ) ;
252+ }
253+ }
254+ } ;
255+
256+ void fetchModels ( ) ;
257+
258+ return ( ) => {
259+ controller . abort ( ) ;
260+ } ;
261+ } , [ provider , debouncedApiKey , customEndpoint ] ) ;
262+
263+ const handleSave = async ( ) => {
130264 localStorage . setItem ( 'aiProvider' , provider ) ;
131265 localStorage . setItem ( 'aiModel' , model ) ;
132266
@@ -298,17 +432,72 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
298432 </ div >
299433 ) }
300434
435+ < div className = "relative" >
436+ < label className = { `block text-sm font-medium ${ theme . label } mb-1` } >
437+ API Key
438+ </ label >
439+ < div className = "flex items-center" >
440+ < input
441+ type = { showApiKey ? "text" : "password" }
442+ value = { apiKey }
443+ onChange = { ( e ) => setApiKey ( e . target . value ) }
444+ placeholder = "Enter API key"
445+ className = { `flex-1 p-2 border rounded-lg focus:outline-none focus:ring-2 ${ theme . input } ` }
446+ />
447+ < button
448+ type = "button"
449+ onClick = { ( ) => setShowApiKey ( ! showApiKey ) }
450+ className = { `ml-2 p-2 rounded ${ theme . closeButton } ` }
451+ >
452+ { showApiKey ? < AiOutlineEyeInvisible /> : < AiOutlineEye /> }
453+ </ button >
454+ </ div >
455+
456+ { /* Security status indicators */ }
457+ { keyProtectionLevel === 'webauthn' && (
458+ < div className = "mt-1 text-xs text-green-500 flex items-center gap-1" >
459+ < svg xmlns = "http://www.w3.org/2000/svg" className = "h-3.5 w-3.5" viewBox = "0 0 20 20" fill = "currentColor" >
460+ < path fillRule = "evenodd" d = "M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule = "evenodd" />
461+ </ svg >
462+ Protected with Passkey (WebAuthn)
463+ </ div >
464+ ) }
465+ { keyProtectionLevel === 'memory-only' && (
466+ < div className = "mt-1 text-xs text-yellow-500 flex items-center gap-1" >
467+ < svg xmlns = "http://www.w3.org/2000/svg" className = "h-3.5 w-3.5" viewBox = "0 0 20 20" fill = "currentColor" >
468+ < path fillRule = "evenodd" d = "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule = "evenodd" />
469+ </ svg >
470+ Stored in memory only (cleared on refresh)
471+ </ div >
472+ ) }
473+ { ! webauthnAvailable && apiKey && provider !== 'ollama' && ! keyProtectionLevel && (
474+ < div className = "mt-1 text-xs text-yellow-500" >
475+ ⚠️ WebAuthn not available. Key will be stored in memory only.
476+ </ div >
477+ ) }
478+ { securityMessage && (
479+ < div className = "mt-1 text-xs text-orange-400" >
480+ { securityMessage }
481+ </ div >
482+ ) }
483+ </ div >
484+
301485 < div >
302486 < label className = { `block text-sm font-medium ${ theme . label } mb-1` } >
303487 Model Name
304488 </ label >
305- < input
306- type = "text"
307- value = { model }
308- onChange = { ( e ) => setModel ( e . target . value ) }
309- placeholder = "Enter model name"
310- className = { `w-full p-2 border rounded-lg focus:outline-none focus:ring-2 ${ theme . input } ` }
311- />
489+ < select
490+ value = { model }
491+ onChange = { ( e ) => setModel ( e . target . value ) }
492+ className = { `w-full p-2 border rounded-lg focus:outline-none focus:ring-2 ${ theme . select } ` }
493+ >
494+ < option value = "" > Select a model</ option >
495+ { availableModels . length > 0
496+ ? availableModels . map ( ( m ) => (
497+ < option key = { m } value = { m } > { m } </ option >
498+ ) ) : < option disabled > No models available</ option >
499+ }
500+ </ select >
312501 { provider && (
313502 < div className = { `mt-1 text-xs ${ theme . helpText } ` } >
314503 { provider === 'openai' && 'Example: gpt-5, gpt-5-mini' }
@@ -319,50 +508,10 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
319508 { provider === 'ollama' && (
320509 < span className = "text-orange-500 font-bold" >
321510 ⚠️ Must run: < code > OLLAMA_ORIGINS="*" ollama serve</ code >
322- < br /> Example models: tinyllama, qwen2.5:0.5b, llama3
511+ < br /> Example models: tinyllama, qwen2.5:0.5b, llama3
323512 </ span >
324513 ) }
325-
326- </ div >
327- ) }
328- </ div >
329-
330- < div >
331- < label className = { `block text-sm font-medium ${ theme . label } mb-1` } >
332- API Key
333- </ label >
334- < input
335- type = "password"
336- value = { apiKey }
337- onChange = { ( e ) => setApiKey ( e . target . value ) }
338- placeholder = "Enter API key"
339- className = { `w-full p-2 border rounded-lg focus:outline-none focus:ring-2 ${ theme . input } ` }
340- />
341- { /* Security status indicators */ }
342- { keyProtectionLevel === 'webauthn' && (
343- < div className = "mt-1 text-xs text-green-500 flex items-center gap-1" >
344- < svg xmlns = "http://www.w3.org/2000/svg" className = "h-3.5 w-3.5" viewBox = "0 0 20 20" fill = "currentColor" >
345- < path fillRule = "evenodd" d = "M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule = "evenodd" />
346- </ svg >
347- Protected with Passkey (WebAuthn)
348- </ div >
349- ) }
350- { keyProtectionLevel === 'memory-only' && (
351- < div className = "mt-1 text-xs text-yellow-500 flex items-center gap-1" >
352- < svg xmlns = "http://www.w3.org/2000/svg" className = "h-3.5 w-3.5" viewBox = "0 0 20 20" fill = "currentColor" >
353- < path fillRule = "evenodd" d = "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule = "evenodd" />
354- </ svg >
355- Stored in memory only (cleared on refresh)
356- </ div >
357- ) }
358- { ! webauthnAvailable && apiKey && provider !== 'ollama' && ! keyProtectionLevel && (
359- < div className = "mt-1 text-xs text-yellow-500" >
360- ⚠️ WebAuthn not available. Key will be stored in memory only.
361- </ div >
362- ) }
363- { securityMessage && (
364- < div className = "mt-1 text-xs text-orange-400" >
365- { securityMessage }
514+
366515 </ div >
367516 ) }
368517 </ div >
@@ -465,7 +614,7 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
465614
466615 < button
467616 onClick = { ( ) => { handleSave ( ) . catch ( console . warn ) ; } }
468- disabled = { isEncrypting || ! provider || ! model || ( provider !== 'ollama' && ! apiKey ) || ( provider === 'openai-compatible' && ! customEndpoint ) }
617+ disabled = { isEncrypting || ! provider || ! model || ( availableModels . length > 0 && ! availableModels . includes ( model ) ) || ( provider !== 'ollama' && ! apiKey ) || ( provider === 'openai-compatible' && ! customEndpoint ) }
469618 className = { `w-full py-2 rounded-lg transition-colors disabled:cursor-not-allowed ${ isEncrypting || ! provider || ! model || ( provider !== 'ollama' && ! apiKey ) || ( provider === 'openai-compatible' && ! customEndpoint )
470619 ? theme . saveButton . disabled
471620 : theme . saveButton . enabled
0 commit comments