@@ -141,15 +141,15 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
141141 switch ( provider ) {
142142 case 'openai' :
143143 case 'openai-compatible' :
144- if ( ! apiKey ) return ;
144+ if ( ! debouncedApiKey ) return ;
145145
146146 let endpoint = provider === 'openai-compatible' ? customEndpoint : 'https://api.openai.com/v1' ;
147147 if ( ! endpoint ) return ;
148148 endpoint = endpoint . replace ( / \/ $ / , '' ) ;
149149 const url = `${ endpoint } /models` ;
150150
151151 const res = await fetch ( url , {
152- headers : { Authorization : `Bearer ${ apiKey } ` } ,
152+ headers : { Authorization : `Bearer ${ debouncedApiKey } ` } ,
153153 signal,
154154 } ) ;
155155
@@ -163,11 +163,11 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
163163 break ;
164164
165165 case 'anthropic' :
166- if ( ! apiKey ) return ;
166+ if ( ! debouncedApiKey ) return ;
167167 {
168168 const res = await fetch ( 'https://api.anthropic.com/v1/models' , {
169169 headers : {
170- 'x-api-key' : apiKey ,
170+ 'x-api-key' : debouncedApiKey ,
171171 'anthropic-version' : '2023-06-01' ,
172172 'content-type' : 'application/json' ,
173173 } ,
@@ -183,10 +183,10 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
183183 break ;
184184
185185 case 'google' :
186- if ( ! apiKey ) return ;
186+ if ( ! debouncedApiKey ) return ;
187187 {
188188 const res = await fetch ( 'https://generativelanguage.googleapis.com/v1beta2/models' , {
189- headers : { 'x-goog-api-key' : apiKey } ,
189+ headers : { 'x-goog-api-key' : debouncedApiKey } ,
190190 signal,
191191 } ) ;
192192 if ( ! res . ok ) {
@@ -199,10 +199,10 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
199199 break ;
200200
201201 case 'mistral' :
202- if ( ! apiKey ) return ;
202+ if ( ! debouncedApiKey ) return ;
203203 {
204204 const res = await fetch ( 'https://api.mistral.ai/v1/models' , {
205- headers : { Authorization : `Bearer ${ apiKey } ` } ,
205+ headers : { Authorization : `Bearer ${ debouncedApiKey } ` } ,
206206 signal,
207207 } ) ;
208208 if ( ! res . ok ) {
@@ -227,10 +227,10 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
227227 break ;
228228
229229 case 'openrouter' :
230- if ( ! apiKey ) return ;
230+ if ( ! debouncedApiKey ) return ;
231231 {
232232 const res = await fetch ( 'https://openrouter.ai/api/v1/models' , {
233- headers : { Authorization : `Bearer ${ apiKey } ` } ,
233+ headers : { Authorization : `Bearer ${ debouncedApiKey } ` } ,
234234 signal,
235235 } ) ;
236236 if ( ! res . ok ) {
@@ -261,71 +261,78 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
261261 } , [ provider , debouncedApiKey , customEndpoint ] ) ;
262262
263263 const handleSave = async ( ) => {
264- localStorage . setItem ( 'aiProvider' , provider ) ;
265- localStorage . setItem ( 'aiModel' , model ) ;
266-
267- if ( provider === 'openai-compatible' ) {
268- localStorage . setItem ( 'aiCustomEndpoint' , customEndpoint ) ;
269- } else {
270- localStorage . removeItem ( 'aiCustomEndpoint' ) ;
271- }
264+ setIsEncrypting ( true ) ;
265+ try {
266+ localStorage . setItem ( 'aiProvider' , provider ) ;
267+ localStorage . setItem ( 'aiModel' , model ) ;
268+
269+ if ( provider === 'openai-compatible' ) {
270+ localStorage . setItem ( 'aiCustomEndpoint' , customEndpoint ) ;
271+ } else {
272+ localStorage . removeItem ( 'aiCustomEndpoint' ) ;
273+ }
272274
273- if ( maxTokens ) {
274- localStorage . setItem ( 'aiResMaxTokens' , maxTokens ) ;
275- } else {
276- localStorage . removeItem ( 'aiResMaxTokens' ) ;
277- }
275+ if ( maxTokens ) {
276+ localStorage . setItem ( 'aiResMaxTokens' , maxTokens ) ;
277+ } else {
278+ localStorage . removeItem ( 'aiResMaxTokens' ) ;
279+ }
278280
279- localStorage . setItem ( 'aiShowFullPrompt' , showFullPrompt . toString ( ) ) ;
280- localStorage . setItem ( 'aiEnableCodeSelectionMenu' , enableCodeSelectionMenu . toString ( ) ) ;
281- localStorage . setItem ( 'aiEnableInlineSuggestions' , enableInlineSuggestions . toString ( ) ) ;
282-
283- // Securely store the API key
284- let protectionLevel : KeyProtectionLevel = 'memory-only' ;
285-
286- if ( apiKey && provider !== 'ollama' ) {
287- if ( webauthnAvailable ) {
288- try {
289- const success = await encryptAndStoreApiKey ( apiKey ) ;
290- if ( success ) {
291- protectionLevel = 'webauthn' ;
292- // Only remove legacy plaintext key when encryption succeeds
293- localStorage . removeItem ( 'aiApiKey' ) ;
281+ localStorage . setItem ( 'aiShowFullPrompt' , showFullPrompt . toString ( ) ) ;
282+ localStorage . setItem ( 'aiEnableCodeSelectionMenu' , enableCodeSelectionMenu . toString ( ) ) ;
283+ localStorage . setItem ( 'aiEnableInlineSuggestions' , enableInlineSuggestions . toString ( ) ) ;
284+
285+ // Securely store the API key
286+ let protectionLevel : KeyProtectionLevel = 'memory-only' ;
287+
288+ if ( apiKey && provider !== 'ollama' ) {
289+ if ( webauthnAvailable ) {
290+ try {
291+ const success = await encryptAndStoreApiKey ( apiKey ) ;
292+ if ( success ) {
293+ protectionLevel = 'webauthn' ;
294+ // Only remove legacy plaintext key when encryption succeeds
295+ localStorage . removeItem ( 'aiApiKey' ) ;
296+ }
297+ } catch {
298+ // Encryption failed — fall through to memory-only
294299 }
295- } catch {
296- // Encryption failed — fall through to memory-only
297300 }
298301 }
299- }
300302
301- // Build the config from the current form values and set it directly
302- // in the Zustand store. This avoids calling loadConfigFromLocalStorage()
303- // which would re-trigger a WebAuthn prompt to decrypt the key we just saved.
304- const config : AIConfig = {
305- provider,
306- model,
307- apiKey : provider === 'ollama' ? '' : apiKey ,
308- includeTemplateMarkContent : localStorage . getItem ( 'aiIncludeTemplateMark' ) === 'true' ,
309- includeConcertoModelContent : localStorage . getItem ( 'aiIncludeConcertoModel' ) === 'true' ,
310- includeDataContent : localStorage . getItem ( 'aiIncludeData' ) === 'true' ,
311- showFullPrompt,
312- enableCodeSelectionMenu,
313- enableInlineSuggestions,
314- } ;
303+ // Build the config from the current form values and set it directly
304+ // in the Zustand store. This avoids calling loadConfigFromLocalStorage()
305+ // which would re-trigger a WebAuthn prompt to decrypt the key we just saved.
306+ const config : AIConfig = {
307+ provider,
308+ model,
309+ apiKey : provider === 'ollama' ? '' : apiKey ,
310+ includeTemplateMarkContent : localStorage . getItem ( 'aiIncludeTemplateMark' ) === 'true' ,
311+ includeConcertoModelContent : localStorage . getItem ( 'aiIncludeConcertoModel' ) === 'true' ,
312+ includeDataContent : localStorage . getItem ( 'aiIncludeData' ) === 'true' ,
313+ showFullPrompt,
314+ enableCodeSelectionMenu,
315+ enableInlineSuggestions,
316+ } ;
317+
318+ if ( provider === 'openai-compatible' && customEndpoint ) {
319+ config . customEndpoint = customEndpoint ;
320+ }
315321
316- if ( provider === 'openai-compatible' && customEndpoint ) {
317- config . customEndpoint = customEndpoint ;
318- }
322+ if ( maxTokens ) {
323+ const parsed = parseInt ( maxTokens , 10 ) ;
324+ if ( Number . isFinite ( parsed ) && parsed >= 1 && parsed <= 32000 ) {
325+ config . maxTokens = parsed ;
326+ }
327+ }
319328
320- if ( maxTokens ) {
321- config . maxTokens = parseInt ( maxTokens ) ;
329+ const { setAIConfig } = useAppStore . getState ( ) ;
330+ setAIConfig ( config ) ;
331+ setKeyProtectionLevel ( protectionLevel ) ;
332+ onClose ( ) ;
333+ } finally {
334+ setIsEncrypting ( false ) ;
322335 }
323-
324- const { setAIConfig } = useAppStore . getState ( ) ;
325- setAIConfig ( config ) ;
326- setKeyProtectionLevel ( protectionLevel ) ;
327- setIsEncrypting ( false ) ;
328- onClose ( ) ;
329336 } ;
330337
331338 const handleReset = ( ) => {
@@ -612,16 +619,27 @@ const AIConfigPopup = ({ isOpen, onClose }: AIConfigPopupProps) => {
612619 ) }
613620 </ div >
614621
615- < button
616- onClick = { ( ) => { handleSave ( ) . catch ( console . warn ) ; } }
617- disabled = { isEncrypting || ! provider || ! model || ( availableModels . length > 0 && ! availableModels . includes ( model ) ) || ( provider !== 'ollama' && ! apiKey ) || ( provider === 'openai-compatible' && ! customEndpoint ) }
618- className = { `w-full py-2 rounded-lg transition-colors disabled:cursor-not-allowed ${ isEncrypting || ! provider || ! model || ( provider !== 'ollama' && ! apiKey ) || ( provider === 'openai-compatible' && ! customEndpoint )
619- ? theme . saveButton . disabled
620- : theme . saveButton . enabled
621- } `}
622- >
623- { isEncrypting ? 'Encrypting & Saving...' : 'Save Configuration' }
624- </ button >
622+ { ( ( ) => {
623+ const isSaveDisabled = isEncrypting || ! provider || ! model || ( availableModels . length > 0 && ! availableModels . includes ( model ) ) || ( provider !== 'ollama' && ! apiKey ) || ( provider === 'openai-compatible' && ! customEndpoint ) ;
624+ return (
625+ < button
626+ onClick = { ( ) => {
627+ setSecurityMessage ( '' ) ;
628+ handleSave ( ) . catch ( ( err ) => {
629+ console . warn ( err ) ;
630+ setSecurityMessage ( `Save failed: ${ err instanceof Error ? err . message : String ( err ) } ` ) ;
631+ } ) ;
632+ } }
633+ disabled = { isSaveDisabled }
634+ className = { `w-full py-2 rounded-lg transition-colors disabled:cursor-not-allowed ${ isSaveDisabled
635+ ? theme . saveButton . disabled
636+ : theme . saveButton . enabled
637+ } `}
638+ >
639+ { isEncrypting ? 'Encrypting & Saving...' : 'Save Configuration' }
640+ </ button >
641+ ) ;
642+ } ) ( ) }
625643
626644 < button
627645 onClick = { handleReset }
0 commit comments