apteva 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.
- package/LICENSE +63 -0
- package/README.md +84 -0
- package/bin/agent-linux-amd64 +0 -0
- package/bin/apteva.js +144 -0
- package/dist/App.g02zmbqf.js +213 -0
- package/dist/App.g02zmbqf.js.map +37 -0
- package/dist/App.mq6jqare.js +1 -0
- package/dist/apteva-kit.css +1 -0
- package/dist/index.html +14 -0
- package/dist/styles.css +1 -0
- package/package.json +65 -0
- package/src/binary.ts +116 -0
- package/src/crypto.ts +152 -0
- package/src/db.ts +446 -0
- package/src/providers.ts +255 -0
- package/src/routes/api.ts +380 -0
- package/src/routes/static.ts +47 -0
- package/src/server.ts +134 -0
- package/src/web/App.tsx +218 -0
- package/src/web/components/agents/AgentCard.tsx +71 -0
- package/src/web/components/agents/AgentsView.tsx +69 -0
- package/src/web/components/agents/ChatPanel.tsx +63 -0
- package/src/web/components/agents/CreateAgentModal.tsx +128 -0
- package/src/web/components/agents/index.ts +4 -0
- package/src/web/components/common/Icons.tsx +61 -0
- package/src/web/components/common/LoadingSpinner.tsx +44 -0
- package/src/web/components/common/Modal.tsx +16 -0
- package/src/web/components/common/Select.tsx +96 -0
- package/src/web/components/common/index.ts +4 -0
- package/src/web/components/dashboard/Dashboard.tsx +136 -0
- package/src/web/components/dashboard/index.ts +1 -0
- package/src/web/components/index.ts +11 -0
- package/src/web/components/layout/ErrorBanner.tsx +18 -0
- package/src/web/components/layout/Header.tsx +26 -0
- package/src/web/components/layout/Sidebar.tsx +66 -0
- package/src/web/components/layout/index.ts +3 -0
- package/src/web/components/onboarding/OnboardingWizard.tsx +344 -0
- package/src/web/components/onboarding/index.ts +1 -0
- package/src/web/components/settings/SettingsPage.tsx +285 -0
- package/src/web/components/settings/index.ts +1 -0
- package/src/web/hooks/index.ts +3 -0
- package/src/web/hooks/useAgents.ts +62 -0
- package/src/web/hooks/useOnboarding.ts +25 -0
- package/src/web/hooks/useProviders.ts +65 -0
- package/src/web/index.html +21 -0
- package/src/web/styles.css +23 -0
- package/src/web/types.ts +43 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { CheckIcon } from "../common/Icons";
|
|
3
|
+
import type { Provider } from "../../types";
|
|
4
|
+
|
|
5
|
+
interface OnboardingWizardProps {
|
|
6
|
+
onComplete: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
|
10
|
+
const [step, setStep] = useState(1);
|
|
11
|
+
const [providers, setProviders] = useState<Provider[]>([]);
|
|
12
|
+
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
|
13
|
+
const [apiKey, setApiKey] = useState("");
|
|
14
|
+
const [saving, setSaving] = useState(false);
|
|
15
|
+
const [testing, setTesting] = useState(false);
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
fetch("/api/providers")
|
|
21
|
+
.then(res => res.json())
|
|
22
|
+
.then(data => setProviders(data.providers || []));
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const configuredProviders = providers.filter(p => p.hasKey);
|
|
26
|
+
|
|
27
|
+
const saveKey = async () => {
|
|
28
|
+
if (!selectedProvider || !apiKey) return;
|
|
29
|
+
setSaving(true);
|
|
30
|
+
setError(null);
|
|
31
|
+
setSuccess(null);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
setTesting(true);
|
|
35
|
+
const testRes = await fetch(`/api/keys/${selectedProvider}/test`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ key: apiKey }),
|
|
39
|
+
});
|
|
40
|
+
const testData = await testRes.json();
|
|
41
|
+
setTesting(false);
|
|
42
|
+
|
|
43
|
+
if (!testData.valid) {
|
|
44
|
+
setError(testData.error || "API key is invalid");
|
|
45
|
+
setSaving(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const saveRes = await fetch(`/api/keys/${selectedProvider}`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ key: apiKey }),
|
|
53
|
+
});
|
|
54
|
+
const saveData = await saveRes.json();
|
|
55
|
+
|
|
56
|
+
if (!saveRes.ok) {
|
|
57
|
+
setError(saveData.error || "Failed to save key");
|
|
58
|
+
} else {
|
|
59
|
+
setSuccess("API key saved successfully!");
|
|
60
|
+
setApiKey("");
|
|
61
|
+
const res = await fetch("/api/providers");
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
setProviders(data.providers || []);
|
|
64
|
+
setSelectedProvider(null);
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
setError("Failed to save key");
|
|
68
|
+
}
|
|
69
|
+
setSaving(false);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const completeOnboarding = async () => {
|
|
73
|
+
await fetch("/api/onboarding/complete", { method: "POST" });
|
|
74
|
+
onComplete();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="min-h-screen bg-[#0a0a0a] text-[#e0e0e0] font-mono flex items-center justify-center p-8">
|
|
79
|
+
<div className="w-full max-w-2xl">
|
|
80
|
+
{/* Logo */}
|
|
81
|
+
<div className="text-center mb-8">
|
|
82
|
+
<div className="flex items-center justify-center gap-2 mb-2">
|
|
83
|
+
<span className="text-[#f97316] text-3xl">>_</span>
|
|
84
|
+
<span className="text-3xl tracking-wider">apteva</span>
|
|
85
|
+
</div>
|
|
86
|
+
<p className="text-[#666]">Run AI agents locally</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Progress */}
|
|
90
|
+
<div className="flex items-center justify-center gap-2 mb-8">
|
|
91
|
+
<div className={`w-3 h-3 rounded-full ${step >= 1 ? 'bg-[#f97316]' : 'bg-[#333]'}`} />
|
|
92
|
+
<div className={`w-16 h-0.5 ${step >= 2 ? 'bg-[#f97316]' : 'bg-[#333]'}`} />
|
|
93
|
+
<div className={`w-3 h-3 rounded-full ${step >= 2 ? 'bg-[#f97316]' : 'bg-[#333]'}`} />
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="bg-[#111] rounded-lg border border-[#1a1a1a] p-8">
|
|
97
|
+
{step === 1 && (
|
|
98
|
+
<Step1AddKeys
|
|
99
|
+
providers={providers}
|
|
100
|
+
configuredProviders={configuredProviders}
|
|
101
|
+
selectedProvider={selectedProvider}
|
|
102
|
+
apiKey={apiKey}
|
|
103
|
+
saving={saving}
|
|
104
|
+
testing={testing}
|
|
105
|
+
error={error}
|
|
106
|
+
success={success}
|
|
107
|
+
onSelectProvider={setSelectedProvider}
|
|
108
|
+
onApiKeyChange={setApiKey}
|
|
109
|
+
onSaveKey={saveKey}
|
|
110
|
+
onContinue={() => setStep(2)}
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{step === 2 && (
|
|
115
|
+
<Step2Complete
|
|
116
|
+
configuredProviders={configuredProviders}
|
|
117
|
+
onAddMore={() => setStep(1)}
|
|
118
|
+
onComplete={completeOnboarding}
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Step1Props {
|
|
128
|
+
providers: Provider[];
|
|
129
|
+
configuredProviders: Provider[];
|
|
130
|
+
selectedProvider: string | null;
|
|
131
|
+
apiKey: string;
|
|
132
|
+
saving: boolean;
|
|
133
|
+
testing: boolean;
|
|
134
|
+
error: string | null;
|
|
135
|
+
success: string | null;
|
|
136
|
+
onSelectProvider: (id: string | null) => void;
|
|
137
|
+
onApiKeyChange: (key: string) => void;
|
|
138
|
+
onSaveKey: () => void;
|
|
139
|
+
onContinue: () => void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function Step1AddKeys({
|
|
143
|
+
providers,
|
|
144
|
+
configuredProviders,
|
|
145
|
+
selectedProvider,
|
|
146
|
+
apiKey,
|
|
147
|
+
saving,
|
|
148
|
+
testing,
|
|
149
|
+
error,
|
|
150
|
+
success,
|
|
151
|
+
onSelectProvider,
|
|
152
|
+
onApiKeyChange,
|
|
153
|
+
onSaveKey,
|
|
154
|
+
onContinue,
|
|
155
|
+
}: Step1Props) {
|
|
156
|
+
const selectedProviderData = providers.find(p => p.id === selectedProvider);
|
|
157
|
+
|
|
158
|
+
// When a provider is selected, show focused view with just that provider
|
|
159
|
+
if (selectedProvider && selectedProviderData) {
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
<h2 className="text-2xl font-semibold mb-2">Add {selectedProviderData.name} Key</h2>
|
|
163
|
+
<p className="text-[#666] mb-6">
|
|
164
|
+
Enter your API key below. It will be encrypted and stored locally.
|
|
165
|
+
</p>
|
|
166
|
+
|
|
167
|
+
<div className="mb-6">
|
|
168
|
+
<div className="p-4 rounded border border-[#f97316] bg-[#f97316]/5 mb-4">
|
|
169
|
+
<div className="flex items-center justify-between">
|
|
170
|
+
<div>
|
|
171
|
+
<p className="font-medium">{selectedProviderData.name}</p>
|
|
172
|
+
<p className="text-sm text-[#666]">
|
|
173
|
+
{selectedProviderData.models.length} models available
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
<a
|
|
177
|
+
href={selectedProviderData.docsUrl}
|
|
178
|
+
target="_blank"
|
|
179
|
+
rel="noopener noreferrer"
|
|
180
|
+
className="text-sm text-[#3b82f6] hover:underline"
|
|
181
|
+
>
|
|
182
|
+
Get API Key
|
|
183
|
+
</a>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div className="space-y-3">
|
|
188
|
+
<input
|
|
189
|
+
type="password"
|
|
190
|
+
value={apiKey}
|
|
191
|
+
onChange={e => onApiKeyChange(e.target.value)}
|
|
192
|
+
placeholder="Enter your API key..."
|
|
193
|
+
autoFocus
|
|
194
|
+
className="w-full bg-[#0a0a0a] border border-[#333] rounded px-4 py-3 focus:outline-none focus:border-[#f97316] text-lg"
|
|
195
|
+
/>
|
|
196
|
+
{error && <p className="text-red-400 text-sm">{error}</p>}
|
|
197
|
+
{success && <p className="text-green-400 text-sm">{success}</p>}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div className="flex gap-3">
|
|
202
|
+
<button
|
|
203
|
+
onClick={() => {
|
|
204
|
+
onSelectProvider(null);
|
|
205
|
+
onApiKeyChange("");
|
|
206
|
+
}}
|
|
207
|
+
className="flex-1 border border-[#333] hover:border-[#666] px-4 py-3 rounded font-medium transition"
|
|
208
|
+
>
|
|
209
|
+
Back
|
|
210
|
+
</button>
|
|
211
|
+
<button
|
|
212
|
+
onClick={onSaveKey}
|
|
213
|
+
disabled={!apiKey || saving}
|
|
214
|
+
className="flex-1 bg-[#f97316] hover:bg-[#fb923c] disabled:opacity-50 disabled:cursor-not-allowed text-black px-4 py-3 rounded font-medium transition"
|
|
215
|
+
>
|
|
216
|
+
{testing ? "Testing..." : saving ? "Saving..." : "Save API Key"}
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Default view: show all providers
|
|
224
|
+
return (
|
|
225
|
+
<>
|
|
226
|
+
<h2 className="text-2xl font-semibold mb-2">Welcome to Apteva</h2>
|
|
227
|
+
<p className="text-[#666] mb-6">
|
|
228
|
+
To get started, you'll need to add at least one AI provider API key.
|
|
229
|
+
Your keys are encrypted and stored locally.
|
|
230
|
+
</p>
|
|
231
|
+
|
|
232
|
+
<div className="space-y-3 mb-6">
|
|
233
|
+
{providers.map(provider => (
|
|
234
|
+
<ProviderCard
|
|
235
|
+
key={provider.id}
|
|
236
|
+
provider={provider}
|
|
237
|
+
selected={false}
|
|
238
|
+
onSelect={() => !provider.hasKey && onSelectProvider(provider.id)}
|
|
239
|
+
/>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<button
|
|
244
|
+
onClick={onContinue}
|
|
245
|
+
disabled={configuredProviders.length === 0}
|
|
246
|
+
className="w-full bg-[#222] hover:bg-[#333] disabled:opacity-50 disabled:cursor-not-allowed px-4 py-3 rounded font-medium transition"
|
|
247
|
+
>
|
|
248
|
+
{configuredProviders.length === 0
|
|
249
|
+
? "Add at least one API key to continue"
|
|
250
|
+
: `Continue with ${configuredProviders.length} provider${configuredProviders.length > 1 ? 's' : ''}`
|
|
251
|
+
}
|
|
252
|
+
</button>
|
|
253
|
+
</>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface ProviderCardProps {
|
|
258
|
+
provider: Provider;
|
|
259
|
+
selected: boolean;
|
|
260
|
+
onSelect: () => void;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function ProviderCard({ provider, selected, onSelect }: ProviderCardProps) {
|
|
264
|
+
return (
|
|
265
|
+
<div
|
|
266
|
+
onClick={onSelect}
|
|
267
|
+
className={`p-4 rounded border transition cursor-pointer ${
|
|
268
|
+
provider.hasKey
|
|
269
|
+
? 'border-green-500/30 bg-green-500/5'
|
|
270
|
+
: selected
|
|
271
|
+
? 'border-[#f97316] bg-[#f97316]/5'
|
|
272
|
+
: 'border-[#222] hover:border-[#333]'
|
|
273
|
+
}`}
|
|
274
|
+
>
|
|
275
|
+
<div className="flex items-center justify-between">
|
|
276
|
+
<div>
|
|
277
|
+
<p className="font-medium">{provider.name}</p>
|
|
278
|
+
<p className="text-sm text-[#666]">
|
|
279
|
+
{provider.models.length} models available
|
|
280
|
+
</p>
|
|
281
|
+
</div>
|
|
282
|
+
{provider.hasKey ? (
|
|
283
|
+
<span className="text-green-400 text-sm flex items-center gap-1">
|
|
284
|
+
<CheckIcon />
|
|
285
|
+
Configured ({provider.keyHint})
|
|
286
|
+
</span>
|
|
287
|
+
) : (
|
|
288
|
+
<a
|
|
289
|
+
href={provider.docsUrl}
|
|
290
|
+
target="_blank"
|
|
291
|
+
rel="noopener noreferrer"
|
|
292
|
+
onClick={e => e.stopPropagation()}
|
|
293
|
+
className="text-sm text-[#3b82f6] hover:underline"
|
|
294
|
+
>
|
|
295
|
+
Get API Key
|
|
296
|
+
</a>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface Step2Props {
|
|
304
|
+
configuredProviders: Provider[];
|
|
305
|
+
onAddMore: () => void;
|
|
306
|
+
onComplete: () => void;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function Step2Complete({ configuredProviders, onAddMore, onComplete }: Step2Props) {
|
|
310
|
+
return (
|
|
311
|
+
<>
|
|
312
|
+
<h2 className="text-2xl font-semibold mb-2">You're all set!</h2>
|
|
313
|
+
<p className="text-[#666] mb-6">
|
|
314
|
+
You've configured {configuredProviders.length} provider{configuredProviders.length > 1 ? 's' : ''}.
|
|
315
|
+
You can add more providers later in Settings.
|
|
316
|
+
</p>
|
|
317
|
+
|
|
318
|
+
<div className="space-y-2 mb-6">
|
|
319
|
+
{configuredProviders.map(provider => (
|
|
320
|
+
<div key={provider.id} className="flex items-center gap-3 p-3 bg-[#0a0a0a] rounded">
|
|
321
|
+
<CheckIcon className="w-5 h-5 text-green-400" />
|
|
322
|
+
<span>{provider.name}</span>
|
|
323
|
+
<span className="text-[#666] text-sm">({provider.keyHint})</span>
|
|
324
|
+
</div>
|
|
325
|
+
))}
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div className="flex gap-3">
|
|
329
|
+
<button
|
|
330
|
+
onClick={onAddMore}
|
|
331
|
+
className="flex-1 border border-[#333] hover:border-[#f97316] px-4 py-3 rounded font-medium transition"
|
|
332
|
+
>
|
|
333
|
+
Add More
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
onClick={onComplete}
|
|
337
|
+
className="flex-1 bg-[#f97316] hover:bg-[#fb923c] text-black px-4 py-3 rounded font-medium transition"
|
|
338
|
+
>
|
|
339
|
+
Start Using Apteva
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OnboardingWizard } from "./OnboardingWizard";
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { CheckIcon } from "../common/Icons";
|
|
3
|
+
import type { Provider } from "../../types";
|
|
4
|
+
|
|
5
|
+
type SettingsTab = "providers";
|
|
6
|
+
|
|
7
|
+
export function SettingsPage() {
|
|
8
|
+
const [activeTab, setActiveTab] = useState<SettingsTab>("providers");
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex-1 flex overflow-hidden">
|
|
12
|
+
{/* Settings Sidebar */}
|
|
13
|
+
<div className="w-48 border-r border-[#1a1a1a] p-4">
|
|
14
|
+
<h2 className="text-sm font-medium text-[#666] uppercase tracking-wider mb-3">Settings</h2>
|
|
15
|
+
<nav className="space-y-1">
|
|
16
|
+
<SettingsNavItem
|
|
17
|
+
label="Providers"
|
|
18
|
+
active={activeTab === "providers"}
|
|
19
|
+
onClick={() => setActiveTab("providers")}
|
|
20
|
+
/>
|
|
21
|
+
{/* Future settings tabs can be added here */}
|
|
22
|
+
</nav>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
{/* Settings Content */}
|
|
26
|
+
<div className="flex-1 overflow-auto p-6">
|
|
27
|
+
{activeTab === "providers" && <ProvidersSettings />}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function SettingsNavItem({
|
|
34
|
+
label,
|
|
35
|
+
active,
|
|
36
|
+
onClick
|
|
37
|
+
}: {
|
|
38
|
+
label: string;
|
|
39
|
+
active: boolean;
|
|
40
|
+
onClick: () => void;
|
|
41
|
+
}) {
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
onClick={onClick}
|
|
45
|
+
className={`w-full text-left px-3 py-2 rounded text-sm transition ${
|
|
46
|
+
active
|
|
47
|
+
? "bg-[#1a1a1a] text-[#e0e0e0]"
|
|
48
|
+
: "text-[#666] hover:bg-[#111] hover:text-[#888]"
|
|
49
|
+
}`}
|
|
50
|
+
>
|
|
51
|
+
{label}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ProvidersSettings() {
|
|
57
|
+
const [providers, setProviders] = useState<Provider[]>([]);
|
|
58
|
+
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
|
59
|
+
const [apiKey, setApiKey] = useState("");
|
|
60
|
+
const [saving, setSaving] = useState(false);
|
|
61
|
+
const [testing, setTesting] = useState(false);
|
|
62
|
+
const [error, setError] = useState<string | null>(null);
|
|
63
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
64
|
+
|
|
65
|
+
const fetchProviders = async () => {
|
|
66
|
+
const res = await fetch("/api/providers");
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
setProviders(data.providers || []);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
fetchProviders();
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const saveKey = async () => {
|
|
76
|
+
if (!selectedProvider || !apiKey) return;
|
|
77
|
+
setSaving(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
setSuccess(null);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
setTesting(true);
|
|
83
|
+
const testRes = await fetch(`/api/keys/${selectedProvider}/test`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ key: apiKey }),
|
|
87
|
+
});
|
|
88
|
+
const testData = await testRes.json();
|
|
89
|
+
setTesting(false);
|
|
90
|
+
|
|
91
|
+
if (!testData.valid) {
|
|
92
|
+
setError(testData.error || "API key is invalid");
|
|
93
|
+
setSaving(false);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const saveRes = await fetch(`/api/keys/${selectedProvider}`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify({ key: apiKey }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!saveRes.ok) {
|
|
104
|
+
const data = await saveRes.json();
|
|
105
|
+
setError(data.error || "Failed to save key");
|
|
106
|
+
} else {
|
|
107
|
+
setSuccess("API key saved!");
|
|
108
|
+
setApiKey("");
|
|
109
|
+
setSelectedProvider(null);
|
|
110
|
+
fetchProviders();
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
setError("Failed to save key");
|
|
114
|
+
}
|
|
115
|
+
setSaving(false);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const deleteKey = async (providerId: string) => {
|
|
119
|
+
if (!confirm("Are you sure you want to remove this API key?")) return;
|
|
120
|
+
await fetch(`/api/keys/${providerId}`, { method: "DELETE" });
|
|
121
|
+
fetchProviders();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const configuredCount = providers.filter(p => p.hasKey).length;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="max-w-4xl">
|
|
128
|
+
<div className="mb-6">
|
|
129
|
+
<h1 className="text-2xl font-semibold mb-1">AI Providers</h1>
|
|
130
|
+
<p className="text-[#666]">
|
|
131
|
+
Manage your API keys for AI providers. {configuredCount} of {providers.length} configured.
|
|
132
|
+
</p>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
136
|
+
{providers.map(provider => (
|
|
137
|
+
<ProviderKeyCard
|
|
138
|
+
key={provider.id}
|
|
139
|
+
provider={provider}
|
|
140
|
+
isEditing={selectedProvider === provider.id}
|
|
141
|
+
apiKey={apiKey}
|
|
142
|
+
saving={saving}
|
|
143
|
+
testing={testing}
|
|
144
|
+
error={selectedProvider === provider.id ? error : null}
|
|
145
|
+
success={selectedProvider === provider.id ? success : null}
|
|
146
|
+
onStartEdit={() => {
|
|
147
|
+
setSelectedProvider(provider.id);
|
|
148
|
+
setError(null);
|
|
149
|
+
setSuccess(null);
|
|
150
|
+
}}
|
|
151
|
+
onCancelEdit={() => {
|
|
152
|
+
setSelectedProvider(null);
|
|
153
|
+
setApiKey("");
|
|
154
|
+
setError(null);
|
|
155
|
+
}}
|
|
156
|
+
onApiKeyChange={setApiKey}
|
|
157
|
+
onSave={saveKey}
|
|
158
|
+
onDelete={() => deleteKey(provider.id)}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface ProviderKeyCardProps {
|
|
167
|
+
provider: Provider;
|
|
168
|
+
isEditing: boolean;
|
|
169
|
+
apiKey: string;
|
|
170
|
+
saving: boolean;
|
|
171
|
+
testing: boolean;
|
|
172
|
+
error: string | null;
|
|
173
|
+
success: string | null;
|
|
174
|
+
onStartEdit: () => void;
|
|
175
|
+
onCancelEdit: () => void;
|
|
176
|
+
onApiKeyChange: (key: string) => void;
|
|
177
|
+
onSave: () => void;
|
|
178
|
+
onDelete: () => void;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ProviderKeyCard({
|
|
182
|
+
provider,
|
|
183
|
+
isEditing,
|
|
184
|
+
apiKey,
|
|
185
|
+
saving,
|
|
186
|
+
testing,
|
|
187
|
+
error,
|
|
188
|
+
success,
|
|
189
|
+
onStartEdit,
|
|
190
|
+
onCancelEdit,
|
|
191
|
+
onApiKeyChange,
|
|
192
|
+
onSave,
|
|
193
|
+
onDelete,
|
|
194
|
+
}: ProviderKeyCardProps) {
|
|
195
|
+
return (
|
|
196
|
+
<div className={`bg-[#111] border rounded-lg p-4 ${
|
|
197
|
+
provider.hasKey ? 'border-green-500/20' : 'border-[#1a1a1a]'
|
|
198
|
+
}`}>
|
|
199
|
+
<div className="flex items-center justify-between mb-2">
|
|
200
|
+
<div>
|
|
201
|
+
<h3 className="font-medium">{provider.name}</h3>
|
|
202
|
+
<p className="text-sm text-[#666]">{provider.models.length} models</p>
|
|
203
|
+
</div>
|
|
204
|
+
{provider.hasKey ? (
|
|
205
|
+
<span className="text-green-400 text-xs flex items-center gap-1 bg-green-500/10 px-2 py-1 rounded">
|
|
206
|
+
<CheckIcon className="w-3 h-3" />
|
|
207
|
+
{provider.keyHint}
|
|
208
|
+
</span>
|
|
209
|
+
) : (
|
|
210
|
+
<span className="text-[#666] text-xs bg-[#1a1a1a] px-2 py-1 rounded">
|
|
211
|
+
Not configured
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{provider.hasKey ? (
|
|
217
|
+
<div className="flex items-center justify-between mt-3 pt-3 border-t border-[#1a1a1a]">
|
|
218
|
+
<a
|
|
219
|
+
href={provider.docsUrl}
|
|
220
|
+
target="_blank"
|
|
221
|
+
rel="noopener noreferrer"
|
|
222
|
+
className="text-sm text-[#3b82f6] hover:underline"
|
|
223
|
+
>
|
|
224
|
+
View docs
|
|
225
|
+
</a>
|
|
226
|
+
<button
|
|
227
|
+
onClick={onDelete}
|
|
228
|
+
className="text-red-400 hover:text-red-300 text-sm"
|
|
229
|
+
>
|
|
230
|
+
Remove key
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
) : (
|
|
234
|
+
<div className="mt-3 pt-3 border-t border-[#1a1a1a]">
|
|
235
|
+
{isEditing ? (
|
|
236
|
+
<div className="space-y-3">
|
|
237
|
+
<input
|
|
238
|
+
type="password"
|
|
239
|
+
value={apiKey}
|
|
240
|
+
onChange={e => onApiKeyChange(e.target.value)}
|
|
241
|
+
placeholder="Enter API key..."
|
|
242
|
+
autoFocus
|
|
243
|
+
className="w-full bg-[#0a0a0a] border border-[#333] rounded px-3 py-2 focus:outline-none focus:border-[#f97316]"
|
|
244
|
+
/>
|
|
245
|
+
{error && <p className="text-red-400 text-sm">{error}</p>}
|
|
246
|
+
{success && <p className="text-green-400 text-sm">{success}</p>}
|
|
247
|
+
<div className="flex gap-2">
|
|
248
|
+
<button
|
|
249
|
+
onClick={onCancelEdit}
|
|
250
|
+
className="flex-1 px-3 py-1.5 border border-[#333] rounded text-sm hover:border-[#666]"
|
|
251
|
+
>
|
|
252
|
+
Cancel
|
|
253
|
+
</button>
|
|
254
|
+
<button
|
|
255
|
+
onClick={onSave}
|
|
256
|
+
disabled={!apiKey || saving}
|
|
257
|
+
className="flex-1 px-3 py-1.5 bg-[#f97316] text-black rounded text-sm font-medium disabled:opacity-50"
|
|
258
|
+
>
|
|
259
|
+
{testing ? "Validating..." : saving ? "Saving..." : "Save"}
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
) : (
|
|
264
|
+
<div className="flex items-center justify-between">
|
|
265
|
+
<a
|
|
266
|
+
href={provider.docsUrl}
|
|
267
|
+
target="_blank"
|
|
268
|
+
rel="noopener noreferrer"
|
|
269
|
+
className="text-sm text-[#3b82f6] hover:underline"
|
|
270
|
+
>
|
|
271
|
+
Get API key
|
|
272
|
+
</a>
|
|
273
|
+
<button
|
|
274
|
+
onClick={onStartEdit}
|
|
275
|
+
className="text-sm text-[#f97316] hover:text-[#fb923c]"
|
|
276
|
+
>
|
|
277
|
+
+ Add key
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SettingsPage } from "./SettingsPage";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import type { Agent } from "../types";
|
|
3
|
+
|
|
4
|
+
export function useAgents(enabled: boolean) {
|
|
5
|
+
const [agents, setAgents] = useState<Agent[]>([]);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
|
|
8
|
+
const fetchAgents = useCallback(async () => {
|
|
9
|
+
const res = await fetch("/api/agents");
|
|
10
|
+
const data = await res.json();
|
|
11
|
+
setAgents(data.agents || []);
|
|
12
|
+
setLoading(false);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (enabled) {
|
|
17
|
+
fetchAgents();
|
|
18
|
+
}
|
|
19
|
+
}, [enabled, fetchAgents]);
|
|
20
|
+
|
|
21
|
+
const createAgent = async (agent: {
|
|
22
|
+
name: string;
|
|
23
|
+
model: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
systemPrompt: string;
|
|
26
|
+
}) => {
|
|
27
|
+
await fetch("/api/agents", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify(agent),
|
|
31
|
+
});
|
|
32
|
+
await fetchAgents();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const deleteAgent = async (id: string) => {
|
|
36
|
+
await fetch(`/api/agents/${id}`, { method: "DELETE" });
|
|
37
|
+
await fetchAgents();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const toggleAgent = async (agent: Agent): Promise<{ error?: string }> => {
|
|
41
|
+
const action = agent.status === "running" ? "stop" : "start";
|
|
42
|
+
const res = await fetch(`/api/agents/${agent.id}/${action}`, { method: "POST" });
|
|
43
|
+
const data = await res.json();
|
|
44
|
+
await fetchAgents();
|
|
45
|
+
if (!res.ok && data.error) {
|
|
46
|
+
return { error: data.error };
|
|
47
|
+
}
|
|
48
|
+
return {};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const runningCount = agents.filter(a => a.status === "running").length;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
agents,
|
|
55
|
+
loading,
|
|
56
|
+
runningCount,
|
|
57
|
+
fetchAgents,
|
|
58
|
+
createAgent,
|
|
59
|
+
deleteAgent,
|
|
60
|
+
toggleAgent,
|
|
61
|
+
};
|
|
62
|
+
}
|