portos-ai-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,665 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { createApiClient } from '../api.js';
3
+ import { io } from 'socket.io-client';
4
+
5
+ /**
6
+ * AIProviders - Full-featured AI provider management page
7
+ *
8
+ * @param {Object} props
9
+ * @param {Function} props.onError - Error handler (e.g., toast.error)
10
+ * @param {string} props.colorPrefix - CSS color prefix (default: 'app')
11
+ */
12
+ export default function AIProviders({ onError = console.error, colorPrefix = 'app' }) {
13
+ const api = createApiClient({ onError });
14
+
15
+ const [providers, setProviders] = useState([]);
16
+ const [activeProviderId, setActiveProviderId] = useState(null);
17
+ const [loading, setLoading] = useState(true);
18
+ const [showForm, setShowForm] = useState(false);
19
+ const [editingProvider, setEditingProvider] = useState(null);
20
+ const [testResults, setTestResults] = useState({});
21
+ const [runs, setRuns] = useState([]);
22
+ const [showRunPanel, setShowRunPanel] = useState(false);
23
+ const [runPrompt, setRunPrompt] = useState('');
24
+ const [activeRun, setActiveRun] = useState(null);
25
+ const [runOutput, setRunOutput] = useState('');
26
+ const [socket, setSocket] = useState(null);
27
+
28
+ // Color classes using the prefix
29
+ const colors = {
30
+ bg: `bg-${colorPrefix}-bg`,
31
+ card: `bg-${colorPrefix}-card`,
32
+ border: `border-${colorPrefix}-border`,
33
+ accent: `bg-${colorPrefix}-accent`,
34
+ accentHover: `hover:bg-${colorPrefix}-accent/80`,
35
+ accentText: `text-${colorPrefix}-accent`,
36
+ accentBg: `bg-${colorPrefix}-accent/20`,
37
+ success: `bg-${colorPrefix}-success`,
38
+ successText: `text-${colorPrefix}-success`,
39
+ successBg: `bg-${colorPrefix}-success/20`,
40
+ warning: `bg-${colorPrefix}-warning`,
41
+ warningText: `text-${colorPrefix}-warning`,
42
+ warningBg: `bg-${colorPrefix}-warning/20`,
43
+ error: `bg-${colorPrefix}-error`,
44
+ errorText: `text-${colorPrefix}-error`,
45
+ errorBg: `bg-${colorPrefix}-error/20`,
46
+ borderColor: `bg-${colorPrefix}-border`,
47
+ borderHover: `hover:bg-${colorPrefix}-border/80`,
48
+ };
49
+
50
+ useEffect(() => {
51
+ loadData();
52
+ const newSocket = io({ path: '/socket.io' });
53
+ setSocket(newSocket);
54
+ return () => newSocket?.disconnect();
55
+ }, []);
56
+
57
+ useEffect(() => {
58
+ if (!activeRun || !socket) return;
59
+
60
+ const handleData = (data) => {
61
+ setRunOutput(prev => prev + data);
62
+ };
63
+
64
+ const handleComplete = () => {
65
+ setActiveRun(null);
66
+ loadRuns();
67
+ };
68
+
69
+ socket.on(`run:${activeRun}:data`, handleData);
70
+ socket.on(`run:${activeRun}:complete`, handleComplete);
71
+
72
+ return () => {
73
+ socket.off(`run:${activeRun}:data`, handleData);
74
+ socket.off(`run:${activeRun}:complete`, handleComplete);
75
+ };
76
+ }, [activeRun, socket]);
77
+
78
+ const loadData = async () => {
79
+ setLoading(true);
80
+ const [providersData, runsData] = await Promise.all([
81
+ api.providers.getAll().catch(() => ({ providers: [], activeProvider: null })),
82
+ api.runs.list(20).catch(() => ({ runs: [] }))
83
+ ]);
84
+ setProviders(providersData.providers || []);
85
+ setActiveProviderId(providersData.activeProvider);
86
+ setRuns(runsData.runs || []);
87
+ setLoading(false);
88
+ };
89
+
90
+ const loadRuns = async () => {
91
+ const runsData = await api.runs.list(20).catch(() => ({ runs: [] }));
92
+ setRuns(runsData.runs || []);
93
+ };
94
+
95
+ const handleSetActive = async (id) => {
96
+ await api.providers.setActive(id);
97
+ setActiveProviderId(id);
98
+ };
99
+
100
+ const handleTest = async (id) => {
101
+ setTestResults(prev => ({ ...prev, [id]: { testing: true } }));
102
+ const result = await api.providers.test(id).catch(err => ({ success: false, error: err.message }));
103
+ setTestResults(prev => ({ ...prev, [id]: result }));
104
+ };
105
+
106
+ const handleDelete = async (id) => {
107
+ await api.providers.delete(id);
108
+ loadData();
109
+ };
110
+
111
+ const handleToggleEnabled = async (provider) => {
112
+ await api.providers.update(provider.id, { enabled: !provider.enabled });
113
+ loadData();
114
+ };
115
+
116
+ const handleRefreshModels = async (id) => {
117
+ await api.providers.refreshModels(id);
118
+ loadData();
119
+ };
120
+
121
+ const handleExecuteRun = async () => {
122
+ if (!runPrompt.trim() || !activeProviderId) return;
123
+
124
+ setRunOutput('');
125
+ const result = await api.runs.create({
126
+ providerId: activeProviderId,
127
+ prompt: runPrompt
128
+ }).catch(err => ({ error: err.message }));
129
+
130
+ if (result.error) {
131
+ setRunOutput(`Error: ${result.error}`);
132
+ return;
133
+ }
134
+
135
+ setActiveRun(result.runId);
136
+ };
137
+
138
+ const handleStopRun = async () => {
139
+ if (activeRun) {
140
+ await api.runs.stop(activeRun);
141
+ setActiveRun(null);
142
+ }
143
+ };
144
+
145
+ if (loading) {
146
+ return (
147
+ <div className="flex items-center justify-center h-64">
148
+ <div className="text-gray-400">Loading providers...</div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ return (
154
+ <div className="p-6 space-y-6">
155
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
156
+ <h1 className="text-2xl font-bold text-white">AI Providers</h1>
157
+ <div className="flex flex-wrap gap-2">
158
+ <button
159
+ onClick={() => setShowRunPanel(!showRunPanel)}
160
+ className={`px-4 py-2 ${colors.accent} ${colors.accentHover} text-white rounded-lg transition-colors`}
161
+ >
162
+ {showRunPanel ? 'Hide Runner' : 'Run Prompt'}
163
+ </button>
164
+ <button
165
+ onClick={() => { setEditingProvider(null); setShowForm(true); }}
166
+ className={`px-4 py-2 ${colors.borderColor} ${colors.borderHover} text-white rounded-lg transition-colors`}
167
+ >
168
+ Add Provider
169
+ </button>
170
+ </div>
171
+ </div>
172
+
173
+ {/* Run Panel */}
174
+ {showRunPanel && (
175
+ <div className={`${colors.card} border ${colors.border} rounded-xl p-4 space-y-4`}>
176
+ <div className="flex flex-col sm:flex-row gap-2 sm:gap-4">
177
+ <select
178
+ value={activeProviderId || ''}
179
+ onChange={(e) => handleSetActive(e.target.value)}
180
+ className={`px-3 py-2 ${colors.bg} border ${colors.border} rounded-lg text-white w-full sm:w-auto`}
181
+ >
182
+ <option value="">Select Provider</option>
183
+ {providers.filter(p => p.enabled).map(p => (
184
+ <option key={p.id} value={p.id}>{p.name}</option>
185
+ ))}
186
+ </select>
187
+ </div>
188
+
189
+ <textarea
190
+ value={runPrompt}
191
+ onChange={(e) => setRunPrompt(e.target.value)}
192
+ placeholder="Enter your prompt..."
193
+ rows={3}
194
+ className={`w-full px-3 py-2 ${colors.bg} border ${colors.border} rounded-lg text-white resize-none focus:border-${colorPrefix}-accent focus:outline-none`}
195
+ />
196
+
197
+ <div className="flex justify-between items-center">
198
+ <button
199
+ onClick={handleExecuteRun}
200
+ disabled={!runPrompt.trim() || !activeProviderId || activeRun}
201
+ className={`px-6 py-2 ${colors.success} hover:opacity-80 text-white rounded-lg transition-colors disabled:opacity-50`}
202
+ >
203
+ {activeRun ? 'Running...' : 'Execute'}
204
+ </button>
205
+
206
+ {activeRun && (
207
+ <button
208
+ onClick={handleStopRun}
209
+ className={`px-4 py-2 ${colors.error} hover:opacity-80 text-white rounded-lg transition-colors`}
210
+ >
211
+ Stop
212
+ </button>
213
+ )}
214
+ </div>
215
+
216
+ {runOutput && (
217
+ <div className={`${colors.bg} border ${colors.border} rounded-lg p-3 max-h-64 overflow-auto`}>
218
+ <pre className="text-sm text-gray-300 font-mono whitespace-pre-wrap">{runOutput}</pre>
219
+ </div>
220
+ )}
221
+ </div>
222
+ )}
223
+
224
+ {/* Provider List */}
225
+ <div className="grid gap-4">
226
+ {providers.map(provider => (
227
+ <div
228
+ key={provider.id}
229
+ className={`${colors.card} border rounded-xl p-4 ${
230
+ provider.id === activeProviderId ? `border-${colorPrefix}-accent` : colors.border
231
+ }`}
232
+ >
233
+ <div className="flex flex-col lg:flex-row lg:items-start justify-between gap-4">
234
+ <div className="flex-1 min-w-0">
235
+ <div className="flex flex-wrap items-center gap-2">
236
+ <h3 className="text-lg font-semibold text-white">{provider.name}</h3>
237
+ <span className={`text-xs px-2 py-0.5 rounded ${
238
+ provider.type === 'cli' ? 'bg-blue-500/20 text-blue-400' : 'bg-purple-500/20 text-purple-400'
239
+ }`}>
240
+ {provider.type.toUpperCase()}
241
+ </span>
242
+ {provider.id === activeProviderId && (
243
+ <span className={`text-xs px-2 py-0.5 rounded ${colors.accentBg} ${colors.accentText}`}>
244
+ DEFAULT
245
+ </span>
246
+ )}
247
+ {!provider.enabled && (
248
+ <span className="text-xs px-2 py-0.5 rounded bg-gray-500/20 text-gray-400">
249
+ DISABLED
250
+ </span>
251
+ )}
252
+ </div>
253
+
254
+ <div className="mt-2 text-sm text-gray-400 space-y-1">
255
+ {provider.type === 'cli' && (
256
+ <p className="break-words">Command: <code className="text-gray-300 break-all">{provider.command} {provider.args?.join(' ')}</code></p>
257
+ )}
258
+ {provider.type === 'api' && (
259
+ <p className="break-words">Endpoint: <code className="text-gray-300 break-all">{provider.endpoint}</code></p>
260
+ )}
261
+ {provider.models?.length > 0 && (
262
+ <p>Models: {provider.models.slice(0, 3).join(', ')}{provider.models.length > 3 ? ` +${provider.models.length - 3}` : ''}</p>
263
+ )}
264
+ {provider.defaultModel && (
265
+ <p className="break-words">Default: <code className="text-gray-300 break-all">{provider.defaultModel}</code></p>
266
+ )}
267
+ {(provider.lightModel || provider.mediumModel || provider.heavyModel) && (
268
+ <p className="text-xs">
269
+ Tiers:
270
+ {provider.lightModel && <span className="ml-1 text-green-400">{provider.lightModel}</span>}
271
+ {provider.mediumModel && <span className="ml-1 text-yellow-400">{provider.mediumModel}</span>}
272
+ {provider.heavyModel && <span className="ml-1 text-red-400">{provider.heavyModel}</span>}
273
+ </p>
274
+ )}
275
+ </div>
276
+
277
+ {testResults[provider.id] && !testResults[provider.id].testing && (
278
+ <div className={`mt-2 text-sm ${testResults[provider.id].success ? colors.successText : colors.errorText}`}>
279
+ {testResults[provider.id].success
280
+ ? `✓ Available${testResults[provider.id].version ? ` (${testResults[provider.id].version})` : ''}`
281
+ : `✗ ${testResults[provider.id].error}`
282
+ }
283
+ </div>
284
+ )}
285
+ </div>
286
+
287
+ <div className="flex flex-wrap items-center gap-2">
288
+ <button
289
+ onClick={() => handleTest(provider.id)}
290
+ disabled={testResults[provider.id]?.testing}
291
+ className={`px-3 py-1.5 text-sm ${colors.borderColor} ${colors.borderHover} text-white rounded transition-colors disabled:opacity-50`}
292
+ >
293
+ {testResults[provider.id]?.testing ? 'Testing...' : 'Test'}
294
+ </button>
295
+
296
+ {provider.type === 'api' && (
297
+ <button
298
+ onClick={() => handleRefreshModels(provider.id)}
299
+ className={`px-3 py-1.5 text-sm ${colors.borderColor} ${colors.borderHover} text-white rounded transition-colors`}
300
+ >
301
+ Refresh
302
+ </button>
303
+ )}
304
+
305
+ <button
306
+ onClick={() => handleToggleEnabled(provider)}
307
+ className={`px-3 py-1.5 text-sm rounded transition-colors ${
308
+ provider.enabled
309
+ ? `${colors.warningBg} ${colors.warningText} hover:bg-${colorPrefix}-warning/30`
310
+ : `${colors.successBg} ${colors.successText} hover:bg-${colorPrefix}-success/30`
311
+ }`}
312
+ >
313
+ {provider.enabled ? 'Disable' : 'Enable'}
314
+ </button>
315
+
316
+ {provider.id !== activeProviderId && provider.enabled && (
317
+ <button
318
+ onClick={() => handleSetActive(provider.id)}
319
+ className={`px-3 py-1.5 text-sm ${colors.accentBg} ${colors.accentText} hover:bg-${colorPrefix}-accent/30 rounded transition-colors`}
320
+ >
321
+ Set Default
322
+ </button>
323
+ )}
324
+
325
+ <button
326
+ onClick={() => { setEditingProvider(provider); setShowForm(true); }}
327
+ className={`px-3 py-1.5 text-sm ${colors.borderColor} ${colors.borderHover} text-white rounded transition-colors`}
328
+ >
329
+ Edit
330
+ </button>
331
+
332
+ <button
333
+ onClick={() => handleDelete(provider.id)}
334
+ className={`px-3 py-1.5 text-sm ${colors.errorBg} ${colors.errorText} hover:bg-${colorPrefix}-error/30 rounded transition-colors`}
335
+ >
336
+ Delete
337
+ </button>
338
+ </div>
339
+ </div>
340
+ </div>
341
+ ))}
342
+
343
+ {providers.length === 0 && (
344
+ <div className="text-center py-12 text-gray-500">
345
+ No providers configured. Add a provider to get started.
346
+ </div>
347
+ )}
348
+ </div>
349
+
350
+ {/* Recent Runs */}
351
+ {runs.length > 0 && (
352
+ <div className="mt-8">
353
+ <h2 className="text-xl font-bold text-white mb-4">Recent Runs</h2>
354
+ <div className="space-y-2">
355
+ {runs.map(run => (
356
+ <div
357
+ key={run.id}
358
+ className={`${colors.card} border ${colors.border} rounded-lg p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2`}
359
+ >
360
+ <div className="flex items-start sm:items-center gap-3 min-w-0">
361
+ <span className={`w-2 h-2 rounded-full flex-shrink-0 mt-1.5 sm:mt-0 ${
362
+ run.success === true ? colors.success :
363
+ run.success === false ? colors.error :
364
+ `${colors.warning} animate-pulse`
365
+ }`} />
366
+ <div className="min-w-0">
367
+ <p className="text-sm text-white truncate">{run.prompt}</p>
368
+ <p className="text-xs text-gray-500">
369
+ {run.providerName} • {run.workspaceName || 'No workspace'} • {new Date(run.startTime).toLocaleString()}
370
+ </p>
371
+ </div>
372
+ </div>
373
+ <div className="text-sm text-gray-400 flex-shrink-0 pl-5 sm:pl-0">
374
+ {run.duration ? `${(run.duration / 1000).toFixed(1)}s` : 'Running...'}
375
+ </div>
376
+ </div>
377
+ ))}
378
+ </div>
379
+ </div>
380
+ )}
381
+
382
+ {/* Provider Form Modal */}
383
+ {showForm && (
384
+ <ProviderForm
385
+ provider={editingProvider}
386
+ onClose={() => { setShowForm(false); setEditingProvider(null); }}
387
+ onSave={() => { setShowForm(false); setEditingProvider(null); loadData(); }}
388
+ api={api}
389
+ colorPrefix={colorPrefix}
390
+ />
391
+ )}
392
+ </div>
393
+ );
394
+ }
395
+
396
+ function ProviderForm({ provider, onClose, onSave, api, colorPrefix = 'app' }) {
397
+ const [formData, setFormData] = useState({
398
+ name: provider?.name || '',
399
+ type: provider?.type || 'cli',
400
+ command: provider?.command || '',
401
+ args: provider?.args?.join(' ') || '',
402
+ endpoint: provider?.endpoint || '',
403
+ apiKey: provider?.apiKey || '',
404
+ models: provider?.models || [],
405
+ defaultModel: provider?.defaultModel || '',
406
+ lightModel: provider?.lightModel || '',
407
+ mediumModel: provider?.mediumModel || '',
408
+ heavyModel: provider?.heavyModel || '',
409
+ timeout: provider?.timeout || 300000,
410
+ enabled: provider?.enabled !== false
411
+ });
412
+
413
+ const availableModels = formData.models || [];
414
+
415
+ const handleSubmit = async (e) => {
416
+ e.preventDefault();
417
+ const data = {
418
+ ...formData,
419
+ args: formData.args ? formData.args.split(' ').filter(Boolean) : [],
420
+ timeout: parseInt(formData.timeout)
421
+ };
422
+
423
+ if (provider) {
424
+ await api.providers.update(provider.id, data);
425
+ } else {
426
+ await api.providers.create(data);
427
+ }
428
+ onSave();
429
+ };
430
+
431
+ return (
432
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
433
+ <div className={`bg-${colorPrefix}-card border border-${colorPrefix}-border rounded-xl p-4 sm:p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto`}>
434
+ <h2 className="text-xl font-bold text-white mb-4">
435
+ {provider ? 'Edit Provider' : 'Add Provider'}
436
+ </h2>
437
+ <form onSubmit={handleSubmit} className="space-y-4">
438
+ <div>
439
+ <label className="block text-sm text-gray-400 mb-1">Name *</label>
440
+ <input
441
+ type="text"
442
+ value={formData.name}
443
+ onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
444
+ required
445
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
446
+ />
447
+ </div>
448
+ <div>
449
+ <label className="block text-sm text-gray-400 mb-1">Type *</label>
450
+ <select
451
+ value={formData.type}
452
+ onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))}
453
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
454
+ >
455
+ <option value="cli">CLI</option>
456
+ <option value="api">API</option>
457
+ </select>
458
+ </div>
459
+ {formData.type === 'cli' && (
460
+ <>
461
+ <div>
462
+ <label className="block text-sm text-gray-400 mb-1">Command *</label>
463
+ <input
464
+ type="text"
465
+ value={formData.command}
466
+ onChange={(e) => setFormData(prev => ({ ...prev, command: e.target.value }))}
467
+ placeholder="claude"
468
+ required={formData.type === 'cli'}
469
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
470
+ />
471
+ </div>
472
+ <div>
473
+ <label className="block text-sm text-gray-400 mb-1">Arguments (space-separated)</label>
474
+ <input
475
+ type="text"
476
+ value={formData.args}
477
+ onChange={(e) => setFormData(prev => ({ ...prev, args: e.target.value }))}
478
+ placeholder="--print -p"
479
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
480
+ />
481
+ </div>
482
+ </>
483
+ )}
484
+ {formData.type === 'api' && (
485
+ <>
486
+ <div>
487
+ <label className="block text-sm text-gray-400 mb-1">Endpoint *</label>
488
+ <input
489
+ type="url"
490
+ value={formData.endpoint}
491
+ onChange={(e) => setFormData(prev => ({ ...prev, endpoint: e.target.value }))}
492
+ placeholder="http://localhost:1234/v1"
493
+ required={formData.type === 'api'}
494
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
495
+ />
496
+ </div>
497
+ <div>
498
+ <label className="block text-sm text-gray-400 mb-1">API Key</label>
499
+ <input
500
+ type="password"
501
+ value={formData.apiKey}
502
+ onChange={(e) => setFormData(prev => ({ ...prev, apiKey: e.target.value }))}
503
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
504
+ />
505
+ </div>
506
+ </>
507
+ )}
508
+
509
+ <div>
510
+ <label className="block text-sm text-gray-400 mb-1">
511
+ Available Models
512
+ {formData.type === 'api' && <span className="text-xs text-gray-500 ml-2">(Use Refresh after saving)</span>}
513
+ </label>
514
+ <textarea
515
+ value={(formData.models || []).join(', ')}
516
+ onChange={(e) => {
517
+ const models = e.target.value.split(',').map(m => m.trim()).filter(Boolean);
518
+ setFormData(prev => ({ ...prev, models }));
519
+ }}
520
+ placeholder="model-1, model-2, model-3"
521
+ rows={2}
522
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white resize-none focus:border-${colorPrefix}-accent focus:outline-none`}
523
+ />
524
+ </div>
525
+
526
+ <div>
527
+ <label className="block text-sm text-gray-400 mb-1">Default Model</label>
528
+ {availableModels.length > 0 ? (
529
+ <select
530
+ value={formData.defaultModel}
531
+ onChange={(e) => setFormData(prev => ({ ...prev, defaultModel: e.target.value }))}
532
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
533
+ >
534
+ <option value="">None</option>
535
+ {availableModels.map(model => (
536
+ <option key={model} value={model}>{model}</option>
537
+ ))}
538
+ </select>
539
+ ) : (
540
+ <input
541
+ type="text"
542
+ value={formData.defaultModel}
543
+ onChange={(e) => setFormData(prev => ({ ...prev, defaultModel: e.target.value }))}
544
+ placeholder="claude-sonnet-4-20250514"
545
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
546
+ />
547
+ )}
548
+ </div>
549
+
550
+ {/* Model Tiers */}
551
+ <div className={`border-t border-${colorPrefix}-border pt-4 mt-4`}>
552
+ <h4 className="text-sm font-medium text-gray-300 mb-3">Model Tiers</h4>
553
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
554
+ <div>
555
+ <label className="block text-xs text-gray-400 mb-1">
556
+ <span className="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>
557
+ Light (fast)
558
+ </label>
559
+ {availableModels.length > 0 ? (
560
+ <select
561
+ value={formData.lightModel}
562
+ onChange={(e) => setFormData(prev => ({ ...prev, lightModel: e.target.value }))}
563
+ className={`w-full px-2 py-1.5 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white text-sm focus:border-${colorPrefix}-accent focus:outline-none`}
564
+ >
565
+ <option value="">None</option>
566
+ {availableModels.map(model => (
567
+ <option key={model} value={model}>{model}</option>
568
+ ))}
569
+ </select>
570
+ ) : (
571
+ <input
572
+ type="text"
573
+ value={formData.lightModel}
574
+ onChange={(e) => setFormData(prev => ({ ...prev, lightModel: e.target.value }))}
575
+ placeholder="haiku"
576
+ className={`w-full px-2 py-1.5 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white text-sm focus:border-${colorPrefix}-accent focus:outline-none`}
577
+ />
578
+ )}
579
+ </div>
580
+ <div>
581
+ <label className="block text-xs text-gray-400 mb-1">
582
+ <span className="inline-block w-2 h-2 rounded-full bg-yellow-500 mr-1"></span>
583
+ Medium (balanced)
584
+ </label>
585
+ {availableModels.length > 0 ? (
586
+ <select
587
+ value={formData.mediumModel}
588
+ onChange={(e) => setFormData(prev => ({ ...prev, mediumModel: e.target.value }))}
589
+ className={`w-full px-2 py-1.5 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white text-sm focus:border-${colorPrefix}-accent focus:outline-none`}
590
+ >
591
+ <option value="">None</option>
592
+ {availableModels.map(model => (
593
+ <option key={model} value={model}>{model}</option>
594
+ ))}
595
+ </select>
596
+ ) : (
597
+ <input
598
+ type="text"
599
+ value={formData.mediumModel}
600
+ onChange={(e) => setFormData(prev => ({ ...prev, mediumModel: e.target.value }))}
601
+ placeholder="sonnet"
602
+ className={`w-full px-2 py-1.5 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white text-sm focus:border-${colorPrefix}-accent focus:outline-none`}
603
+ />
604
+ )}
605
+ </div>
606
+ <div>
607
+ <label className="block text-xs text-gray-400 mb-1">
608
+ <span className="inline-block w-2 h-2 rounded-full bg-red-500 mr-1"></span>
609
+ Heavy (powerful)
610
+ </label>
611
+ {availableModels.length > 0 ? (
612
+ <select
613
+ value={formData.heavyModel}
614
+ onChange={(e) => setFormData(prev => ({ ...prev, heavyModel: e.target.value }))}
615
+ className={`w-full px-2 py-1.5 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white text-sm focus:border-${colorPrefix}-accent focus:outline-none`}
616
+ >
617
+ <option value="">None</option>
618
+ {availableModels.map(model => (
619
+ <option key={model} value={model}>{model}</option>
620
+ ))}
621
+ </select>
622
+ ) : (
623
+ <input
624
+ type="text"
625
+ value={formData.heavyModel}
626
+ onChange={(e) => setFormData(prev => ({ ...prev, heavyModel: e.target.value }))}
627
+ placeholder="opus"
628
+ className={`w-full px-2 py-1.5 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white text-sm focus:border-${colorPrefix}-accent focus:outline-none`}
629
+ />
630
+ )}
631
+ </div>
632
+ </div>
633
+ </div>
634
+
635
+ <div>
636
+ <label className="block text-sm text-gray-400 mb-1">Timeout (ms)</label>
637
+ <input
638
+ type="number"
639
+ value={formData.timeout}
640
+ onChange={(e) => setFormData(prev => ({ ...prev, timeout: e.target.value }))}
641
+ className={`w-full px-3 py-2 bg-${colorPrefix}-bg border border-${colorPrefix}-border rounded-lg text-white focus:border-${colorPrefix}-accent focus:outline-none`}
642
+ />
643
+ </div>
644
+ <label className="flex items-center gap-2">
645
+ <input
646
+ type="checkbox"
647
+ checked={formData.enabled}
648
+ onChange={(e) => setFormData(prev => ({ ...prev, enabled: e.target.checked }))}
649
+ className={`w-4 h-4 rounded border-${colorPrefix}-border bg-${colorPrefix}-bg`}
650
+ />
651
+ <span className="text-sm text-gray-400">Enabled</span>
652
+ </label>
653
+ <div className="flex justify-end gap-3 pt-4">
654
+ <button type="button" onClick={onClose} className="px-4 py-2 text-gray-400 hover:text-white">
655
+ Cancel
656
+ </button>
657
+ <button type="submit" className={`px-6 py-2 bg-${colorPrefix}-accent hover:bg-${colorPrefix}-accent/80 text-white rounded-lg transition-colors`}>
658
+ {provider ? 'Save' : 'Create'}
659
+ </button>
660
+ </div>
661
+ </form>
662
+ </div>
663
+ </div>
664
+ );
665
+ }
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * AI Toolkit - Main Entry Point
3
+ * Shared AI provider, model, and prompt template patterns for PortOS-style applications
4
+ */
5
+
6
+ export * from './shared/index.js';
7
+ export * from './server/index.js';
8
+ export * from './client/index.js';