Skip to content

Commit 08dda5f

Browse files
Merge branch 'main' of https://github.com/accordproject/template-playground into feat/performance-resource-hints
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
2 parents b420a77 + 76717a7 commit 08dda5f

File tree

6 files changed

+2482
-3521
lines changed

6 files changed

+2482
-3521
lines changed

package-lock.json

Lines changed: 2015 additions & 3464 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"shepherd.js": "^13.0.3",
6060
"styled-components": "^6.1.12",
6161
"ts-debounce": "^4.0.0",
62+
"use-debounce": "^10.1.0",
6263
"uuid": "^11.1.0",
6364
"zustand": "^4.3.9"
6465
},

src/components/AIConfigPopup.tsx

Lines changed: 203 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { useState, useEffect, useMemo, useCallback } from 'react';
22
import { AIConfig, AIConfigPopupProps, KeyProtectionLevel } from '../types/components/AIAssistant.types';
3+
import { useDebounce } from 'use-debounce';
34
import useAppStore from '../store/store';
45
import {
56
isWebAuthnPRFSupported,
67
encryptAndStoreApiKey,
78
loadAndDecryptApiKey,
89
clearStoredKey,
910
} from '../utils/secureKeyStorage';
11+
import { AiOutlineEye, AiOutlineEyeInvisible } from "react-icons/ai";
1012

1113
const 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

Comments
 (0)