apteva 0.4.57 → 0.7.1

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.
Files changed (142) hide show
  1. package/README.md +216 -54
  2. package/cli.js +35 -0
  3. package/install.js +92 -0
  4. package/package.json +15 -76
  5. package/LICENSE +0 -63
  6. package/bin/apteva.js +0 -196
  7. package/dist/ActivityPage.kxzzb4yc.js +0 -3
  8. package/dist/ApiDocsPage.zq998hbm.js +0 -4
  9. package/dist/App.55rea8mn.js +0 -61
  10. package/dist/App.5ywb23z4.js +0 -53
  11. package/dist/App.6thds120.js +0 -4
  12. package/dist/App.9tctxzqm.js +0 -8
  13. package/dist/App.a8r8ttaz.js +0 -4
  14. package/dist/App.agsv5bje.js +0 -4
  15. package/dist/App.cepapqmx.js +0 -4
  16. package/dist/App.dp041gb3.js +0 -221
  17. package/dist/App.fds72zb5.js +0 -4
  18. package/dist/App.fg9qj2dq.js +0 -4
  19. package/dist/App.ndfejbm9.js +0 -4
  20. package/dist/App.nxmfmq1h.js +0 -13
  21. package/dist/App.qdfyt8ba.js +0 -4
  22. package/dist/App.x2d0ygt6.js +0 -4
  23. package/dist/App.yt9p4nr3.js +0 -20
  24. package/dist/App.zn4mw16t.js +0 -1
  25. package/dist/ConnectionsPage.8r96ryw7.js +0 -3
  26. package/dist/McpPage.3cwh0gnd.js +0 -3
  27. package/dist/SettingsPage.ykgdh5ev.js +0 -3
  28. package/dist/SkillsPage.4np1s65b.js +0 -3
  29. package/dist/TasksPage.4g08t7p6.js +0 -3
  30. package/dist/TelemetryPage.72w9pwcp.js +0 -3
  31. package/dist/TestsPage.z4fk3r7r.js +0 -3
  32. package/dist/ThreadsPage.63tcajeh.js +0 -3
  33. package/dist/apteva-kit.css +0 -1
  34. package/dist/icon.png +0 -0
  35. package/dist/index.html +0 -16
  36. package/dist/styles.css +0 -1
  37. package/scripts/postinstall.mjs +0 -102
  38. package/src/auth/index.ts +0 -394
  39. package/src/auth/middleware.ts +0 -213
  40. package/src/binary.ts +0 -536
  41. package/src/channels/index.ts +0 -40
  42. package/src/channels/telegram.ts +0 -311
  43. package/src/crypto.ts +0 -301
  44. package/src/db-tests.ts +0 -174
  45. package/src/db.ts +0 -3133
  46. package/src/integrations/agentdojo.ts +0 -559
  47. package/src/integrations/composio.ts +0 -437
  48. package/src/integrations/index.ts +0 -87
  49. package/src/integrations/skillsmp.ts +0 -318
  50. package/src/mcp-client.ts +0 -605
  51. package/src/mcp-handler.ts +0 -394
  52. package/src/mcp-platform.ts +0 -2403
  53. package/src/openapi.ts +0 -2410
  54. package/src/providers.ts +0 -597
  55. package/src/routes/api/agent-utils.ts +0 -890
  56. package/src/routes/api/agents.ts +0 -916
  57. package/src/routes/api/api-keys.ts +0 -95
  58. package/src/routes/api/channels.ts +0 -182
  59. package/src/routes/api/helpers.ts +0 -12
  60. package/src/routes/api/integrations.ts +0 -639
  61. package/src/routes/api/mcp.ts +0 -574
  62. package/src/routes/api/meta-agent.ts +0 -195
  63. package/src/routes/api/projects.ts +0 -112
  64. package/src/routes/api/providers.ts +0 -424
  65. package/src/routes/api/skills.ts +0 -537
  66. package/src/routes/api/system.ts +0 -333
  67. package/src/routes/api/telemetry.ts +0 -203
  68. package/src/routes/api/tests.ts +0 -148
  69. package/src/routes/api/triggers.ts +0 -518
  70. package/src/routes/api/users.ts +0 -148
  71. package/src/routes/api/webhooks.ts +0 -171
  72. package/src/routes/api.ts +0 -53
  73. package/src/routes/auth.ts +0 -251
  74. package/src/routes/share.ts +0 -86
  75. package/src/routes/static.ts +0 -131
  76. package/src/server.ts +0 -642
  77. package/src/test-runner.ts +0 -598
  78. package/src/triggers/agentdojo.ts +0 -253
  79. package/src/triggers/composio.ts +0 -264
  80. package/src/triggers/index.ts +0 -71
  81. package/src/tui/AgentList.tsx +0 -145
  82. package/src/tui/App.tsx +0 -102
  83. package/src/tui/Login.tsx +0 -104
  84. package/src/tui/api.ts +0 -72
  85. package/src/tui/index.tsx +0 -7
  86. package/src/web/App.tsx +0 -455
  87. package/src/web/components/activity/ActivityPage.tsx +0 -314
  88. package/src/web/components/activity/index.ts +0 -1
  89. package/src/web/components/agents/AgentCard.tsx +0 -189
  90. package/src/web/components/agents/AgentPanel.tsx +0 -2244
  91. package/src/web/components/agents/AgentsView.tsx +0 -180
  92. package/src/web/components/agents/CreateAgentModal.tsx +0 -475
  93. package/src/web/components/agents/index.ts +0 -4
  94. package/src/web/components/api/ApiDocsPage.tsx +0 -842
  95. package/src/web/components/auth/CreateAccountStep.tsx +0 -176
  96. package/src/web/components/auth/LoginPage.tsx +0 -91
  97. package/src/web/components/auth/index.ts +0 -2
  98. package/src/web/components/common/Icons.tsx +0 -250
  99. package/src/web/components/common/LoadingSpinner.tsx +0 -44
  100. package/src/web/components/common/Modal.tsx +0 -199
  101. package/src/web/components/common/Select.tsx +0 -97
  102. package/src/web/components/common/index.ts +0 -20
  103. package/src/web/components/connections/ConnectionsPage.tsx +0 -54
  104. package/src/web/components/connections/IntegrationsTab.tsx +0 -170
  105. package/src/web/components/connections/OverviewTab.tsx +0 -137
  106. package/src/web/components/connections/TriggersTab.tsx +0 -1346
  107. package/src/web/components/dashboard/Dashboard.tsx +0 -572
  108. package/src/web/components/dashboard/index.ts +0 -1
  109. package/src/web/components/index.ts +0 -21
  110. package/src/web/components/layout/ErrorBanner.tsx +0 -18
  111. package/src/web/components/layout/Header.tsx +0 -332
  112. package/src/web/components/layout/Sidebar.tsx +0 -231
  113. package/src/web/components/layout/index.ts +0 -3
  114. package/src/web/components/mcp/IntegrationsPanel.tsx +0 -857
  115. package/src/web/components/mcp/McpPage.tsx +0 -2515
  116. package/src/web/components/mcp/index.ts +0 -1
  117. package/src/web/components/meta-agent/MetaAgent.tsx +0 -245
  118. package/src/web/components/onboarding/OnboardingWizard.tsx +0 -404
  119. package/src/web/components/onboarding/index.ts +0 -1
  120. package/src/web/components/settings/SettingsPage.tsx +0 -2776
  121. package/src/web/components/settings/index.ts +0 -1
  122. package/src/web/components/skills/SkillsPage.tsx +0 -1200
  123. package/src/web/components/tasks/TasksPage.tsx +0 -1116
  124. package/src/web/components/tasks/index.ts +0 -1
  125. package/src/web/components/telemetry/TelemetryPage.tsx +0 -1129
  126. package/src/web/components/tests/TestsPage.tsx +0 -594
  127. package/src/web/components/threads/ThreadsPage.tsx +0 -315
  128. package/src/web/context/AuthContext.tsx +0 -242
  129. package/src/web/context/ProjectContext.tsx +0 -214
  130. package/src/web/context/TelemetryContext.tsx +0 -299
  131. package/src/web/context/ThemeContext.tsx +0 -90
  132. package/src/web/context/UIModeContext.tsx +0 -49
  133. package/src/web/context/index.ts +0 -12
  134. package/src/web/hooks/index.ts +0 -3
  135. package/src/web/hooks/useAgents.ts +0 -115
  136. package/src/web/hooks/useOnboarding.ts +0 -20
  137. package/src/web/hooks/useProviders.ts +0 -75
  138. package/src/web/icon.png +0 -0
  139. package/src/web/index.html +0 -16
  140. package/src/web/styles.css +0 -118
  141. package/src/web/themes.ts +0 -162
  142. package/src/web/types.ts +0 -298
@@ -1,2776 +0,0 @@
1
- import React, { useState, useEffect } from "react";
2
- import { CheckIcon, CloseIcon, PlusIcon } from "../common/Icons";
3
- import { Modal, useConfirm } from "../common/Modal";
4
- import { Select } from "../common/Select";
5
- import { useProjects, useAuth, useTheme, useUIMode, type Project } from "../../context";
6
- import type { ThemeMode, ThemeStyle } from "../../themes";
7
- import type { Provider } from "../../types";
8
- import type { UIMode } from "../../context";
9
-
10
- type SettingsTab = "general" | "providers" | "projects" | "channels" | "api-keys" | "account" | "updates" | "data" | "assistant";
11
-
12
- export function SettingsPage() {
13
- const { projectsEnabled, metaAgentEnabled } = useProjects();
14
- const { isBusiness } = useUIMode();
15
- const [activeTab, setActiveTab] = useState<SettingsTab>("general");
16
-
17
- const hiddenInBusiness: SettingsTab[] = ["providers", "api-keys", "data"];
18
-
19
- const tabs: { key: SettingsTab; label: string }[] = [
20
- { key: "general", label: "General" },
21
- { key: "providers", label: "Providers" },
22
- ...(projectsEnabled ? [{ key: "projects" as SettingsTab, label: "Projects" }] : []),
23
- ...(metaAgentEnabled ? [{ key: "assistant" as SettingsTab, label: "Assistant" }] : []),
24
- { key: "channels", label: "Channels" },
25
- { key: "api-keys", label: "API Keys" },
26
- { key: "account", label: "Account" },
27
- { key: "updates", label: "Updates" },
28
- { key: "data", label: "Data" },
29
- ].filter(tab => !isBusiness || !hiddenInBusiness.includes(tab.key));
30
-
31
- return (
32
- <div className="flex-1 flex flex-col md:flex-row overflow-hidden">
33
- {/* Mobile: Horizontal scrolling tabs */}
34
- <div className="md:hidden border-b border-[var(--color-border)] bg-[var(--color-bg)]">
35
- <div className="flex overflow-x-auto" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
36
- {tabs.map(tab => (
37
- <button
38
- key={tab.key}
39
- onClick={() => setActiveTab(tab.key)}
40
- className={`flex-shrink-0 px-4 py-3 text-sm font-medium border-b-2 transition ${
41
- activeTab === tab.key
42
- ? "border-[var(--color-accent)] text-[var(--color-accent)]"
43
- : "border-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
44
- }`}
45
- >
46
- {tab.label}
47
- </button>
48
- ))}
49
- </div>
50
- </div>
51
-
52
- {/* Desktop: Settings Sidebar */}
53
- <div className="hidden md:block w-48 border-r border-[var(--color-border)] p-4 flex-shrink-0">
54
- <h2 className="text-sm font-medium text-[var(--color-text-muted)] uppercase tracking-wider mb-3">Settings</h2>
55
- <nav className="space-y-1">
56
- {tabs.map(tab => (
57
- <SettingsNavItem
58
- key={tab.key}
59
- label={tab.label}
60
- active={activeTab === tab.key}
61
- onClick={() => setActiveTab(tab.key)}
62
- />
63
- ))}
64
- </nav>
65
- </div>
66
-
67
- {/* Settings Content */}
68
- <div className="flex-1 overflow-auto p-4 md:p-6">
69
- {activeTab === "general" && <GeneralSettings />}
70
- {activeTab === "providers" && <ProvidersSettings />}
71
- {activeTab === "projects" && projectsEnabled && <ProjectsSettings />}
72
- {activeTab === "channels" && <ChannelsSettings />}
73
- {activeTab === "api-keys" && <ApiKeysSettings />}
74
- {activeTab === "account" && <AccountSettings />}
75
- {activeTab === "updates" && <UpdatesSettings />}
76
- {activeTab === "data" && <DataSettings />}
77
- {activeTab === "assistant" && metaAgentEnabled && <AssistantSettings />}
78
- </div>
79
- </div>
80
- );
81
- }
82
-
83
- function SettingsNavItem({
84
- label,
85
- active,
86
- onClick
87
- }: {
88
- label: string;
89
- active: boolean;
90
- onClick: () => void;
91
- }) {
92
- return (
93
- <button
94
- onClick={onClick}
95
- className={`w-full text-left px-3 py-2 rounded text-sm transition ${
96
- active
97
- ? "bg-[var(--color-surface-raised)] text-[var(--color-text)]"
98
- : "text-[var(--color-text-muted)] hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
99
- }`}
100
- >
101
- {label}
102
- </button>
103
- );
104
- }
105
-
106
- function GeneralSettings() {
107
- const { authFetch } = useAuth();
108
- const { mode, style, setMode, setStyle } = useTheme();
109
- const { mode: uiMode, setMode: setUIMode } = useUIMode();
110
- const [instanceUrl, setInstanceUrl] = useState("");
111
- const [loading, setLoading] = useState(true);
112
- const [saving, setSaving] = useState(false);
113
- const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
114
-
115
- useEffect(() => {
116
- const fetch = async () => {
117
- try {
118
- const res = await authFetch("/api/settings/instance-url");
119
- const data = await res.json();
120
- setInstanceUrl(data.instance_url || "");
121
- } catch {
122
- // ignore
123
- }
124
- setLoading(false);
125
- };
126
- fetch();
127
- }, []);
128
-
129
- const handleSave = async () => {
130
- setSaving(true);
131
- setMessage(null);
132
- try {
133
- const res = await authFetch("/api/settings/instance-url", {
134
- method: "PUT",
135
- headers: { "Content-Type": "application/json" },
136
- body: JSON.stringify({ instance_url: instanceUrl }),
137
- });
138
- const data = await res.json();
139
- if (res.ok) {
140
- setInstanceUrl(data.instance_url || "");
141
- setMessage({ type: "success", text: "Instance URL saved" });
142
- } else {
143
- setMessage({ type: "error", text: data.error || "Failed to save" });
144
- }
145
- } catch {
146
- setMessage({ type: "error", text: "Failed to save" });
147
- }
148
- setSaving(false);
149
- };
150
-
151
- const themeOptions: { value: ThemeMode; label: string; description: string }[] = [
152
- { value: "auto", label: "Auto", description: "Follow system preference" },
153
- { value: "dark", label: "Dark", description: "Dark background" },
154
- { value: "light", label: "Light", description: "Light background" },
155
- ];
156
-
157
- const styleOptions: { value: ThemeStyle; label: string; description: string }[] = [
158
- { value: "classic", label: "Classic", description: "Terminal-inspired, sharp" },
159
- { value: "professional", label: "Professional", description: "Polished, enterprise" },
160
- ];
161
-
162
- return (
163
- <div className="max-w-4xl w-full">
164
- <div className="mb-6">
165
- <h1 className="text-2xl font-semibold mb-1">General</h1>
166
- <p className="text-[var(--color-text-muted)]">Instance configuration and appearance.</p>
167
- </div>
168
-
169
- {/* Appearance */}
170
- <div className="bg-[var(--color-surface)] card p-4 mb-4">
171
- <h3 className="font-medium mb-4">Appearance</h3>
172
-
173
- {/* Color scheme */}
174
- <p className="text-sm text-[var(--color-text-secondary)] mb-2">Color scheme</p>
175
- <div className="flex gap-3 mb-5">
176
- {themeOptions.map(opt => (
177
- <button
178
- key={opt.value}
179
- onClick={() => setMode(opt.value)}
180
- className={`flex-1 max-w-[160px] px-4 py-3 border text-left transition ${
181
- mode === opt.value
182
- ? "border-[var(--color-accent)] bg-[var(--color-accent-10)]"
183
- : "border-[var(--color-border-light)] bg-[var(--color-bg)] hover:border-[var(--color-scrollbar)]"
184
- }`}
185
- style={{ borderRadius: "var(--radius-card)" }}
186
- >
187
- <div className="flex items-center gap-2 mb-1">
188
- <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
189
- mode === opt.value ? "border-[var(--color-accent)]" : "border-[var(--color-scrollbar)]"
190
- }`}>
191
- {mode === opt.value && <div className="w-2 h-2 rounded-full bg-[var(--color-accent)]" />}
192
- </div>
193
- <span className="text-sm font-medium">{opt.label}</span>
194
- </div>
195
- <p className="text-xs text-[var(--color-text-muted)] ml-6">{opt.description}</p>
196
- </button>
197
- ))}
198
- </div>
199
-
200
- {/* Style */}
201
- <p className="text-sm text-[var(--color-text-secondary)] mb-2">Style</p>
202
- <div className="flex gap-3">
203
- {styleOptions.map(opt => (
204
- <button
205
- key={opt.value}
206
- onClick={() => setStyle(opt.value)}
207
- className={`flex-1 max-w-[200px] px-4 py-3 border text-left transition ${
208
- style === opt.value
209
- ? "border-[var(--color-accent)] bg-[var(--color-accent-10)]"
210
- : "border-[var(--color-border-light)] bg-[var(--color-bg)] hover:border-[var(--color-scrollbar)]"
211
- }`}
212
- style={{ borderRadius: "var(--radius-card)" }}
213
- >
214
- <div className="flex items-center gap-2 mb-1">
215
- <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
216
- style === opt.value ? "border-[var(--color-accent)]" : "border-[var(--color-scrollbar)]"
217
- }`}>
218
- {style === opt.value && <div className="w-2 h-2 rounded-full bg-[var(--color-accent)]" />}
219
- </div>
220
- <span className="text-sm font-medium">{opt.label}</span>
221
- </div>
222
- <p className="text-xs text-[var(--color-text-muted)] ml-6">{opt.description}</p>
223
- </button>
224
- ))}
225
- </div>
226
- </div>
227
-
228
- {/* UI Mode */}
229
- <div className="bg-[var(--color-surface)] card p-4 mb-4">
230
- <h3 className="font-medium mb-2">UI Mode</h3>
231
- <p className="text-sm text-[var(--color-text-muted)] mb-4">Switch between developer and business views.</p>
232
- <div className="flex gap-3">
233
- {([
234
- { value: "developer" as UIMode, label: "Developer", description: "Full control over agents, providers, MCP, and configuration" },
235
- { value: "business" as UIMode, label: "Business", description: "Simplified view focused on employees and conversations" },
236
- ]).map(opt => (
237
- <button
238
- key={opt.value}
239
- onClick={() => setUIMode(opt.value)}
240
- className={`flex-1 max-w-[240px] px-4 py-3 border text-left transition ${
241
- uiMode === opt.value
242
- ? "border-[var(--color-accent)] bg-[var(--color-accent-10)]"
243
- : "border-[var(--color-border-light)] bg-[var(--color-bg)] hover:border-[var(--color-scrollbar)]"
244
- }`}
245
- style={{ borderRadius: "var(--radius-card)" }}
246
- >
247
- <div className="flex items-center gap-2 mb-1">
248
- <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
249
- uiMode === opt.value ? "border-[var(--color-accent)]" : "border-[var(--color-scrollbar)]"
250
- }`}>
251
- {uiMode === opt.value && <div className="w-2 h-2 rounded-full bg-[var(--color-accent)]" />}
252
- </div>
253
- <span className="text-sm font-medium">{opt.label}</span>
254
- </div>
255
- <p className="text-xs text-[var(--color-text-muted)] ml-6">{opt.description}</p>
256
- </button>
257
- ))}
258
- </div>
259
- </div>
260
-
261
- <div className="bg-[var(--color-surface)] card p-4">
262
- <h3 className="font-medium mb-2">Instance URL</h3>
263
- <p className="text-sm text-[var(--color-text-muted)] mb-4">
264
- The public HTTPS URL for this instance. Used for webhook callbacks from external services like Composio.
265
- </p>
266
-
267
- {loading ? (
268
- <div className="text-[var(--color-text-muted)] text-sm">Loading...</div>
269
- ) : (
270
- <div className="space-y-3 max-w-lg">
271
- <input
272
- type="text"
273
- value={instanceUrl}
274
- onChange={e => setInstanceUrl(e.target.value)}
275
- placeholder="https://your-domain.com"
276
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)] font-mono text-sm"
277
- />
278
-
279
- {message && (
280
- <div className={`p-3 rounded text-sm ${
281
- message.type === "success"
282
- ? "bg-green-500/10 text-green-400 border border-green-500/30"
283
- : "bg-red-500/10 text-red-400 border border-red-500/30"
284
- }`}>
285
- {message.text}
286
- </div>
287
- )}
288
-
289
- <button
290
- onClick={handleSave}
291
- disabled={saving}
292
- className="px-4 py-2 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] disabled:opacity-50 text-black rounded text-sm font-medium transition"
293
- >
294
- {saving ? "Saving..." : "Save"}
295
- </button>
296
- </div>
297
- )}
298
- </div>
299
- </div>
300
- );
301
- }
302
-
303
- function ProvidersSettings() {
304
- const { authFetch } = useAuth();
305
- const { projects, projectsEnabled } = useProjects();
306
- const [providers, setProviders] = useState<Provider[]>([]);
307
- const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
308
- const [apiKey, setApiKey] = useState("");
309
- const [extraField, setExtraField] = useState("");
310
- const [saving, setSaving] = useState(false);
311
- const [testing, setTesting] = useState(false);
312
- const [error, setError] = useState<string | null>(null);
313
- const [success, setSuccess] = useState<string | null>(null);
314
- const { confirm, ConfirmDialog } = useConfirm();
315
-
316
- const fetchProviders = async () => {
317
- const res = await authFetch("/api/providers");
318
- const data = await res.json();
319
- setProviders(data.providers || []);
320
- };
321
-
322
- useEffect(() => {
323
- fetchProviders();
324
- }, []);
325
-
326
- const saveKey = async () => {
327
- if (!selectedProvider || !apiKey) return;
328
- setSaving(true);
329
- setError(null);
330
- setSuccess(null);
331
-
332
- // For multi-field providers, combine into JSON
333
- let keyToSave = apiKey;
334
- if (selectedProvider === "browserbase" && extraField) {
335
- keyToSave = JSON.stringify({ api_key: apiKey, project_id: extraField });
336
- }
337
-
338
- try {
339
- setTesting(true);
340
- const testRes = await authFetch(`/api/keys/${selectedProvider}/test`, {
341
- method: "POST",
342
- headers: { "Content-Type": "application/json" },
343
- body: JSON.stringify({ key: keyToSave }),
344
- });
345
- const testData = await testRes.json();
346
- setTesting(false);
347
-
348
- if (!testData.valid) {
349
- setError(testData.error || "API key is invalid");
350
- setSaving(false);
351
- return;
352
- }
353
-
354
- const saveRes = await authFetch(`/api/keys/${selectedProvider}`, {
355
- method: "POST",
356
- headers: { "Content-Type": "application/json" },
357
- body: JSON.stringify({ key: keyToSave }),
358
- });
359
-
360
- const saveData = await saveRes.json();
361
- if (!saveRes.ok) {
362
- setError(saveData.error || "Failed to save key");
363
- } else {
364
- // Build success message including agent restart info
365
- let msg = "API key saved!";
366
- if (saveData.restartedAgents && saveData.restartedAgents.length > 0) {
367
- const successCount = saveData.restartedAgents.filter((a: { success: boolean }) => a.success).length;
368
- const failCount = saveData.restartedAgents.length - successCount;
369
- if (failCount === 0) {
370
- msg += ` Restarted ${successCount} agent${successCount > 1 ? 's' : ''} with new key.`;
371
- } else {
372
- msg += ` Restarted ${successCount}/${saveData.restartedAgents.length} agents.`;
373
- }
374
- }
375
- setSuccess(msg);
376
- setApiKey("");
377
- setExtraField("");
378
- setSelectedProvider(null);
379
- fetchProviders();
380
- }
381
- } catch (e) {
382
- setError("Failed to save key");
383
- }
384
- setSaving(false);
385
- };
386
-
387
- const deleteKey = async (providerId: string) => {
388
- const confirmed = await confirm("Are you sure you want to remove this API key?", { confirmText: "Remove", title: "Remove API Key" });
389
- if (!confirmed) return;
390
- await authFetch(`/api/keys/${providerId}`, { method: "DELETE" });
391
- fetchProviders();
392
- };
393
-
394
- const llmProviders = providers.filter(p => p.type === "llm");
395
- const cloudVoiceProviders = providers.filter(p => p.type === "voice" && !p.isLocal);
396
- const localVoiceProviders = providers.filter(p => p.type === "voice" && p.isLocal);
397
- const voiceProviders = providers.filter(p => p.type === "voice");
398
- const integrations = providers.filter(p => p.type === "integration");
399
- const browserProviders = providers.filter(p => p.type === "browser");
400
- const llmConfiguredCount = llmProviders.filter(p => p.hasKey).length;
401
- const voiceConfiguredCount = voiceProviders.filter(p => p.hasKey).length;
402
- const intConfiguredCount = integrations.filter(p => p.hasKey).length;
403
- const browserConfiguredCount = browserProviders.filter(p => p.hasKey).length;
404
-
405
- // Auto-dismiss success message after 5 seconds
406
- useEffect(() => {
407
- if (success && !selectedProvider) {
408
- const timer = setTimeout(() => setSuccess(null), 5000);
409
- return () => clearTimeout(timer);
410
- }
411
- }, [success, selectedProvider]);
412
-
413
- return (
414
- <>
415
- {ConfirmDialog}
416
- <div className="space-y-10">
417
- {/* Global Success Banner */}
418
- {success && !selectedProvider && (
419
- <div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4 flex items-center justify-between">
420
- <div className="flex items-center gap-2 text-green-400">
421
- <CheckIcon className="w-5 h-5" />
422
- <span>{success}</span>
423
- </div>
424
- <button
425
- onClick={() => setSuccess(null)}
426
- className="text-green-400 hover:text-green-300"
427
- >
428
- <CloseIcon className="w-4 h-4" />
429
- </button>
430
- </div>
431
- )}
432
-
433
- {/* AI Providers Section */}
434
- <div>
435
- <div className="mb-6">
436
- <h1 className="text-2xl font-semibold mb-1">AI Providers</h1>
437
- <p className="text-[var(--color-text-muted)]">
438
- Manage your API keys for AI providers. {llmConfiguredCount} of {llmProviders.length} configured.
439
- </p>
440
- </div>
441
-
442
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
443
- {llmProviders.map(provider => (
444
- <ProviderKeyCard
445
- key={provider.id}
446
- provider={provider}
447
- isEditing={selectedProvider === provider.id}
448
- apiKey={apiKey}
449
- saving={saving}
450
- testing={testing}
451
- error={selectedProvider === provider.id ? error : null}
452
- success={selectedProvider === provider.id ? success : null}
453
- onStartEdit={() => {
454
- setSelectedProvider(provider.id);
455
- setError(null);
456
- setSuccess(null);
457
- }}
458
- onCancelEdit={() => {
459
- setSelectedProvider(null);
460
- setApiKey("");
461
- setError(null);
462
- }}
463
- onApiKeyChange={setApiKey}
464
- onSave={saveKey}
465
- onDelete={() => deleteKey(provider.id)}
466
- />
467
- ))}
468
- </div>
469
- </div>
470
-
471
- {/* Voice Providers Section */}
472
- <div>
473
- <div className="mb-6">
474
- <h2 className="text-xl font-semibold mb-1">Voice Providers</h2>
475
- <p className="text-[var(--color-text-muted)]">
476
- Configure voice providers for real-time voice conversations. {voiceConfiguredCount} of {voiceProviders.length} configured.
477
- </p>
478
- </div>
479
-
480
- {/* Cloud Voice Providers */}
481
- <h3 className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 uppercase tracking-wider">Cloud</h3>
482
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mb-6">
483
- {cloudVoiceProviders.map(provider => (
484
- <IntegrationKeyCard
485
- key={provider.id}
486
- provider={provider}
487
- isEditing={selectedProvider === provider.id}
488
- apiKey={apiKey}
489
- saving={saving}
490
- testing={testing}
491
- error={selectedProvider === provider.id ? error : null}
492
- success={selectedProvider === provider.id ? success : null}
493
- onStartEdit={() => {
494
- setSelectedProvider(provider.id);
495
- setError(null);
496
- setSuccess(null);
497
- }}
498
- onCancelEdit={() => {
499
- setSelectedProvider(null);
500
- setApiKey("");
501
- setError(null);
502
- }}
503
- onApiKeyChange={setApiKey}
504
- onSave={saveKey}
505
- onDelete={() => deleteKey(provider.id)}
506
- projectsEnabled={projectsEnabled}
507
- projects={projects}
508
- onRefresh={fetchProviders}
509
- />
510
- ))}
511
- </div>
512
-
513
- {/* Local Voice Providers */}
514
- <h3 className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 uppercase tracking-wider">Local</h3>
515
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
516
- {localVoiceProviders.map(provider => (
517
- <ProviderKeyCard
518
- key={provider.id}
519
- provider={provider}
520
- isEditing={selectedProvider === provider.id}
521
- apiKey={apiKey}
522
- saving={saving}
523
- testing={testing}
524
- error={selectedProvider === provider.id ? error : null}
525
- success={selectedProvider === provider.id ? success : null}
526
- onStartEdit={() => {
527
- setSelectedProvider(provider.id);
528
- setError(null);
529
- setSuccess(null);
530
- }}
531
- onCancelEdit={() => {
532
- setSelectedProvider(null);
533
- setApiKey("");
534
- setError(null);
535
- }}
536
- onApiKeyChange={setApiKey}
537
- onSave={saveKey}
538
- onDelete={() => deleteKey(provider.id)}
539
- />
540
- ))}
541
- </div>
542
- </div>
543
-
544
- {/* MCP Integrations Section */}
545
- <div>
546
- <div className="mb-6">
547
- <h2 className="text-xl font-semibold mb-1">MCP Integrations</h2>
548
- <p className="text-[var(--color-text-muted)]">
549
- Connect to MCP gateways for tool integrations. {intConfiguredCount} of {integrations.length} configured.
550
- </p>
551
- </div>
552
-
553
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
554
- {integrations.map(provider => (
555
- <IntegrationKeyCard
556
- key={provider.id}
557
- provider={provider}
558
- isEditing={selectedProvider === provider.id}
559
- apiKey={apiKey}
560
- saving={saving}
561
- testing={testing}
562
- error={selectedProvider === provider.id ? error : null}
563
- success={selectedProvider === provider.id ? success : null}
564
- onStartEdit={() => {
565
- setSelectedProvider(provider.id);
566
- setError(null);
567
- setSuccess(null);
568
- }}
569
- onCancelEdit={() => {
570
- setSelectedProvider(null);
571
- setApiKey("");
572
- setError(null);
573
- }}
574
- onApiKeyChange={setApiKey}
575
- onSave={saveKey}
576
- onDelete={() => deleteKey(provider.id)}
577
- projectsEnabled={projectsEnabled}
578
- projects={projects}
579
- onRefresh={fetchProviders}
580
- />
581
- ))}
582
- </div>
583
- </div>
584
-
585
- {/* Browser Providers Section */}
586
- <div>
587
- <div className="mb-6">
588
- <h2 className="text-xl font-semibold mb-1">Browser Providers</h2>
589
- <p className="text-[var(--color-text-muted)]">
590
- Configure browser environments for operator mode (computer use). {browserConfiguredCount} of {browserProviders.length} configured.
591
- </p>
592
- </div>
593
-
594
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
595
- {browserProviders.map(provider => (
596
- <IntegrationKeyCard
597
- key={provider.id}
598
- provider={provider}
599
- isEditing={selectedProvider === provider.id}
600
- apiKey={apiKey}
601
- saving={saving}
602
- testing={testing}
603
- error={selectedProvider === provider.id ? error : null}
604
- success={selectedProvider === provider.id ? success : null}
605
- onStartEdit={() => {
606
- setSelectedProvider(provider.id);
607
- setError(null);
608
- setSuccess(null);
609
- }}
610
- onCancelEdit={() => {
611
- setSelectedProvider(null);
612
- setApiKey("");
613
- setError(null);
614
- }}
615
- onApiKeyChange={setApiKey}
616
- onSave={saveKey}
617
- onDelete={() => deleteKey(provider.id)}
618
- projectsEnabled={projectsEnabled}
619
- projects={projects}
620
- onRefresh={fetchProviders}
621
- />
622
- ))}
623
- </div>
624
- </div>
625
- </div>
626
- </>
627
- );
628
- }
629
-
630
- const DEFAULT_PROJECT_COLORS = [
631
- "#f97316", // orange
632
- "#6366f1", // indigo
633
- "#22c55e", // green
634
- "#ef4444", // red
635
- "#3b82f6", // blue
636
- "#a855f7", // purple
637
- "#14b8a6", // teal
638
- "#f59e0b", // amber
639
- ];
640
-
641
- function ProjectsSettings() {
642
- const { projects, createProject, updateProject, deleteProject } = useProjects();
643
- const [showModal, setShowModal] = useState(false);
644
- const [editingProject, setEditingProject] = useState<Project | null>(null);
645
- const { confirm, ConfirmDialog } = useConfirm();
646
-
647
- const handleDelete = async (id: string) => {
648
- const confirmed = await confirm("Are you sure you want to delete this project? Agents in this project will become unassigned.", { confirmText: "Delete", title: "Delete Project" });
649
- if (!confirmed) return;
650
- await deleteProject(id);
651
- };
652
-
653
- const openCreate = () => {
654
- setEditingProject(null);
655
- setShowModal(true);
656
- };
657
-
658
- const openEdit = (project: Project) => {
659
- setEditingProject(project);
660
- setShowModal(true);
661
- };
662
-
663
- const closeModal = () => {
664
- setShowModal(false);
665
- setEditingProject(null);
666
- };
667
-
668
- return (
669
- <>
670
- {ConfirmDialog}
671
- <div className="max-w-4xl w-full">
672
- <div className="mb-6 flex items-center justify-between gap-4">
673
- <div>
674
- <h1 className="text-2xl font-semibold mb-1">Projects</h1>
675
- <p className="text-[var(--color-text-muted)]">
676
- Organize agents into projects for better management.
677
- </p>
678
- </div>
679
- <button
680
- onClick={openCreate}
681
- className="flex items-center gap-2 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] text-black px-4 py-2 rounded font-medium transition flex-shrink-0"
682
- >
683
- <PlusIcon className="w-4 h-4" />
684
- New Project
685
- </button>
686
- </div>
687
-
688
- {/* Project List */}
689
- {projects.length === 0 ? (
690
- <div className="text-center py-12 text-[var(--color-text-muted)]">
691
- <p className="text-lg mb-2">No projects yet</p>
692
- <p className="text-sm">Create a project to organize your agents.</p>
693
- </div>
694
- ) : (
695
- <div className="space-y-3">
696
- {projects.map(project => (
697
- <div
698
- key={project.id}
699
- className="bg-[var(--color-surface)] card p-4 flex items-center gap-4"
700
- >
701
- <div
702
- className="w-4 h-4 rounded-full flex-shrink-0"
703
- style={{ backgroundColor: project.color }}
704
- />
705
- <div className="flex-1 min-w-0">
706
- <h3 className="font-medium">{project.name}</h3>
707
- {project.description && (
708
- <p className="text-sm text-[var(--color-text-muted)] truncate">{project.description}</p>
709
- )}
710
- <p className="text-xs text-[var(--color-text-muted)] mt-1">
711
- {project.agentCount} agent{project.agentCount !== 1 ? "s" : ""}
712
- </p>
713
- </div>
714
- <div className="flex items-center gap-2">
715
- <button
716
- onClick={() => openEdit(project)}
717
- className="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text)] px-2 py-1"
718
- >
719
- Edit
720
- </button>
721
- <button
722
- onClick={() => handleDelete(project.id)}
723
- className="text-sm text-red-400 hover:text-red-300 px-2 py-1"
724
- >
725
- Delete
726
- </button>
727
- </div>
728
- </div>
729
- ))}
730
- </div>
731
- )}
732
-
733
- {/* Project Modal */}
734
- {showModal && (
735
- <ProjectModal
736
- project={editingProject}
737
- onSave={async (data) => {
738
- if (editingProject) {
739
- const result = await updateProject(editingProject.id, data);
740
- if (result) closeModal();
741
- return !!result;
742
- } else {
743
- const result = await createProject(data);
744
- if (result) closeModal();
745
- return !!result;
746
- }
747
- }}
748
- onClose={closeModal}
749
- />
750
- )}
751
- </div>
752
- </>
753
- );
754
- }
755
-
756
- interface ProjectModalProps {
757
- project: Project | null;
758
- onSave: (data: { name: string; description?: string; color: string }) => Promise<boolean>;
759
- onClose: () => void;
760
- }
761
-
762
- function ProjectModal({ project, onSave, onClose }: ProjectModalProps) {
763
- const [name, setName] = useState(project?.name || "");
764
- const [description, setDescription] = useState(project?.description || "");
765
- const [color, setColor] = useState(
766
- project?.color || DEFAULT_PROJECT_COLORS[Math.floor(Math.random() * DEFAULT_PROJECT_COLORS.length)]
767
- );
768
- const [saving, setSaving] = useState(false);
769
- const [error, setError] = useState<string | null>(null);
770
-
771
- const handleSubmit = async () => {
772
- if (!name.trim()) {
773
- setError("Name is required");
774
- return;
775
- }
776
- setSaving(true);
777
- setError(null);
778
- const success = await onSave({ name, description: description || undefined, color });
779
- setSaving(false);
780
- if (!success) {
781
- setError(project ? "Failed to update project" : "Failed to create project");
782
- }
783
- };
784
-
785
- return (
786
- <Modal onClose={onClose}>
787
- <h2 className="text-xl font-semibold mb-6">{project ? "Edit Project" : "Create New Project"}</h2>
788
-
789
- <div className="space-y-4">
790
- <div>
791
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Name</label>
792
- <input
793
- type="text"
794
- value={name}
795
- onChange={e => setName(e.target.value)}
796
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
797
- placeholder="My Project"
798
- autoFocus
799
- />
800
- </div>
801
-
802
- <div>
803
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Description (optional)</label>
804
- <input
805
- type="text"
806
- value={description}
807
- onChange={e => setDescription(e.target.value)}
808
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
809
- placeholder="A short description"
810
- />
811
- </div>
812
-
813
- <div>
814
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Color</label>
815
- <div className="flex gap-3 flex-wrap">
816
- {DEFAULT_PROJECT_COLORS.map(c => (
817
- <button
818
- key={c}
819
- type="button"
820
- onClick={() => setColor(c)}
821
- className={`w-10 h-10 rounded-full transition ${
822
- color === c ? "ring-2 ring-white ring-offset-2 ring-offset-[#111]" : "hover:scale-110"
823
- }`}
824
- style={{ backgroundColor: c }}
825
- />
826
- ))}
827
- </div>
828
- </div>
829
-
830
- {error && <p className="text-red-400 text-sm">{error}</p>}
831
- </div>
832
-
833
- <div className="flex gap-3 mt-6">
834
- <button
835
- onClick={onClose}
836
- className="flex-1 border border-[var(--color-border-light)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] px-4 py-2 rounded font-medium transition"
837
- >
838
- Cancel
839
- </button>
840
- <button
841
- onClick={handleSubmit}
842
- disabled={saving || !name.trim()}
843
- className="flex-1 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] disabled:opacity-50 text-black px-4 py-2 rounded font-medium transition"
844
- >
845
- {saving ? "Saving..." : project ? "Update" : "Create"}
846
- </button>
847
- </div>
848
- </Modal>
849
- );
850
- }
851
-
852
- interface ProviderKeyCardProps {
853
- provider: Provider;
854
- isEditing: boolean;
855
- apiKey: string;
856
- saving: boolean;
857
- testing: boolean;
858
- error: string | null;
859
- success: string | null;
860
- onStartEdit: () => void;
861
- onCancelEdit: () => void;
862
- onApiKeyChange: (key: string) => void;
863
- onSave: () => void;
864
- onDelete: () => void;
865
- extraField?: string;
866
- onExtraFieldChange?: (val: string) => void;
867
- }
868
-
869
- interface VersionInfo {
870
- installed: string | null;
871
- latest: string | null;
872
- updateAvailable: boolean;
873
- lastChecked: string | null;
874
- }
875
-
876
- interface AllVersionInfo {
877
- apteva: VersionInfo;
878
- agent: VersionInfo;
879
- isDocker?: boolean;
880
- }
881
-
882
- function UpdatesSettings() {
883
- const { authFetch } = useAuth();
884
- const [versions, setVersions] = useState<AllVersionInfo | null>(null);
885
- const [checking, setChecking] = useState(false);
886
- const [updatingAgent, setUpdatingAgent] = useState(false);
887
- const [error, setError] = useState<string | null>(null);
888
- const [updateSuccess, setUpdateSuccess] = useState<string | null>(null);
889
- const [copied, setCopied] = useState<string | null>(null);
890
-
891
- const checkForUpdates = async () => {
892
- setChecking(true);
893
- setError(null);
894
- try {
895
- const res = await authFetch("/api/version");
896
- if (!res.ok) throw new Error("Failed to check for updates");
897
- const data = await res.json();
898
- setVersions(data);
899
- } catch (e) {
900
- setError("Failed to check for updates");
901
- }
902
- setChecking(false);
903
- };
904
-
905
- const updateAgent = async () => {
906
- setUpdatingAgent(true);
907
- setError(null);
908
- setUpdateSuccess(null);
909
- try {
910
- const res = await authFetch("/api/version/update", { method: "POST" });
911
- const data = await res.json();
912
- if (!data.success) {
913
- setError(data.error || "Update failed");
914
- } else {
915
- const restartedCount = data.restarted?.length || 0;
916
- const restartMsg = restartedCount > 0
917
- ? ` ${restartedCount} running agent${restartedCount > 1 ? 's' : ''} restarted.`
918
- : '';
919
- setUpdateSuccess(`Agent binary updated to v${data.version}.${restartMsg}`);
920
- await checkForUpdates();
921
- }
922
- } catch (e) {
923
- setError("Failed to update agent");
924
- }
925
- setUpdatingAgent(false);
926
- };
927
-
928
- useEffect(() => {
929
- checkForUpdates();
930
- }, []);
931
-
932
- const copyCommand = (cmd: string, id: string) => {
933
- navigator.clipboard.writeText(cmd);
934
- setCopied(id);
935
- setTimeout(() => setCopied(null), 2000);
936
- };
937
-
938
- const hasAnyUpdate = versions?.apteva.updateAvailable || versions?.agent.updateAvailable;
939
-
940
- return (
941
- <div className="max-w-4xl w-full">
942
- <div className="mb-6">
943
- <h1 className="text-2xl font-semibold mb-1">Updates</h1>
944
- <p className="text-[var(--color-text-muted)]">
945
- Check for new versions of apteva and the agent binary.
946
- </p>
947
- </div>
948
-
949
- {checking && !versions ? (
950
- <div className="text-[var(--color-text-muted)]">Checking version info...</div>
951
- ) : error && !versions ? (
952
- <div className="text-red-400">{error}</div>
953
- ) : versions?.isDocker ? (
954
- /* Docker Environment */
955
- <div className="space-y-6">
956
- <div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
957
- <div className="flex items-center gap-2 text-blue-400 mb-2">
958
- <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
959
- <path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.186.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.186.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.186.186 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.186.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186H5.136a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>
960
- </svg>
961
- <span className="font-medium">Docker Environment</span>
962
- </div>
963
- <p className="text-sm text-[var(--color-text-secondary)]">
964
- Updates are automatic when you pull a new image version.
965
- </p>
966
- </div>
967
-
968
- {/* Current Version */}
969
- <div className="bg-[var(--color-surface)] card p-5">
970
- <div className="flex items-center justify-between mb-4">
971
- <div>
972
- <h3 className="font-medium text-lg">Current Version</h3>
973
- <p className="text-sm text-[var(--color-text-muted)]">apteva + agent binary</p>
974
- </div>
975
- <div className="text-right">
976
- <div className="text-xl font-mono">v{versions.apteva.installed || "?"}</div>
977
- </div>
978
- </div>
979
-
980
- {hasAnyUpdate ? (
981
- <div className="bg-[var(--color-accent-10)] border border-[var(--color-accent-30)] rounded-lg p-4">
982
- <p className="text-sm text-[var(--color-text-secondary)] mb-3">
983
- A newer version (v{versions.apteva.latest}) is available. To update:
984
- </p>
985
- <div className="space-y-2">
986
- <code className="block bg-[var(--color-bg)] px-3 py-2 rounded font-mono text-sm text-[var(--color-text-secondary)]">
987
- docker pull apteva/apteva:latest
988
- </code>
989
- <code className="block bg-[var(--color-bg)] px-3 py-2 rounded font-mono text-sm text-[var(--color-text-secondary)]">
990
- docker compose up -d
991
- </code>
992
- </div>
993
- <button
994
- onClick={() => {
995
- navigator.clipboard.writeText("docker pull apteva/apteva:latest && docker compose up -d");
996
- setCopied("docker");
997
- setTimeout(() => setCopied(null), 2000);
998
- }}
999
- className="mt-3 px-3 py-1.5 bg-[var(--color-surface-raised)] hover:bg-[var(--color-surface-raised)] rounded text-sm"
1000
- >
1001
- {copied === "docker" ? "Copied!" : "Copy commands"}
1002
- </button>
1003
- </div>
1004
- ) : (
1005
- <div className="flex items-center gap-2 text-green-400 text-sm">
1006
- <CheckIcon className="w-4 h-4" />
1007
- Up to date
1008
- </div>
1009
- )}
1010
- </div>
1011
-
1012
- <p className="text-xs text-[var(--color-text-faint)]">
1013
- Your data is stored in a Docker volume and persists across updates.
1014
- </p>
1015
- </div>
1016
- ) : versions ? (
1017
- /* Non-Docker Environment */
1018
- <div className="space-y-6">
1019
- {updateSuccess && (
1020
- <div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4 text-green-400">
1021
- {updateSuccess}
1022
- </div>
1023
- )}
1024
-
1025
- {error && (
1026
- <div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-400">
1027
- {error}
1028
- </div>
1029
- )}
1030
-
1031
- {/* Apteva App Version */}
1032
- <div className="bg-[var(--color-surface)] card p-5">
1033
- <div className="flex items-center justify-between mb-4">
1034
- <div>
1035
- <h3 className="font-medium text-lg">apteva</h3>
1036
- <p className="text-sm text-[var(--color-text-muted)]">The app you're running</p>
1037
- </div>
1038
- <div className="text-right">
1039
- <div className="text-xl font-mono">v{versions.apteva.installed || "?"}</div>
1040
- {versions.apteva.updateAvailable && (
1041
- <div className="text-sm text-[var(--color-accent)]">→ v{versions.apteva.latest}</div>
1042
- )}
1043
- </div>
1044
- </div>
1045
-
1046
- {versions.apteva.updateAvailable ? (
1047
- <div className="bg-[var(--color-accent-10)] border border-[var(--color-accent-30)] rounded-lg p-4">
1048
- <p className="text-sm text-[var(--color-text-secondary)] mb-3">
1049
- Update by running:
1050
- </p>
1051
- <div className="flex items-center gap-2">
1052
- <code className="flex-1 bg-[var(--color-bg)] px-3 py-2 rounded font-mono text-sm text-[var(--color-text-secondary)]">
1053
- npx apteva@latest
1054
- </code>
1055
- <button
1056
- onClick={() => copyCommand("npx apteva@latest", "apteva")}
1057
- className="px-3 py-2 bg-[var(--color-surface-raised)] hover:bg-[var(--color-surface-raised)] rounded text-sm"
1058
- >
1059
- {copied === "apteva" ? "Copied!" : "Copy"}
1060
- </button>
1061
- </div>
1062
- </div>
1063
- ) : (
1064
- <div className="flex items-center gap-2 text-green-400 text-sm">
1065
- <CheckIcon className="w-4 h-4" />
1066
- Up to date
1067
- </div>
1068
- )}
1069
- </div>
1070
-
1071
- {/* Agent Binary Version */}
1072
- <div className="bg-[var(--color-surface)] card p-5">
1073
- <div className="flex items-center justify-between mb-4">
1074
- <div>
1075
- <h3 className="font-medium text-lg">Agent Binary</h3>
1076
- <p className="text-sm text-[var(--color-text-muted)]">The Go binary that runs agents</p>
1077
- </div>
1078
- <div className="text-right">
1079
- <div className="text-xl font-mono">v{versions.agent.installed || "?"}</div>
1080
- {versions.agent.updateAvailable && (
1081
- <div className="text-sm text-[var(--color-accent)]">→ v{versions.agent.latest}</div>
1082
- )}
1083
- </div>
1084
- </div>
1085
-
1086
- {versions.agent.updateAvailable ? (
1087
- <div className="bg-[var(--color-accent-10)] border border-[var(--color-accent-30)] rounded-lg p-4">
1088
- <p className="text-sm text-[var(--color-text-secondary)] mb-3">
1089
- A new version is available. Stop all agents before updating.
1090
- </p>
1091
- <div className="flex items-center gap-2">
1092
- <button
1093
- onClick={updateAgent}
1094
- disabled={updatingAgent}
1095
- className="px-4 py-2 bg-[var(--color-accent)] text-black rounded font-medium text-sm disabled:opacity-50"
1096
- >
1097
- {updatingAgent ? "Updating..." : "Update Agent"}
1098
- </button>
1099
- </div>
1100
- </div>
1101
- ) : (
1102
- <div className="flex items-center gap-2 text-green-400 text-sm">
1103
- <CheckIcon className="w-4 h-4" />
1104
- Up to date
1105
- </div>
1106
- )}
1107
- </div>
1108
-
1109
- {!hasAnyUpdate && !updateSuccess && (
1110
- <div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4 flex items-center gap-2 text-green-400">
1111
- <CheckIcon className="w-5 h-5" />
1112
- Everything is up to date!
1113
- </div>
1114
- )}
1115
-
1116
- <button
1117
- onClick={checkForUpdates}
1118
- disabled={checking}
1119
- className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] disabled:opacity-50"
1120
- >
1121
- {checking ? "Checking..." : "Check for updates"}
1122
- </button>
1123
- </div>
1124
- ) : null}
1125
- </div>
1126
- );
1127
- }
1128
-
1129
- function ProviderKeyCard({
1130
- provider,
1131
- isEditing,
1132
- apiKey,
1133
- saving,
1134
- testing,
1135
- error,
1136
- success,
1137
- onStartEdit,
1138
- onCancelEdit,
1139
- onApiKeyChange,
1140
- onSave,
1141
- onDelete,
1142
- extraField,
1143
- onExtraFieldChange,
1144
- }: ProviderKeyCardProps) {
1145
- const { authFetch: providerAuthFetch } = useAuth();
1146
- const isOllama = provider.id === "ollama";
1147
- const isCDP = provider.id === "cdp";
1148
- const isLocal = provider.isLocal || false;
1149
- const isUrlBased = isLocal || isCDP;
1150
- const isBrowser = provider.type === "browser";
1151
- const isMultiField = provider.id === "browserbase";
1152
- const voiceSubtype = (provider as any).voiceSubtype as string | undefined;
1153
- const [localStatus, setLocalStatus] = React.useState<{ connected: boolean; modelCount?: number; isDocker?: boolean } | null>(null);
1154
- const [installing, setInstalling] = React.useState(false);
1155
- const [installResult, setInstallResult] = React.useState<{ success: boolean; message: string } | null>(null);
1156
-
1157
- // Check status for local providers (Ollama + local voice providers)
1158
- const checkLocalStatus = React.useCallback(() => {
1159
- providerAuthFetch(`/api/providers/${provider.id}/status`)
1160
- .then(res => res.json())
1161
- .then(data => setLocalStatus({ connected: data.connected, modelCount: data.modelCount, isDocker: data.isDocker }))
1162
- .catch(() => setLocalStatus({ connected: false }));
1163
- }, [providerAuthFetch, provider.id]);
1164
-
1165
- React.useEffect(() => {
1166
- if (isLocal) {
1167
- checkLocalStatus();
1168
- }
1169
- }, [isLocal, provider.hasKey, checkLocalStatus]);
1170
-
1171
- const handleInstallOllama = async () => {
1172
- setInstalling(true);
1173
- setInstallResult(null);
1174
- try {
1175
- const res = await providerAuthFetch("/api/providers/ollama/install", { method: "POST" });
1176
- const data = await res.json();
1177
- if (data.success) {
1178
- setInstallResult({ success: true, message: data.message });
1179
- checkLocalStatus();
1180
- } else {
1181
- setInstallResult({ success: false, message: data.error || "Installation failed" });
1182
- }
1183
- } catch {
1184
- setInstallResult({ success: false, message: "Failed to connect to server" });
1185
- } finally {
1186
- setInstalling(false);
1187
- }
1188
- };
1189
-
1190
- return (
1191
- <div className={`bg-[var(--color-surface)] border rounded-lg p-4 ${
1192
- provider.hasKey ? 'border-green-500/20' : 'border-[var(--color-border)]'
1193
- }`}>
1194
- <div className="flex items-start justify-between gap-2 mb-2">
1195
- <div className="min-w-0">
1196
- <h3 className="font-medium">{provider.name}</h3>
1197
- <p className="text-sm text-[var(--color-text-muted)] truncate">
1198
- {provider.description
1199
- ? provider.description
1200
- : isBrowser
1201
- ? "Browser automation"
1202
- : provider.type === "integration"
1203
- ? "MCP integration"
1204
- : isLocal
1205
- ? "Run locally"
1206
- : `${provider.models.length} models`}
1207
- </p>
1208
- {voiceSubtype && (
1209
- <span className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 rounded mt-1 inline-block">
1210
- {voiceSubtype === "both" ? "STT + TTS" : voiceSubtype === "stt" ? "STT" : "TTS"}
1211
- </span>
1212
- )}
1213
- </div>
1214
- {provider.hasKey ? (
1215
- <span className={`text-xs flex items-center gap-1 px-2 py-1 rounded whitespace-nowrap flex-shrink-0 ${
1216
- isLocal && localStatus
1217
- ? localStatus.connected
1218
- ? "text-green-400 bg-green-500/10"
1219
- : "text-yellow-400 bg-yellow-500/10"
1220
- : "text-green-400 bg-green-500/10"
1221
- }`}>
1222
- {isLocal && localStatus ? (
1223
- localStatus.connected ? (
1224
- <><CheckIcon className="w-3 h-3" />{localStatus.modelCount ? `${localStatus.modelCount} models` : "Connected"}</>
1225
- ) : (
1226
- <>Not running</>
1227
- )
1228
- ) : isUrlBased ? (
1229
- <><CheckIcon className="w-3 h-3" />Configured</>
1230
- ) : (
1231
- <><CheckIcon className="w-3 h-3" />{provider.keyHint}</>
1232
- )}
1233
- </span>
1234
- ) : (
1235
- <span className="text-[var(--color-text-muted)] text-xs bg-[var(--color-surface-raised)] px-2 py-1 rounded whitespace-nowrap flex-shrink-0">
1236
- Not configured
1237
- </span>
1238
- )}
1239
- </div>
1240
-
1241
- <div className="mt-3 pt-3 border-t border-[var(--color-border)]">
1242
- {isEditing ? (
1243
- <div className="space-y-3">
1244
- {isMultiField ? (
1245
- <>
1246
- <div>
1247
- <label className="block text-xs text-[var(--color-text-secondary)] mb-1">API Key</label>
1248
- <input
1249
- type="password"
1250
- value={apiKey}
1251
- onChange={e => onApiKeyChange(e.target.value)}
1252
- placeholder={provider.hasKey ? "Enter new API key..." : "Enter API key..."}
1253
- autoFocus
1254
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
1255
- />
1256
- </div>
1257
- <div>
1258
- <label className="block text-xs text-[var(--color-text-secondary)] mb-1">Project ID</label>
1259
- <input
1260
- type="text"
1261
- value={extraField || ""}
1262
- onChange={e => onExtraFieldChange?.(e.target.value)}
1263
- placeholder="Enter your Browserbase project ID..."
1264
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
1265
- />
1266
- </div>
1267
- </>
1268
- ) : (
1269
- <input
1270
- type={isUrlBased ? "text" : "password"}
1271
- value={apiKey}
1272
- onChange={e => onApiKeyChange(e.target.value)}
1273
- placeholder={isLocal
1274
- ? (provider.defaultBaseUrl || "http://localhost:8080")
1275
- : isCDP ? "ws://localhost:9222"
1276
- : provider.hasKey ? "Enter new API key..." : "Enter API key..."}
1277
- autoFocus
1278
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
1279
- />
1280
- )}
1281
- {isUrlBased && (
1282
- <p className="text-xs text-[var(--color-text-muted)]">
1283
- {isCDP
1284
- ? "Enter the CDP URL of your browser (e.g., ws://localhost:9222)"
1285
- : `Enter the server URL. Default is ${provider.defaultBaseUrl || "http://localhost:8080"}`}
1286
- </p>
1287
- )}
1288
- {error && <p className="text-red-400 text-sm">{error}</p>}
1289
- {success && <p className="text-green-400 text-sm">{success}</p>}
1290
- <div className="flex gap-2">
1291
- <button
1292
- onClick={onCancelEdit}
1293
- className="flex-1 px-3 py-1.5 border border-[var(--color-border-light)] rounded text-sm hover:border-[var(--color-text-muted)]"
1294
- >
1295
- Cancel
1296
- </button>
1297
- <button
1298
- onClick={onSave}
1299
- disabled={!apiKey || saving}
1300
- className="flex-1 px-3 py-1.5 bg-[var(--color-accent)] text-black rounded text-sm font-medium disabled:opacity-50"
1301
- >
1302
- {testing ? "Validating..." : saving ? "Saving..." : isUrlBased ? "Connect" : "Save"}
1303
- </button>
1304
- </div>
1305
- </div>
1306
- ) : provider.hasKey ? (
1307
- <div>
1308
- {isLocal && localStatus && !localStatus.connected && (
1309
- <div className="mb-3">
1310
- {isOllama && !localStatus.isDocker ? (
1311
- <button
1312
- onClick={handleInstallOllama}
1313
- disabled={installing}
1314
- className="w-full px-3 py-1.5 bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 rounded text-sm font-medium transition disabled:opacity-50 disabled:cursor-wait"
1315
- >
1316
- {installing ? "Starting Ollama..." : "Start Ollama"}
1317
- </button>
1318
- ) : (
1319
- <p className="text-xs text-yellow-400/80">
1320
- Service not reachable. Make sure it&apos;s running at the configured URL.
1321
- </p>
1322
- )}
1323
- {installResult && (
1324
- <p className={`text-xs mt-1.5 ${installResult.success ? "text-green-400" : "text-red-400"}`}>
1325
- {installResult.message}
1326
- </p>
1327
- )}
1328
- </div>
1329
- )}
1330
- <div className="flex items-center justify-between">
1331
- {provider.docsUrl ? (
1332
- <a
1333
- href={provider.docsUrl}
1334
- target="_blank"
1335
- rel="noopener noreferrer"
1336
- className="text-sm text-[#3b82f6] hover:underline"
1337
- >
1338
- {isLocal ? "Setup guide" : "View docs"}
1339
- </a>
1340
- ) : (
1341
- <span />
1342
- )}
1343
- <div className="flex items-center gap-3">
1344
- <button
1345
- onClick={onStartEdit}
1346
- className="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text)]"
1347
- >
1348
- {isUrlBased ? "Change URL" : "Update key"}
1349
- </button>
1350
- <button
1351
- onClick={onDelete}
1352
- className="text-red-400 hover:text-red-300 text-sm"
1353
- >
1354
- Remove
1355
- </button>
1356
- </div>
1357
- </div>
1358
- </div>
1359
- ) : (
1360
- <div>
1361
- {isOllama && !localStatus?.isDocker && (
1362
- <div className="mb-3">
1363
- <button
1364
- onClick={handleInstallOllama}
1365
- disabled={installing}
1366
- className="w-full px-3 py-2 bg-[#3b82f6]/20 text-[#3b82f6] hover:bg-[#3b82f6]/30 rounded text-sm font-medium transition disabled:opacity-50 disabled:cursor-wait"
1367
- >
1368
- {installing ? "Installing Ollama..." : localStatus?.connected ? "Ollama Running" : "Install Ollama"}
1369
- </button>
1370
- {installResult && (
1371
- <p className={`text-xs mt-1.5 ${installResult.success ? "text-green-400" : "text-red-400"}`}>
1372
- {installResult.message}
1373
- </p>
1374
- )}
1375
- </div>
1376
- )}
1377
- <div className="flex items-center justify-between">
1378
- {provider.docsUrl ? (
1379
- <a
1380
- href={provider.docsUrl}
1381
- target="_blank"
1382
- rel="noopener noreferrer"
1383
- className="text-sm text-[#3b82f6] hover:underline"
1384
- >
1385
- {isLocal ? "Setup guide" : isBrowser ? "View docs" : "Get API key"}
1386
- </a>
1387
- ) : (
1388
- <span />
1389
- )}
1390
- <button
1391
- onClick={onStartEdit}
1392
- className="text-sm text-[var(--color-accent)] hover:text-[var(--color-accent-hover)]"
1393
- >
1394
- {isUrlBased ? "Configure" : "+ Add key"}
1395
- </button>
1396
- </div>
1397
- </div>
1398
- )}
1399
- </div>
1400
- </div>
1401
- );
1402
- }
1403
-
1404
- interface IntegrationKey {
1405
- id: string;
1406
- provider_id: string;
1407
- key_hint: string;
1408
- is_valid: boolean;
1409
- project_id: string | null;
1410
- name: string | null;
1411
- created_at: string;
1412
- }
1413
-
1414
- interface IntegrationKeyCardProps extends ProviderKeyCardProps {
1415
- projectsEnabled: boolean;
1416
- projects: Array<{ id: string; name: string; color: string }>;
1417
- onRefresh: () => void;
1418
- }
1419
-
1420
- function IntegrationKeyCard({
1421
- provider,
1422
- isEditing,
1423
- apiKey,
1424
- saving,
1425
- testing,
1426
- error,
1427
- success,
1428
- onStartEdit,
1429
- onCancelEdit,
1430
- onApiKeyChange,
1431
- onSave,
1432
- onDelete,
1433
- projectsEnabled,
1434
- projects,
1435
- onRefresh,
1436
- }: IntegrationKeyCardProps) {
1437
- const { authFetch } = useAuth();
1438
- const [keys, setKeys] = useState<IntegrationKey[]>([]);
1439
- const [selectedProjectId, setSelectedProjectId] = useState<string>("");
1440
- const [expanded, setExpanded] = useState(false);
1441
- const [localError, setLocalError] = useState<string | null>(null);
1442
- const [localSaving, setLocalSaving] = useState(false);
1443
- const [bbProjectId, setBbProjectId] = useState(""); // Browserbase project ID (their internal ID)
1444
- const { confirm, ConfirmDialog } = useConfirm();
1445
-
1446
- const isBrowserbase = provider.id === "browserbase";
1447
-
1448
- // Fetch all keys for this provider
1449
- const fetchKeys = async () => {
1450
- try {
1451
- const res = await authFetch(`/api/keys/${provider.id}`);
1452
- const data = await res.json();
1453
- setKeys(data.keys || []);
1454
- } catch (e) {
1455
- console.error("Failed to fetch keys:", e);
1456
- }
1457
- };
1458
-
1459
- useEffect(() => {
1460
- if (projectsEnabled) {
1461
- fetchKeys();
1462
- }
1463
- }, [provider.id, projectsEnabled]);
1464
-
1465
- // Clear local error when starting to edit
1466
- useEffect(() => {
1467
- if (isEditing) {
1468
- setLocalError(null);
1469
- }
1470
- }, [isEditing]);
1471
-
1472
- const handleSaveWithProject = async () => {
1473
- if (!apiKey) return;
1474
-
1475
- setLocalSaving(true);
1476
- setLocalError(null);
1477
-
1478
- // For Browserbase, combine API key + BB project ID into JSON
1479
- let keyToSave = apiKey;
1480
- if (isBrowserbase && bbProjectId) {
1481
- keyToSave = JSON.stringify({ api_key: apiKey, project_id: bbProjectId });
1482
- }
1483
-
1484
- try {
1485
- const res = await authFetch(`/api/keys/${provider.id}`, {
1486
- method: "POST",
1487
- headers: { "Content-Type": "application/json" },
1488
- body: JSON.stringify({
1489
- key: keyToSave,
1490
- project_id: selectedProjectId || null,
1491
- }),
1492
- });
1493
-
1494
- const data = await res.json();
1495
-
1496
- if (res.ok) {
1497
- onApiKeyChange("");
1498
- setBbProjectId("");
1499
- setSelectedProjectId("");
1500
- onCancelEdit();
1501
- fetchKeys();
1502
- onRefresh();
1503
- } else {
1504
- setLocalError(data.error || "Failed to save key");
1505
- }
1506
- } catch (e) {
1507
- console.error("Failed to save key:", e);
1508
- setLocalError("Failed to save key");
1509
- }
1510
- setLocalSaving(false);
1511
- };
1512
-
1513
- const handleDeleteKey = async (keyId: string, keyName: string | null) => {
1514
- const confirmed = await confirm(
1515
- `Are you sure you want to remove this API key${keyName ? ` (${keyName})` : ""}?`,
1516
- { confirmText: "Remove", title: "Remove API Key" }
1517
- );
1518
- if (!confirmed) return;
1519
-
1520
- try {
1521
- await authFetch(`/api/keys/by-id/${keyId}`, { method: "DELETE" });
1522
- fetchKeys();
1523
- onRefresh();
1524
- } catch (e) {
1525
- console.error("Failed to delete key:", e);
1526
- }
1527
- };
1528
-
1529
- const globalKey = keys.find(k => !k.project_id);
1530
- const projectKeys = keys.filter(k => k.project_id);
1531
- const getProjectName = (projectId: string) => projects.find(p => p.id === projectId)?.name || "Unknown";
1532
- const getProjectColor = (projectId: string) => projects.find(p => p.id === projectId)?.color || "#666";
1533
-
1534
- // Simple view when projects not enabled
1535
- if (!projectsEnabled) {
1536
- return (
1537
- <div className={`bg-[var(--color-surface)] border rounded-lg p-4 ${
1538
- provider.hasKey ? 'border-[var(--color-accent-20)]' : 'border-[var(--color-border)]'
1539
- }`}>
1540
- <div className="flex items-center justify-between mb-2">
1541
- <div>
1542
- <h3 className="font-medium">{provider.name}</h3>
1543
- <p className="text-sm text-[var(--color-text-muted)]">{provider.description || "MCP integration"}</p>
1544
- </div>
1545
- {provider.hasKey ? (
1546
- <span className="text-[var(--color-accent)] text-xs flex items-center gap-1 bg-[var(--color-accent-10)] px-2 py-1 rounded">
1547
- <CheckIcon className="w-3 h-3" />
1548
- {provider.keyHint}
1549
- </span>
1550
- ) : (
1551
- <span className="text-[var(--color-text-muted)] text-xs bg-[var(--color-surface-raised)] px-2 py-1 rounded">
1552
- Not configured
1553
- </span>
1554
- )}
1555
- </div>
1556
-
1557
- <div className="mt-3 pt-3 border-t border-[var(--color-border)]">
1558
- {isEditing ? (
1559
- <div className="space-y-3">
1560
- <input
1561
- type={inputType}
1562
- value={apiKey}
1563
- onChange={e => onApiKeyChange(e.target.value)}
1564
- placeholder={provider.hasKey ? `Enter new ${isUrlBased ? "URL" : "API key"}...` : inputPlaceholder}
1565
- autoFocus
1566
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
1567
- />
1568
- {error && <p className="text-red-400 text-sm">{error}</p>}
1569
- {success && <p className="text-green-400 text-sm">{success}</p>}
1570
- <div className="flex gap-2">
1571
- <button
1572
- onClick={onCancelEdit}
1573
- className="flex-1 px-3 py-1.5 border border-[var(--color-border-light)] rounded text-sm hover:border-[var(--color-text-muted)]"
1574
- >
1575
- Cancel
1576
- </button>
1577
- <button
1578
- onClick={onSave}
1579
- disabled={!apiKey || saving}
1580
- className="flex-1 px-3 py-1.5 bg-[var(--color-accent)] text-black rounded text-sm font-medium disabled:opacity-50"
1581
- >
1582
- {testing ? "Validating..." : saving ? "Saving..." : "Save"}
1583
- </button>
1584
- </div>
1585
- </div>
1586
- ) : provider.hasKey ? (
1587
- <div className="flex items-center justify-between">
1588
- <a
1589
- href={provider.docsUrl}
1590
- target="_blank"
1591
- rel="noopener noreferrer"
1592
- className="text-sm text-[#3b82f6] hover:underline"
1593
- >
1594
- View docs
1595
- </a>
1596
- <div className="flex items-center gap-3">
1597
- <button
1598
- onClick={onStartEdit}
1599
- className="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text)]"
1600
- >
1601
- Update key
1602
- </button>
1603
- <button
1604
- onClick={onDelete}
1605
- className="text-red-400 hover:text-red-300 text-sm"
1606
- >
1607
- Remove
1608
- </button>
1609
- </div>
1610
- </div>
1611
- ) : (
1612
- <div className="flex items-center justify-between">
1613
- <a
1614
- href={provider.docsUrl}
1615
- target="_blank"
1616
- rel="noopener noreferrer"
1617
- className="text-sm text-[#3b82f6] hover:underline"
1618
- >
1619
- Get API key
1620
- </a>
1621
- <button
1622
- onClick={onStartEdit}
1623
- className="text-sm text-[var(--color-accent)] hover:text-[var(--color-accent-hover)]"
1624
- >
1625
- + Add key
1626
- </button>
1627
- </div>
1628
- )}
1629
- </div>
1630
- </div>
1631
- );
1632
- }
1633
-
1634
- // Determine input type and placeholder based on provider
1635
- const isUrlBased = provider.isLocal;
1636
- const inputType = isUrlBased ? "text" : "password";
1637
- const inputPlaceholder = isUrlBased
1638
- ? (provider.defaultBaseUrl || "http://localhost:8080")
1639
- : "Enter API key...";
1640
-
1641
- // Enhanced view with project support
1642
- return (
1643
- <>
1644
- {ConfirmDialog}
1645
- <div className={`bg-[var(--color-surface)] border rounded-lg p-4 ${
1646
- keys.length > 0 ? 'border-[var(--color-accent-20)]' : 'border-[var(--color-border)]'
1647
- }`}>
1648
- <div className="flex items-center justify-between mb-2">
1649
- <div>
1650
- <h3 className="font-medium">{provider.name}</h3>
1651
- <p className="text-sm text-[var(--color-text-muted)]">{provider.description || "MCP integration"}</p>
1652
- </div>
1653
- {keys.length > 0 ? (
1654
- <span className="text-[var(--color-accent)] text-xs flex items-center gap-1 bg-[var(--color-accent-10)] px-2 py-1 rounded">
1655
- <CheckIcon className="w-3 h-3" />
1656
- {keys.length} key{keys.length !== 1 ? "s" : ""}
1657
- </span>
1658
- ) : (
1659
- <span className="text-[var(--color-text-muted)] text-xs bg-[var(--color-surface-raised)] px-2 py-1 rounded">
1660
- Not configured
1661
- </span>
1662
- )}
1663
- </div>
1664
-
1665
- {/* Keys List */}
1666
- {keys.length > 0 && (
1667
- <div className="mt-3 space-y-2">
1668
- {/* Global Key */}
1669
- {globalKey && (
1670
- <div className="flex items-center justify-between text-sm bg-[var(--color-bg)] rounded px-3 py-2">
1671
- <div className="flex items-center gap-2">
1672
- <span className="text-[var(--color-text-secondary)]">Global</span>
1673
- <span className="text-[var(--color-text-faint)]">·</span>
1674
- <span className="text-[var(--color-text-muted)] font-mono text-xs">{globalKey.key_hint}</span>
1675
- </div>
1676
- <button
1677
- onClick={() => handleDeleteKey(globalKey.id, "Global")}
1678
- className="text-red-400 hover:text-red-300 text-xs"
1679
- >
1680
- Remove
1681
- </button>
1682
- </div>
1683
- )}
1684
-
1685
- {/* Project Keys - show first 2, expand for more */}
1686
- {projectKeys.slice(0, expanded ? undefined : 2).map(key => (
1687
- <div key={key.id} className="flex items-center justify-between text-sm bg-[var(--color-bg)] rounded px-3 py-2">
1688
- <div className="flex items-center gap-2 min-w-0">
1689
- <span
1690
- className="w-2 h-2 rounded-full flex-shrink-0"
1691
- style={{ backgroundColor: getProjectColor(key.project_id!) }}
1692
- />
1693
- <span className="text-[var(--color-text-secondary)] truncate">{key.name || getProjectName(key.project_id!)}</span>
1694
- <span className="text-[var(--color-text-faint)]">·</span>
1695
- <span className="text-[var(--color-text-muted)] font-mono text-xs">{key.key_hint}</span>
1696
- </div>
1697
- <button
1698
- onClick={() => handleDeleteKey(key.id, key.name || getProjectName(key.project_id!))}
1699
- className="text-red-400 hover:text-red-300 text-xs flex-shrink-0 ml-2"
1700
- >
1701
- Remove
1702
- </button>
1703
- </div>
1704
- ))}
1705
-
1706
- {projectKeys.length > 2 && !expanded && (
1707
- <button
1708
- onClick={() => setExpanded(true)}
1709
- className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] w-full text-center py-1"
1710
- >
1711
- Show {projectKeys.length - 2} more...
1712
- </button>
1713
- )}
1714
- </div>
1715
- )}
1716
-
1717
- <div className="mt-3 pt-3 border-t border-[var(--color-border)]">
1718
- {isEditing ? (
1719
- <div className="space-y-3">
1720
- <input
1721
- type={inputType}
1722
- value={apiKey}
1723
- onChange={e => onApiKeyChange(e.target.value)}
1724
- placeholder={inputPlaceholder}
1725
- autoFocus
1726
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
1727
- />
1728
-
1729
- {isBrowserbase && (
1730
- <input
1731
- type="text"
1732
- value={bbProjectId}
1733
- onChange={e => setBbProjectId(e.target.value)}
1734
- placeholder="Browserbase Project ID (optional)"
1735
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)] text-sm"
1736
- />
1737
- )}
1738
-
1739
- <Select
1740
- value={selectedProjectId}
1741
- onChange={setSelectedProjectId}
1742
- placeholder="Global (all projects)"
1743
- options={[
1744
- { value: "", label: "Global (all projects)" },
1745
- ...projects.map(p => ({ value: p.id, label: p.name }))
1746
- ]}
1747
- />
1748
-
1749
- {localError && <p className="text-red-400 text-sm">{localError}</p>}
1750
-
1751
- <div className="flex gap-2">
1752
- <button
1753
- onClick={() => {
1754
- onCancelEdit();
1755
- setSelectedProjectId("");
1756
- setLocalError(null);
1757
- }}
1758
- className="flex-1 px-3 py-1.5 border border-[var(--color-border-light)] rounded text-sm hover:border-[var(--color-text-muted)]"
1759
- >
1760
- Cancel
1761
- </button>
1762
- <button
1763
- onClick={handleSaveWithProject}
1764
- disabled={!apiKey || localSaving}
1765
- className="flex-1 px-3 py-1.5 bg-[var(--color-accent)] text-black rounded text-sm font-medium disabled:opacity-50"
1766
- >
1767
- {localSaving ? "Saving..." : "Save"}
1768
- </button>
1769
- </div>
1770
- </div>
1771
- ) : (
1772
- <div className="flex items-center justify-between">
1773
- <a
1774
- href={provider.docsUrl}
1775
- target="_blank"
1776
- rel="noopener noreferrer"
1777
- className="text-sm text-[#3b82f6] hover:underline"
1778
- >
1779
- {keys.length > 0 ? "View docs" : "Get API key"}
1780
- </a>
1781
- <button
1782
- onClick={onStartEdit}
1783
- className="text-sm text-[var(--color-accent)] hover:text-[var(--color-accent-hover)]"
1784
- >
1785
- + Add key
1786
- </button>
1787
- </div>
1788
- )}
1789
- </div>
1790
- </div>
1791
- </>
1792
- );
1793
- }
1794
-
1795
- interface ApiKeyItem {
1796
- id: string;
1797
- name: string;
1798
- prefix: string;
1799
- is_active: boolean;
1800
- expires_at: string | null;
1801
- last_used_at: string | null;
1802
- created_at: string;
1803
- }
1804
-
1805
- function ApiKeysSettings() {
1806
- const { authFetch } = useAuth();
1807
- const [keys, setKeys] = useState<ApiKeyItem[]>([]);
1808
- const [showCreate, setShowCreate] = useState(false);
1809
- const [name, setName] = useState("");
1810
- const [expiresInDays, setExpiresInDays] = useState<string>("90");
1811
- const [creating, setCreating] = useState(false);
1812
- const [error, setError] = useState<string | null>(null);
1813
- const [newKey, setNewKey] = useState<string | null>(null);
1814
- const [copied, setCopied] = useState(false);
1815
- const { confirm, ConfirmDialog } = useConfirm();
1816
-
1817
- const fetchKeys = async () => {
1818
- try {
1819
- const res = await authFetch("/api/keys/personal");
1820
- const data = await res.json();
1821
- setKeys(data.keys || []);
1822
- } catch {
1823
- // ignore
1824
- }
1825
- };
1826
-
1827
- useEffect(() => {
1828
- fetchKeys();
1829
- }, []);
1830
-
1831
- const handleCreate = async () => {
1832
- if (!name.trim()) {
1833
- setError("Name is required");
1834
- return;
1835
- }
1836
- setCreating(true);
1837
- setError(null);
1838
-
1839
- try {
1840
- const res = await authFetch("/api/keys/personal", {
1841
- method: "POST",
1842
- headers: { "Content-Type": "application/json" },
1843
- body: JSON.stringify({
1844
- name: name.trim(),
1845
- expires_in_days: expiresInDays ? parseInt(expiresInDays) : null,
1846
- }),
1847
- });
1848
-
1849
- const data = await res.json();
1850
- if (!res.ok) {
1851
- setError(data.error || "Failed to create key");
1852
- } else {
1853
- setNewKey(data.key);
1854
- setName("");
1855
- setExpiresInDays("90");
1856
- fetchKeys();
1857
- }
1858
- } catch {
1859
- setError("Failed to create key");
1860
- }
1861
- setCreating(false);
1862
- };
1863
-
1864
- const handleDelete = async (id: string, keyName: string) => {
1865
- const confirmed = await confirm(`Delete API key "${keyName}"? This cannot be undone.`, { confirmText: "Delete", title: "Delete API Key" });
1866
- if (!confirmed) return;
1867
-
1868
- try {
1869
- await authFetch(`/api/keys/personal/${id}`, { method: "DELETE" });
1870
- fetchKeys();
1871
- } catch {
1872
- // ignore
1873
- }
1874
- };
1875
-
1876
- const copyKey = () => {
1877
- if (newKey) {
1878
- navigator.clipboard.writeText(newKey);
1879
- setCopied(true);
1880
- setTimeout(() => setCopied(false), 2000);
1881
- }
1882
- };
1883
-
1884
- const formatDate = (dateStr: string | null) => {
1885
- if (!dateStr) return "Never";
1886
- const d = new Date(dateStr);
1887
- return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
1888
- };
1889
-
1890
- const isExpired = (expiresAt: string | null) => {
1891
- if (!expiresAt) return false;
1892
- return new Date(expiresAt) < new Date();
1893
- };
1894
-
1895
- return (
1896
- <>
1897
- {ConfirmDialog}
1898
- <div className="max-w-4xl w-full">
1899
- <div className="mb-6 flex items-center justify-between gap-4">
1900
- <div>
1901
- <h1 className="text-2xl font-semibold mb-1">API Keys</h1>
1902
- <p className="text-[var(--color-text-muted)]">
1903
- Create personal API keys for programmatic access. Use them with the <code className="text-[var(--color-text-secondary)] bg-[var(--color-surface-raised)] px-1 rounded text-xs">X-API-Key</code> header.
1904
- </p>
1905
- </div>
1906
- {!showCreate && !newKey && (
1907
- <button
1908
- onClick={() => { setShowCreate(true); setError(null); }}
1909
- className="flex items-center gap-2 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] text-black px-4 py-2 rounded font-medium transition flex-shrink-0"
1910
- >
1911
- <PlusIcon className="w-4 h-4" />
1912
- New Key
1913
- </button>
1914
- )}
1915
- </div>
1916
-
1917
- {/* Newly created key - show once */}
1918
- {newKey && (
1919
- <div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4 mb-6">
1920
- <div className="flex items-center gap-2 text-green-400 mb-2">
1921
- <CheckIcon className="w-5 h-5" />
1922
- <span className="font-medium">API key created</span>
1923
- </div>
1924
- <p className="text-sm text-[var(--color-text-secondary)] mb-3">
1925
- Copy this key now. You won't be able to see it again.
1926
- </p>
1927
- <div className="flex items-center gap-2">
1928
- <code className="flex-1 bg-[var(--color-bg)] px-3 py-2 rounded font-mono text-sm text-[var(--color-text)] break-all select-all">
1929
- {newKey}
1930
- </code>
1931
- <button
1932
- onClick={copyKey}
1933
- className="px-3 py-2 bg-[var(--color-surface-raised)] hover:bg-[var(--color-surface-raised)] rounded text-sm flex-shrink-0"
1934
- >
1935
- {copied ? "Copied!" : "Copy"}
1936
- </button>
1937
- </div>
1938
- <button
1939
- onClick={() => { setNewKey(null); setShowCreate(false); }}
1940
- className="mt-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
1941
- >
1942
- Done
1943
- </button>
1944
- </div>
1945
- )}
1946
-
1947
- {/* Create Form */}
1948
- {showCreate && !newKey && (
1949
- <div className="bg-[var(--color-surface)] card p-4 mb-6">
1950
- <h3 className="font-medium mb-4">Create new API key</h3>
1951
- <div className="space-y-4 max-w-md">
1952
- <div>
1953
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Name</label>
1954
- <input
1955
- type="text"
1956
- value={name}
1957
- onChange={e => setName(e.target.value)}
1958
- placeholder="e.g. CI Pipeline, My Script"
1959
- autoFocus
1960
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
1961
- />
1962
- </div>
1963
- <div>
1964
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Expiration</label>
1965
- <select
1966
- value={expiresInDays}
1967
- onChange={e => setExpiresInDays(e.target.value)}
1968
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
1969
- >
1970
- <option value="30">30 days</option>
1971
- <option value="90">90 days</option>
1972
- <option value="180">180 days</option>
1973
- <option value="365">1 year</option>
1974
- <option value="">No expiration</option>
1975
- </select>
1976
- </div>
1977
-
1978
- {error && <p className="text-red-400 text-sm">{error}</p>}
1979
-
1980
- <div className="flex gap-2">
1981
- <button
1982
- onClick={() => { setShowCreate(false); setError(null); setName(""); }}
1983
- className="flex-1 px-3 py-2 border border-[var(--color-border-light)] rounded text-sm hover:border-[var(--color-text-muted)]"
1984
- >
1985
- Cancel
1986
- </button>
1987
- <button
1988
- onClick={handleCreate}
1989
- disabled={creating || !name.trim()}
1990
- className="flex-1 px-3 py-2 bg-[var(--color-accent)] text-black rounded text-sm font-medium disabled:opacity-50"
1991
- >
1992
- {creating ? "Creating..." : "Create Key"}
1993
- </button>
1994
- </div>
1995
- </div>
1996
- </div>
1997
- )}
1998
-
1999
- {/* Keys List */}
2000
- {keys.length === 0 ? (
2001
- <div className="text-center py-12 text-[var(--color-text-muted)]">
2002
- <p className="text-lg mb-2">No API keys yet</p>
2003
- <p className="text-sm">Create an API key to access apteva programmatically.</p>
2004
- </div>
2005
- ) : (
2006
- <div className="space-y-3">
2007
- {keys.map(key => (
2008
- <div
2009
- key={key.id}
2010
- className={`bg-[var(--color-surface)] border rounded-lg p-4 flex items-center gap-4 ${
2011
- !key.is_active || isExpired(key.expires_at) ? "border-[var(--color-border)] opacity-60" : "border-[var(--color-border)]"
2012
- }`}
2013
- >
2014
- <div className="flex-1 min-w-0">
2015
- <div className="flex items-center gap-2 mb-1">
2016
- <h3 className="font-medium">{key.name}</h3>
2017
- {!key.is_active && (
2018
- <span className="text-xs text-red-400 bg-red-500/10 px-2 py-0.5 rounded">Revoked</span>
2019
- )}
2020
- {key.is_active && isExpired(key.expires_at) && (
2021
- <span className="text-xs text-yellow-400 bg-yellow-500/10 px-2 py-0.5 rounded">Expired</span>
2022
- )}
2023
- </div>
2024
- <div className="flex items-center gap-3 text-sm text-[var(--color-text-muted)]">
2025
- <code className="font-mono text-xs bg-[var(--color-bg)] px-2 py-0.5 rounded">{key.prefix}...</code>
2026
- <span>Created {formatDate(key.created_at)}</span>
2027
- {key.expires_at && <span>Expires {formatDate(key.expires_at)}</span>}
2028
- {key.last_used_at && <span>Last used {formatDate(key.last_used_at)}</span>}
2029
- </div>
2030
- </div>
2031
- {key.is_active && (
2032
- <button
2033
- onClick={() => handleDelete(key.id, key.name)}
2034
- className="text-sm text-red-400 hover:text-red-300 px-2 py-1 flex-shrink-0"
2035
- >
2036
- Delete
2037
- </button>
2038
- )}
2039
- </div>
2040
- ))}
2041
- </div>
2042
- )}
2043
-
2044
- {/* Usage Info */}
2045
- {keys.length > 0 && (
2046
- <div className="mt-6 bg-[var(--color-surface)] card p-4">
2047
- <h3 className="font-medium mb-2 text-sm">Usage</h3>
2048
- <code className="block bg-[var(--color-bg)] px-3 py-2 rounded font-mono text-xs text-[var(--color-text-secondary)]">
2049
- curl -H "X-API-Key: apt_..." http://localhost:4280/api/agents
2050
- </code>
2051
- </div>
2052
- )}
2053
- </div>
2054
- </>
2055
- );
2056
- }
2057
-
2058
- function AccountSettings() {
2059
- const { authFetch, user } = useAuth();
2060
- const [currentPassword, setCurrentPassword] = useState("");
2061
- const [newPassword, setNewPassword] = useState("");
2062
- const [confirmPassword, setConfirmPassword] = useState("");
2063
- const [saving, setSaving] = useState(false);
2064
- const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
2065
-
2066
- const handleChangePassword = async () => {
2067
- // Validation
2068
- if (!currentPassword || !newPassword || !confirmPassword) {
2069
- setMessage({ type: "error", text: "All fields are required" });
2070
- return;
2071
- }
2072
-
2073
- if (newPassword !== confirmPassword) {
2074
- setMessage({ type: "error", text: "New passwords do not match" });
2075
- return;
2076
- }
2077
-
2078
- if (newPassword.length < 8) {
2079
- setMessage({ type: "error", text: "Password must be at least 8 characters" });
2080
- return;
2081
- }
2082
-
2083
- setSaving(true);
2084
- setMessage(null);
2085
-
2086
- try {
2087
- const res = await authFetch("/api/auth/password", {
2088
- method: "PUT",
2089
- headers: { "Content-Type": "application/json" },
2090
- body: JSON.stringify({ currentPassword, newPassword }),
2091
- });
2092
-
2093
- const data = await res.json();
2094
-
2095
- if (res.ok) {
2096
- setMessage({ type: "success", text: "Password updated successfully" });
2097
- setCurrentPassword("");
2098
- setNewPassword("");
2099
- setConfirmPassword("");
2100
- } else {
2101
- setMessage({ type: "error", text: data.error || "Failed to update password" });
2102
- }
2103
- } catch {
2104
- setMessage({ type: "error", text: "Failed to update password" });
2105
- }
2106
-
2107
- setSaving(false);
2108
- };
2109
-
2110
- return (
2111
- <div className="max-w-4xl w-full">
2112
- <div className="mb-6">
2113
- <h1 className="text-2xl font-semibold mb-1">Account Settings</h1>
2114
- <p className="text-[var(--color-text-muted)]">Manage your account and security.</p>
2115
- </div>
2116
-
2117
- {/* User Info */}
2118
- {user && (
2119
- <div className="bg-[var(--color-surface)] card p-4 mb-6">
2120
- <h3 className="font-medium mb-3">Profile</h3>
2121
- <div className="space-y-2 text-sm">
2122
- <div className="flex justify-between">
2123
- <span className="text-[var(--color-text-muted)]">Username</span>
2124
- <span>{user.username}</span>
2125
- </div>
2126
- {user.email && (
2127
- <div className="flex justify-between">
2128
- <span className="text-[var(--color-text-muted)]">Email</span>
2129
- <span>{user.email}</span>
2130
- </div>
2131
- )}
2132
- <div className="flex justify-between">
2133
- <span className="text-[var(--color-text-muted)]">Role</span>
2134
- <span className="capitalize">{user.role}</span>
2135
- </div>
2136
- </div>
2137
- </div>
2138
- )}
2139
-
2140
- {/* Change Password */}
2141
- <div className="bg-[var(--color-surface)] card p-4">
2142
- <h3 className="font-medium mb-4">Change Password</h3>
2143
-
2144
- <div className="space-y-4 max-w-md">
2145
- <div>
2146
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Current Password</label>
2147
- <input
2148
- type="password"
2149
- value={currentPassword}
2150
- onChange={(e) => setCurrentPassword(e.target.value)}
2151
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
2152
- />
2153
- </div>
2154
-
2155
- <div>
2156
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">New Password</label>
2157
- <input
2158
- type="password"
2159
- value={newPassword}
2160
- onChange={(e) => setNewPassword(e.target.value)}
2161
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
2162
- />
2163
- </div>
2164
-
2165
- <div>
2166
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Confirm New Password</label>
2167
- <input
2168
- type="password"
2169
- value={confirmPassword}
2170
- onChange={(e) => setConfirmPassword(e.target.value)}
2171
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 focus:outline-none focus:border-[var(--color-accent)]"
2172
- />
2173
- </div>
2174
-
2175
- {message && (
2176
- <div className={`p-3 rounded text-sm ${
2177
- message.type === "success"
2178
- ? "bg-green-500/10 text-green-400 border border-green-500/30"
2179
- : "bg-red-500/10 text-red-400 border border-red-500/30"
2180
- }`}>
2181
- {message.text}
2182
- </div>
2183
- )}
2184
-
2185
- <button
2186
- onClick={handleChangePassword}
2187
- disabled={saving || !currentPassword || !newPassword || !confirmPassword}
2188
- className="px-4 py-2 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed text-black rounded text-sm font-medium transition"
2189
- >
2190
- {saving ? "Updating..." : "Update Password"}
2191
- </button>
2192
- </div>
2193
- </div>
2194
- </div>
2195
- );
2196
- }
2197
-
2198
- function DataSettings() {
2199
- const { authFetch } = useAuth();
2200
- const [clearing, setClearing] = useState(false);
2201
- const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
2202
- const [eventCount, setEventCount] = useState<number | null>(null);
2203
- const { confirm, ConfirmDialog } = useConfirm();
2204
-
2205
- const fetchStats = async () => {
2206
- try {
2207
- const res = await authFetch("/api/telemetry/stats");
2208
- const data = await res.json();
2209
- setEventCount(data.stats?.total_events || 0);
2210
- } catch {
2211
- setEventCount(null);
2212
- }
2213
- };
2214
-
2215
- useEffect(() => {
2216
- fetchStats();
2217
- }, []);
2218
-
2219
- const clearTelemetry = async () => {
2220
- const confirmed = await confirm("Are you sure you want to delete all analytics data? This cannot be undone.", { confirmText: "Clear All", title: "Clear Analytics Data" });
2221
- if (!confirmed) return;
2222
-
2223
- setClearing(true);
2224
- setMessage(null);
2225
-
2226
- try {
2227
- const res = await authFetch("/api/telemetry/clear", { method: "POST" });
2228
- const data = await res.json();
2229
-
2230
- if (res.ok) {
2231
- setMessage({ type: "success", text: `Cleared ${data.deleted || 0} telemetry events.` });
2232
- setEventCount(0);
2233
- } else {
2234
- setMessage({ type: "error", text: data.error || "Failed to clear telemetry" });
2235
- }
2236
- } catch {
2237
- setMessage({ type: "error", text: "Failed to clear telemetry" });
2238
- }
2239
-
2240
- setClearing(false);
2241
- };
2242
-
2243
- return (
2244
- <>
2245
- {ConfirmDialog}
2246
- <div className="max-w-4xl w-full">
2247
- <div className="mb-6">
2248
- <h1 className="text-2xl font-semibold mb-1">Data Management</h1>
2249
- <p className="text-[var(--color-text-muted)]">Manage stored data and analytics.</p>
2250
- </div>
2251
-
2252
- <div className="bg-[var(--color-surface)] card p-4">
2253
- <h3 className="font-medium mb-2">Analytics Data</h3>
2254
- <p className="text-sm text-[var(--color-text-muted)] mb-4">
2255
- {eventCount !== null
2256
- ? `${eventCount.toLocaleString()} events stored`
2257
- : "Loading..."}
2258
- </p>
2259
-
2260
- {message && (
2261
- <div className={`mb-4 p-3 rounded text-sm ${
2262
- message.type === "success"
2263
- ? "bg-green-500/10 text-green-400 border border-green-500/30"
2264
- : "bg-red-500/10 text-red-400 border border-red-500/30"
2265
- }`}>
2266
- {message.text}
2267
- </div>
2268
- )}
2269
-
2270
- <button
2271
- onClick={clearTelemetry}
2272
- disabled={clearing || eventCount === 0}
2273
- className="px-4 py-2 bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50 disabled:cursor-not-allowed rounded text-sm font-medium transition"
2274
- >
2275
- {clearing ? "Clearing..." : "Clear All Analytics"}
2276
- </button>
2277
- </div>
2278
- </div>
2279
- </>
2280
- );
2281
- }
2282
-
2283
- // --- Channels Settings ---
2284
-
2285
- interface ChannelInfo {
2286
- id: string;
2287
- type: string;
2288
- name: string;
2289
- agent_id: string;
2290
- status: "stopped" | "running" | "error";
2291
- error: string | null;
2292
- created_at: string;
2293
- }
2294
-
2295
- interface AgentOption {
2296
- id: string;
2297
- name: string;
2298
- status: string;
2299
- }
2300
-
2301
- function ChannelsSettings() {
2302
- const { authFetch } = useAuth();
2303
- const [channels, setChannels] = useState<ChannelInfo[]>([]);
2304
- const [agents, setAgents] = useState<AgentOption[]>([]);
2305
- const [loading, setLoading] = useState(true);
2306
- const [showForm, setShowForm] = useState(false);
2307
- const [formData, setFormData] = useState({ name: "", agent_id: "", botToken: "" });
2308
- const [creating, setCreating] = useState(false);
2309
- const [error, setError] = useState<string | null>(null);
2310
- const { confirm, ConfirmDialog } = useConfirm();
2311
-
2312
- const fetchChannels = async () => {
2313
- try {
2314
- const res = await authFetch("/api/channels");
2315
- const data = await res.json();
2316
- setChannels(data.channels || []);
2317
- } catch {
2318
- // Ignore
2319
- } finally {
2320
- setLoading(false);
2321
- }
2322
- };
2323
-
2324
- const fetchAgents = async () => {
2325
- try {
2326
- const res = await authFetch("/api/agents");
2327
- const data = await res.json();
2328
- setAgents((data.agents || []).map((a: any) => ({ id: a.id, name: a.name, status: a.status })));
2329
- } catch {
2330
- // Ignore
2331
- }
2332
- };
2333
-
2334
- useEffect(() => {
2335
- fetchChannels();
2336
- fetchAgents();
2337
- }, []);
2338
-
2339
- const createChannel = async () => {
2340
- if (!formData.name || !formData.agent_id || !formData.botToken) return;
2341
- setCreating(true);
2342
- setError(null);
2343
-
2344
- try {
2345
- const res = await authFetch("/api/channels", {
2346
- method: "POST",
2347
- headers: { "Content-Type": "application/json" },
2348
- body: JSON.stringify({
2349
- type: "telegram",
2350
- name: formData.name,
2351
- agent_id: formData.agent_id,
2352
- config: { botToken: formData.botToken },
2353
- }),
2354
- });
2355
-
2356
- if (!res.ok) {
2357
- const data = await res.json();
2358
- setError(data.error || "Failed to create channel");
2359
- } else {
2360
- setFormData({ name: "", agent_id: "", botToken: "" });
2361
- setShowForm(false);
2362
- await fetchChannels();
2363
- }
2364
- } catch (err: any) {
2365
- setError(err.message);
2366
- } finally {
2367
- setCreating(false);
2368
- }
2369
- };
2370
-
2371
- const toggleChannel = async (channel: ChannelInfo) => {
2372
- const action = channel.status === "running" ? "stop" : "start";
2373
- try {
2374
- const res = await authFetch(`/api/channels/${channel.id}/${action}`, { method: "POST" });
2375
- if (!res.ok) {
2376
- const data = await res.json();
2377
- setError(data.error || `Failed to ${action} channel`);
2378
- }
2379
- await fetchChannels();
2380
- } catch {
2381
- setError(`Failed to ${action} channel`);
2382
- }
2383
- };
2384
-
2385
- const deleteChannel = async (channel: ChannelInfo) => {
2386
- const confirmed = await confirm(`Delete channel "${channel.name}"?`, {
2387
- confirmText: "Delete",
2388
- title: "Delete Channel",
2389
- });
2390
- if (!confirmed) return;
2391
-
2392
- try {
2393
- await authFetch(`/api/channels/${channel.id}`, { method: "DELETE" });
2394
- await fetchChannels();
2395
- } catch {
2396
- // Ignore
2397
- }
2398
- };
2399
-
2400
- const statusColors: Record<string, string> = {
2401
- running: "bg-green-500/20 text-green-400",
2402
- stopped: "bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]",
2403
- error: "bg-red-500/20 text-red-400",
2404
- };
2405
-
2406
- const getAgentName = (agentId: string) => {
2407
- return agents.find(a => a.id === agentId)?.name || agentId;
2408
- };
2409
-
2410
- return (
2411
- <>
2412
- {ConfirmDialog}
2413
- <div className="max-w-2xl">
2414
- <div className="flex items-center justify-between mb-6">
2415
- <div>
2416
- <h2 className="text-xl font-semibold mb-1">Channels</h2>
2417
- <p className="text-sm text-[var(--color-text-muted)]">Connect agents to external messaging platforms</p>
2418
- </div>
2419
- <button
2420
- onClick={() => setShowForm(!showForm)}
2421
- className="flex items-center gap-2 bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] text-black px-3 py-1.5 rounded text-sm font-medium transition"
2422
- >
2423
- <PlusIcon /> Add Channel
2424
- </button>
2425
- </div>
2426
-
2427
- {error && (
2428
- <div className="mb-4 bg-red-500/10 text-red-400 border border-red-500/30 px-3 py-2 rounded text-sm flex items-center justify-between">
2429
- <span>{error}</span>
2430
- <button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
2431
- <CloseIcon />
2432
- </button>
2433
- </div>
2434
- )}
2435
-
2436
- {/* Create form */}
2437
- {showForm && (
2438
- <div className="mb-6 bg-[var(--color-surface)] card p-4 space-y-3">
2439
- <h3 className="text-sm font-medium text-[var(--color-text-secondary)] mb-2">New Telegram Channel</h3>
2440
-
2441
- <div>
2442
- <label className="block text-xs text-[var(--color-text-muted)] mb-1">Channel Name</label>
2443
- <input
2444
- type="text"
2445
- value={formData.name}
2446
- onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
2447
- placeholder="e.g. My Telegram Bot"
2448
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--color-accent)]"
2449
- />
2450
- </div>
2451
-
2452
- <div>
2453
- <label className="block text-xs text-[var(--color-text-muted)] mb-1">Agent</label>
2454
- <Select
2455
- value={formData.agent_id}
2456
- options={agents.map(a => ({ value: a.id, label: a.name }))}
2457
- onChange={value => setFormData(prev => ({ ...prev, agent_id: value }))}
2458
- placeholder="Select an agent..."
2459
- />
2460
- </div>
2461
-
2462
- <div>
2463
- <label className="block text-xs text-[var(--color-text-muted)] mb-1">Bot Token</label>
2464
- <input
2465
- type="password"
2466
- value={formData.botToken}
2467
- onChange={e => setFormData(prev => ({ ...prev, botToken: e.target.value }))}
2468
- placeholder="From @BotFather on Telegram"
2469
- className="w-full bg-[var(--color-bg)] border border-[var(--color-border-light)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--color-accent)]"
2470
- />
2471
- <p className="text-xs text-[var(--color-text-faint)] mt-1">
2472
- Create a bot via <a href="https://t.me/BotFather" target="_blank" className="text-[var(--color-accent)] hover:underline">@BotFather</a> on Telegram to get a token.
2473
- </p>
2474
- </div>
2475
-
2476
- <div className="flex gap-2 pt-1">
2477
- <button
2478
- onClick={createChannel}
2479
- disabled={creating || !formData.name || !formData.agent_id || !formData.botToken}
2480
- className="bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] disabled:opacity-50 text-black px-4 py-1.5 rounded text-sm font-medium transition"
2481
- >
2482
- {creating ? "Creating..." : "Create"}
2483
- </button>
2484
- <button
2485
- onClick={() => { setShowForm(false); setFormData({ name: "", agent_id: "", botToken: "" }); }}
2486
- className="border border-[var(--color-border-light)] hover:border-[var(--color-scrollbar)] px-4 py-1.5 rounded text-sm transition"
2487
- >
2488
- Cancel
2489
- </button>
2490
- </div>
2491
- </div>
2492
- )}
2493
-
2494
- {/* Channel list */}
2495
- {loading ? (
2496
- <p className="text-[var(--color-text-muted)] text-sm">Loading channels...</p>
2497
- ) : channels.length === 0 ? (
2498
- <div className="text-center py-12 text-[var(--color-text-muted)]">
2499
- <p className="text-lg mb-2">No channels configured</p>
2500
- <p className="text-sm">Add a Telegram channel to let users message your agents directly.</p>
2501
- </div>
2502
- ) : (
2503
- <div className="space-y-3">
2504
- {channels.map(channel => (
2505
- <div key={channel.id} className="bg-[var(--color-surface)] card p-4">
2506
- <div className="flex items-start justify-between">
2507
- <div className="flex-1 min-w-0">
2508
- <div className="flex items-center gap-2 mb-1">
2509
- <h3 className="font-medium">{channel.name}</h3>
2510
- <span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[channel.status] || statusColors.stopped}`}>
2511
- {channel.status}
2512
- </span>
2513
- </div>
2514
- <p className="text-sm text-[var(--color-text-muted)]">
2515
- {channel.type === "telegram" ? "Telegram" : channel.type} → {getAgentName(channel.agent_id)}
2516
- </p>
2517
- {channel.status === "error" && channel.error && (
2518
- <p className="text-xs text-red-400 mt-1">{channel.error}</p>
2519
- )}
2520
- </div>
2521
- <div className="flex items-center gap-2 ml-4">
2522
- <button
2523
- onClick={() => toggleChannel(channel)}
2524
- className={`px-3 py-1 rounded text-xs font-medium transition ${
2525
- channel.status === "running"
2526
- ? "bg-[var(--color-accent-20)] text-[var(--color-accent)] hover:bg-[var(--color-accent-30)]"
2527
- : "bg-[#3b82f6]/20 text-[#3b82f6] hover:bg-[#3b82f6]/30"
2528
- }`}
2529
- >
2530
- {channel.status === "running" ? "Stop" : "Start"}
2531
- </button>
2532
- <button
2533
- onClick={() => deleteChannel(channel)}
2534
- className="text-[var(--color-text-muted)] hover:text-red-400 transition text-sm"
2535
- >
2536
- ×
2537
- </button>
2538
- </div>
2539
- </div>
2540
- </div>
2541
- ))}
2542
- </div>
2543
- )}
2544
- </div>
2545
- </>
2546
- );
2547
- }
2548
-
2549
- function AssistantSettings() {
2550
- const { authFetch } = useAuth();
2551
- const [providers, setProviders] = useState<Provider[]>([]);
2552
- const [provider, setProvider] = useState("");
2553
- const [model, setModel] = useState("");
2554
- const [systemPrompt, setSystemPrompt] = useState("");
2555
- const [status, setStatus] = useState<"running" | "stopped" | "unknown">("unknown");
2556
- const [loading, setLoading] = useState(true);
2557
- const [saving, setSaving] = useState(false);
2558
- const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
2559
- const [starting, setStarting] = useState(false);
2560
- const [webSearch, setWebSearch] = useState(false);
2561
- const [webFetch, setWebFetch] = useState(false);
2562
-
2563
- // Original values for change detection
2564
- const [original, setOriginal] = useState({ provider: "", model: "", systemPrompt: "", webSearch: false, webFetch: false });
2565
-
2566
- useEffect(() => {
2567
- const fetchData = async () => {
2568
- try {
2569
- const [statusRes, providersRes] = await Promise.all([
2570
- authFetch("/api/meta-agent/status"),
2571
- authFetch("/api/providers"),
2572
- ]);
2573
- const statusData = await statusRes.json();
2574
- const providersData = await providersRes.json();
2575
- setProviders((providersData.providers || []).filter((p: Provider) => p.type === "llm" && p.hasKey));
2576
-
2577
- if (statusData.agent) {
2578
- const a = statusData.agent;
2579
- setProvider(a.provider || "");
2580
- setModel(a.model || "");
2581
- setSystemPrompt(a.systemPrompt || "");
2582
- setStatus(a.status || "stopped");
2583
- const ws = a.features?.builtinTools?.webSearch || false;
2584
- const wf = a.features?.builtinTools?.webFetch || false;
2585
- setWebSearch(ws);
2586
- setWebFetch(wf);
2587
- setOriginal({ provider: a.provider || "", model: a.model || "", systemPrompt: a.systemPrompt || "", webSearch: ws, webFetch: wf });
2588
- }
2589
- } catch {
2590
- setMessage({ type: "error", text: "Failed to load assistant config" });
2591
- } finally {
2592
- setLoading(false);
2593
- }
2594
- };
2595
- fetchData();
2596
- }, [authFetch]);
2597
-
2598
- const selectedProvider = providers.find(p => p.id === provider);
2599
- const models = selectedProvider?.models || [];
2600
-
2601
- const handleProviderChange = (newProvider: string) => {
2602
- setProvider(newProvider);
2603
- const p = providers.find(pr => pr.id === newProvider);
2604
- const defaultModel = p?.models.find(m => m.recommended)?.value || p?.models[0]?.value || "";
2605
- setModel(defaultModel);
2606
- };
2607
-
2608
- const hasChanges = provider !== original.provider || model !== original.model || systemPrompt !== original.systemPrompt || webSearch !== original.webSearch || webFetch !== original.webFetch;
2609
-
2610
- const handleSave = async () => {
2611
- setSaving(true);
2612
- setMessage(null);
2613
- try {
2614
- const res = await authFetch("/api/agents/apteva-assistant", {
2615
- method: "PUT",
2616
- headers: { "Content-Type": "application/json" },
2617
- body: JSON.stringify({ provider, model, systemPrompt, features: { builtinTools: { webSearch, webFetch } } }),
2618
- });
2619
- if (res.ok) {
2620
- setOriginal({ provider, model, systemPrompt, webSearch, webFetch });
2621
- setMessage({ type: "success", text: "Assistant settings saved" });
2622
- setTimeout(() => setMessage(null), 3000);
2623
- } else {
2624
- const data = await res.json().catch(() => ({}));
2625
- setMessage({ type: "error", text: data.error || "Failed to save" });
2626
- }
2627
- } catch {
2628
- setMessage({ type: "error", text: "Failed to save settings" });
2629
- } finally {
2630
- setSaving(false);
2631
- }
2632
- };
2633
-
2634
- const handleToggle = async () => {
2635
- setStarting(true);
2636
- setMessage(null);
2637
- try {
2638
- const endpoint = status === "running" ? "/api/meta-agent/stop" : "/api/meta-agent/start";
2639
- const res = await authFetch(endpoint, { method: "POST" });
2640
- if (res.ok) {
2641
- setStatus(status === "running" ? "stopped" : "running");
2642
- } else {
2643
- const data = await res.json().catch(() => ({}));
2644
- setMessage({ type: "error", text: data.error || "Failed to toggle assistant" });
2645
- }
2646
- } catch {
2647
- setMessage({ type: "error", text: "Failed to toggle assistant" });
2648
- } finally {
2649
- setStarting(false);
2650
- }
2651
- };
2652
-
2653
- if (loading) {
2654
- return <div className="text-[var(--color-text-muted)]">Loading assistant settings...</div>;
2655
- }
2656
-
2657
- return (
2658
- <div className="max-w-2xl">
2659
- <h2 className="text-lg font-medium mb-1">Apteva Assistant</h2>
2660
- <p className="text-sm text-[var(--color-text-muted)] mb-6">Configure the built-in AI assistant that manages your agents and platform.</p>
2661
-
2662
- {message && (
2663
- <div className={`mb-4 px-3 py-2 rounded text-sm ${
2664
- message.type === "success" ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-400"
2665
- }`}>
2666
- {message.text}
2667
- </div>
2668
- )}
2669
-
2670
- {/* Status */}
2671
- <div className="mb-6 flex items-center gap-3">
2672
- <span className="text-sm text-[var(--color-text-muted)]">Status:</span>
2673
- <span className={`px-2 py-1 rounded text-xs font-medium ${
2674
- status === "running" ? "bg-[#3b82f6]/20 text-[#3b82f6]" : "bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]"
2675
- }`}>
2676
- {status}
2677
- </span>
2678
- <button
2679
- onClick={handleToggle}
2680
- disabled={starting}
2681
- className={`px-3 py-1.5 rounded text-sm font-medium transition ${
2682
- status === "running"
2683
- ? "bg-[var(--color-accent-20)] text-[var(--color-accent)] hover:bg-[var(--color-accent-30)]"
2684
- : "bg-[#3b82f6]/20 text-[#3b82f6] hover:bg-[#3b82f6]/30"
2685
- } disabled:opacity-50`}
2686
- >
2687
- {starting ? "..." : status === "running" ? "Stop" : "Start"}
2688
- </button>
2689
- </div>
2690
-
2691
- {/* Provider */}
2692
- <div className="mb-4">
2693
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Provider</label>
2694
- <Select
2695
- value={provider}
2696
- onChange={handleProviderChange}
2697
- options={providers.map(p => ({ value: p.id, label: p.name }))}
2698
- placeholder="Select provider..."
2699
- />
2700
- </div>
2701
-
2702
- {/* Model */}
2703
- <div className="mb-4">
2704
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Model</label>
2705
- <Select
2706
- value={model}
2707
- onChange={setModel}
2708
- options={models.map(m => ({ value: m.value, label: m.label, recommended: m.recommended }))}
2709
- placeholder="Select model..."
2710
- />
2711
- </div>
2712
-
2713
- {/* Built-in Tools - Anthropic only */}
2714
- {provider === "anthropic" && (
2715
- <div className="mb-4">
2716
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">Built-in Tools</label>
2717
- <div className="flex flex-wrap gap-2">
2718
- <button
2719
- type="button"
2720
- onClick={() => setWebSearch(!webSearch)}
2721
- className={`flex items-center gap-2 px-3 py-2 rounded border transition ${
2722
- webSearch
2723
- ? "border-[var(--color-accent)] bg-[var(--color-accent-10)] text-[var(--color-accent)]"
2724
- : "border-[var(--color-border-light)] hover:border-[var(--color-border-light)] text-[var(--color-text-secondary)]"
2725
- }`}
2726
- >
2727
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2728
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
2729
- </svg>
2730
- <span className="text-sm">Web Search</span>
2731
- </button>
2732
- <button
2733
- type="button"
2734
- onClick={() => setWebFetch(!webFetch)}
2735
- className={`flex items-center gap-2 px-3 py-2 rounded border transition ${
2736
- webFetch
2737
- ? "border-[var(--color-accent)] bg-[var(--color-accent-10)] text-[var(--color-accent)]"
2738
- : "border-[var(--color-border-light)] hover:border-[var(--color-border-light)] text-[var(--color-text-secondary)]"
2739
- }`}
2740
- >
2741
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2742
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
2743
- </svg>
2744
- <span className="text-sm">Web Fetch</span>
2745
- </button>
2746
- </div>
2747
- <p className="text-xs text-[var(--color-text-faint)] mt-2">Provider-native tools for real-time web access</p>
2748
- </div>
2749
- )}
2750
-
2751
- {/* System Prompt */}
2752
- <div className="mb-6">
2753
- <label className="block text-sm text-[var(--color-text-muted)] mb-1">System Prompt</label>
2754
- <textarea
2755
- value={systemPrompt}
2756
- onChange={e => setSystemPrompt(e.target.value)}
2757
- rows={12}
2758
- className="w-full bg-[var(--color-surface)] border border-[var(--color-border)] rounded px-3 py-2 text-sm font-mono focus:outline-none focus:border-[var(--color-accent)] resize-y"
2759
- />
2760
- </div>
2761
-
2762
- {/* Save */}
2763
- <button
2764
- onClick={handleSave}
2765
- disabled={!hasChanges || saving}
2766
- className="bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed text-black px-4 py-2 rounded font-medium transition"
2767
- >
2768
- {saving ? "Saving..." : "Save Changes"}
2769
- </button>
2770
-
2771
- {status === "running" && hasChanges && (
2772
- <p className="text-xs text-[var(--color-text-muted)] mt-2">Changes will be applied to the running assistant</p>
2773
- )}
2774
- </div>
2775
- );
2776
- }