fluxy-bot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/bin/cli.js +469 -0
  2. package/client/index.html +13 -0
  3. package/client/public/fluxy.png +0 -0
  4. package/client/public/icons/claude.png +0 -0
  5. package/client/public/icons/codex.png +0 -0
  6. package/client/public/icons/openai.svg +15 -0
  7. package/client/src/App.tsx +81 -0
  8. package/client/src/components/Chat/ChatView.tsx +19 -0
  9. package/client/src/components/Chat/InputBar.tsx +242 -0
  10. package/client/src/components/Chat/MessageBubble.tsx +20 -0
  11. package/client/src/components/Chat/MessageList.tsx +39 -0
  12. package/client/src/components/Chat/TypingIndicator.tsx +10 -0
  13. package/client/src/components/Dashboard/ConversationAnalytics.tsx +84 -0
  14. package/client/src/components/Dashboard/DashboardPage.tsx +52 -0
  15. package/client/src/components/Dashboard/PromoCard.tsx +44 -0
  16. package/client/src/components/Dashboard/ReportCard.tsx +35 -0
  17. package/client/src/components/Dashboard/TodayStats.tsx +28 -0
  18. package/client/src/components/ErrorBoundary.tsx +23 -0
  19. package/client/src/components/FluxyFab.tsx +25 -0
  20. package/client/src/components/Layout/ConnectionStatus.tsx +8 -0
  21. package/client/src/components/Layout/DashboardHeader.tsx +90 -0
  22. package/client/src/components/Layout/DashboardLayout.tsx +24 -0
  23. package/client/src/components/Layout/Header.tsx +10 -0
  24. package/client/src/components/Layout/MobileNav.tsx +30 -0
  25. package/client/src/components/Layout/Sidebar.tsx +55 -0
  26. package/client/src/components/Onboard/OnboardWizard.tsx +763 -0
  27. package/client/src/components/ui/avatar.tsx +109 -0
  28. package/client/src/components/ui/badge.tsx +48 -0
  29. package/client/src/components/ui/button.tsx +64 -0
  30. package/client/src/components/ui/card.tsx +92 -0
  31. package/client/src/components/ui/dialog.tsx +156 -0
  32. package/client/src/components/ui/dropdown-menu.tsx +257 -0
  33. package/client/src/components/ui/input.tsx +21 -0
  34. package/client/src/components/ui/scroll-area.tsx +58 -0
  35. package/client/src/components/ui/select.tsx +190 -0
  36. package/client/src/components/ui/separator.tsx +28 -0
  37. package/client/src/components/ui/sheet.tsx +141 -0
  38. package/client/src/components/ui/skeleton.tsx +13 -0
  39. package/client/src/components/ui/switch.tsx +33 -0
  40. package/client/src/components/ui/tabs.tsx +89 -0
  41. package/client/src/components/ui/textarea.tsx +18 -0
  42. package/client/src/components/ui/tooltip.tsx +55 -0
  43. package/client/src/hooks/useChat.ts +69 -0
  44. package/client/src/hooks/useMobile.ts +16 -0
  45. package/client/src/hooks/useWebSocket.ts +24 -0
  46. package/client/src/lib/mock-data.ts +104 -0
  47. package/client/src/lib/utils.ts +6 -0
  48. package/client/src/lib/ws-client.ts +52 -0
  49. package/client/src/main.tsx +10 -0
  50. package/client/src/styles/globals.css +55 -0
  51. package/components.json +20 -0
  52. package/dist/assets/index-BkNWpS06.css +1 -0
  53. package/dist/assets/index-CX3QeqQ8.js +64 -0
  54. package/dist/fluxy.png +0 -0
  55. package/dist/icons/claude.png +0 -0
  56. package/dist/icons/codex.png +0 -0
  57. package/dist/icons/openai.svg +15 -0
  58. package/dist/index.html +14 -0
  59. package/dist/manifest.webmanifest +1 -0
  60. package/dist/registerSW.js +1 -0
  61. package/dist/sw.js +1 -0
  62. package/dist/workbox-8c29f6e4.js +1 -0
  63. package/package.json +82 -0
  64. package/postcss.config.js +5 -0
  65. package/shared/ai.ts +141 -0
  66. package/shared/config.ts +37 -0
  67. package/shared/logger.ts +13 -0
  68. package/shared/paths.ts +14 -0
  69. package/shared/relay.ts +101 -0
  70. package/supervisor/fluxy.html +94 -0
  71. package/supervisor/index.ts +173 -0
  72. package/supervisor/tunnel.ts +62 -0
  73. package/supervisor/worker.ts +55 -0
  74. package/tsconfig.json +20 -0
  75. package/vite.config.ts +38 -0
  76. package/worker/claude-auth.ts +224 -0
  77. package/worker/codex-auth.ts +199 -0
  78. package/worker/db.ts +75 -0
  79. package/worker/index.ts +169 -0
@@ -0,0 +1,763 @@
1
+ import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
2
+ import { ArrowRight, LoaderCircle, ExternalLink, ClipboardPaste, RefreshCw, Check, ChevronDown, Mic } from 'lucide-react';
3
+ import { motion, AnimatePresence } from 'framer-motion';
4
+
5
+ /* ── Provider config ── */
6
+
7
+ const PROVIDERS = [
8
+ { id: 'anthropic', name: 'Claude', subtitle: 'by Anthropic', icon: '/icons/claude.png' },
9
+ { id: 'openai', name: 'OpenAI Codex', subtitle: 'ChatGPT Plus / Pro', icon: '/icons/codex.png' },
10
+ { id: 'ollama', name: 'Ollama', subtitle: 'Run locally', icon: null },
11
+ ] as const;
12
+
13
+ const MODELS: Record<string, { id: string; label: string }[]> = {
14
+ anthropic: [
15
+ { id: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
16
+ { id: 'claude-opus-4-20250514', label: 'Claude Opus 4' },
17
+ { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
18
+ ],
19
+ openai: [
20
+ { id: 'gpt-5.2-codex:medium', label: 'GPT-5.2 Codex Medium' },
21
+ { id: 'gpt-5.2-codex:high', label: 'GPT-5.2 Codex High' },
22
+ { id: 'gpt-5.2-codex:xhigh', label: 'GPT-5.2 Codex Extra High' },
23
+ { id: 'gpt-5.3-codex:medium', label: 'GPT-5.3 Codex Medium (Pro)' },
24
+ { id: 'gpt-5.3-codex:high', label: 'GPT-5.3 Codex High (Pro)' },
25
+ { id: 'gpt-5.3-codex:xhigh', label: 'GPT-5.3 Codex Extra High (Pro)' },
26
+ ],
27
+ ollama: [
28
+ { id: 'llama3.2', label: 'Llama 3.2' },
29
+ { id: 'mistral', label: 'Mistral' },
30
+ { id: 'codellama', label: 'Code Llama' },
31
+ { id: 'phi3', label: 'Phi-3' },
32
+ ],
33
+ };
34
+
35
+ const TOTAL_STEPS = 5; // 0..4
36
+
37
+ /* ── Dropdown ── */
38
+
39
+ function ModelDropdown({ models, value, onChange }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void }) {
40
+ const [open, setOpen] = useState(false);
41
+ const ref = useRef<HTMLDivElement>(null);
42
+
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ const handler = (e: MouseEvent) => {
46
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
47
+ };
48
+ document.addEventListener('mousedown', handler);
49
+ return () => document.removeEventListener('mousedown', handler);
50
+ }, [open]);
51
+
52
+ const selected = models.find((m) => m.id === value);
53
+
54
+ return (
55
+ <div className="relative" ref={ref}>
56
+ <button
57
+ type="button"
58
+ onClick={() => setOpen((o) => !o)}
59
+ className="w-full flex items-center justify-between bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none hover:border-white/15 focus:border-primary/30 transition-colors"
60
+ >
61
+ <span className={selected ? 'text-white' : 'text-white/20'}>
62
+ {selected ? selected.label : 'Choose a model...'}
63
+ </span>
64
+ <ChevronDown className={`h-4 w-4 text-white/30 transition-transform ${open ? 'rotate-180' : ''}`} />
65
+ </button>
66
+ {open && (
67
+ <div className="absolute left-0 right-0 top-full mt-1 bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 z-10 max-h-48 overflow-y-auto">
68
+ {models.map((m) => (
69
+ <button
70
+ key={m.id}
71
+ onClick={() => { onChange(m.id); setOpen(false); }}
72
+ className={`w-full text-left px-4 py-2 text-[13px] transition-colors ${
73
+ value === m.id
74
+ ? 'text-primary bg-primary/10'
75
+ : 'text-white/70 hover:bg-white/[0.04] hover:text-white'
76
+ }`}
77
+ >
78
+ {m.label}
79
+ </button>
80
+ ))}
81
+ </div>
82
+ )}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ /* ── Component ── */
88
+
89
+ interface Props {
90
+ onComplete: () => void;
91
+ }
92
+
93
+ export default function OnboardWizard({ onComplete }: Props) {
94
+ const [step, setStep] = useState(0);
95
+ const [userName, setUserName] = useState('');
96
+ const [agentName, setAgentName] = useState('Fluxy');
97
+ const [provider, setProvider] = useState('anthropic');
98
+ const [model, setModel] = useState('');
99
+ const [saving, setSaving] = useState(false);
100
+
101
+ // Auth state per provider
102
+ const [authState, setAuthState] = useState<Record<string, 'idle' | 'authenticating' | 'connected'>>({
103
+ anthropic: 'idle',
104
+ openai: 'idle',
105
+ ollama: 'connected',
106
+ });
107
+
108
+ // Anthropic/Claude-specific
109
+ const [oauthStarted, setOauthStarted] = useState(false);
110
+ const [anthropicCode, setAnthropicCode] = useState('');
111
+ const [isExchanging, setIsExchanging] = useState(false);
112
+ const [anthropicError, setAnthropicError] = useState<string | undefined>();
113
+ const [anthropicChecking, setAnthropicChecking] = useState(false);
114
+
115
+ // OpenAI/Codex-specific
116
+ const [openaiWaiting, setOpenaiWaiting] = useState(false);
117
+ const [openaiError, setOpenaiError] = useState<string | undefined>();
118
+
119
+ // Ollama-specific
120
+ const [baseUrl, setBaseUrl] = useState('');
121
+
122
+ // Whisper (step 4)
123
+ const [whisperEnabled, setWhisperEnabled] = useState(false);
124
+
125
+ const isConnected = authState[provider] === 'connected';
126
+
127
+ // Check if Claude is already authenticated when selecting Anthropic
128
+ useEffect(() => {
129
+ if (provider !== 'anthropic' || authState.anthropic === 'connected') return;
130
+ fetch('/api/auth/claude/status')
131
+ .then((r) => r.json())
132
+ .then((data) => {
133
+ if (data.authenticated) setAuthState((s) => ({ ...s, anthropic: 'connected' }));
134
+ })
135
+ .catch(() => {});
136
+ }, [provider]);
137
+
138
+ // Check if Codex is already authenticated when selecting OpenAI
139
+ useEffect(() => {
140
+ if (provider !== 'openai' || authState.openai === 'connected') return;
141
+ fetch('/api/auth/codex/status')
142
+ .then((r) => r.json())
143
+ .then((data) => {
144
+ if (data.authenticated) setAuthState((s) => ({ ...s, openai: 'connected' }));
145
+ })
146
+ .catch(() => {});
147
+ }, [provider]);
148
+
149
+ // Poll for Codex auth completion while waiting
150
+ useEffect(() => {
151
+ if (!openaiWaiting) return;
152
+ const interval = setInterval(async () => {
153
+ try {
154
+ const res = await fetch('/api/auth/codex/status');
155
+ const data = await res.json();
156
+ if (data.authenticated) {
157
+ setOpenaiWaiting(false);
158
+ setAuthState((s) => ({ ...s, openai: 'connected' }));
159
+ }
160
+ } catch {}
161
+ }, 2000);
162
+ return () => clearInterval(interval);
163
+ }, [openaiWaiting]);
164
+
165
+ const handleProviderChange = (id: string) => {
166
+ // Cancel Codex OAuth if switching away from OpenAI
167
+ if (provider === 'openai' && id !== 'openai' && openaiWaiting) {
168
+ fetch('/api/auth/codex/cancel', { method: 'POST' });
169
+ setOpenaiWaiting(false);
170
+ }
171
+ setProvider(id);
172
+ setModel('');
173
+ setOauthStarted(false);
174
+ setAnthropicCode('');
175
+ setAnthropicError(undefined);
176
+ setOpenaiWaiting(false);
177
+ setOpenaiError(undefined);
178
+ };
179
+
180
+ /* ── Auth handlers: Anthropic/Claude ── */
181
+
182
+ const handleAnthropicAuth = async () => {
183
+ setAnthropicError(undefined);
184
+ try {
185
+ const res = await fetch('/api/auth/claude/start', { method: 'POST' });
186
+ const data = await res.json();
187
+ if (data.success && data.authUrl) {
188
+ window.open(data.authUrl, '_blank');
189
+ setOauthStarted(true);
190
+ } else {
191
+ setAnthropicError(data.error || 'Failed to start authentication');
192
+ }
193
+ } catch (err: any) {
194
+ setAnthropicError(err.message);
195
+ }
196
+ };
197
+
198
+ const handleAnthropicConnect = async () => {
199
+ if (!anthropicCode.trim()) return;
200
+ setIsExchanging(true);
201
+ setAnthropicError(undefined);
202
+ try {
203
+ const res = await fetch('/api/auth/claude/exchange', {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({ code: anthropicCode.trim() }),
207
+ });
208
+ const data = await res.json();
209
+ if (data.success) {
210
+ setAuthState((s) => ({ ...s, anthropic: 'connected' }));
211
+ } else {
212
+ setAnthropicError(data.error || 'Code exchange failed');
213
+ }
214
+ } catch (err: any) {
215
+ setAnthropicError(err.message);
216
+ } finally {
217
+ setIsExchanging(false);
218
+ }
219
+ };
220
+
221
+ const handleAnthropicPaste = async () => {
222
+ try {
223
+ const text = await navigator.clipboard.readText();
224
+ if (text) setAnthropicCode(text.trim());
225
+ } catch { /* clipboard denied */ }
226
+ };
227
+
228
+ const handleAnthropicCheckAuth = async () => {
229
+ setAnthropicChecking(true);
230
+ setAnthropicError(undefined);
231
+ try {
232
+ const res = await fetch('/api/auth/claude/status');
233
+ const data = await res.json();
234
+ if (data.authenticated) {
235
+ setAuthState((s) => ({ ...s, anthropic: 'connected' }));
236
+ } else {
237
+ setAnthropicError('No active session found. Please authenticate first.');
238
+ }
239
+ } catch {} finally {
240
+ setAnthropicChecking(false);
241
+ }
242
+ };
243
+
244
+ /* ── Auth handlers: OpenAI/Codex ── */
245
+
246
+ const handleOpenAIAuth = async () => {
247
+ setOpenaiWaiting(true);
248
+ setOpenaiError(undefined);
249
+ try {
250
+ const res = await fetch('/api/auth/codex/start', { method: 'POST' });
251
+ const data = await res.json();
252
+ if (data.success && data.authUrl) {
253
+ window.open(data.authUrl, '_blank');
254
+ } else {
255
+ setOpenaiWaiting(false);
256
+ setOpenaiError(data.error || 'Failed to start authentication');
257
+ }
258
+ } catch (err: any) {
259
+ setOpenaiWaiting(false);
260
+ setOpenaiError(err.message);
261
+ }
262
+ };
263
+
264
+ const handleOpenAICancel = () => {
265
+ setOpenaiWaiting(false);
266
+ fetch('/api/auth/codex/cancel', { method: 'POST' });
267
+ };
268
+
269
+ /* ── Navigation ── */
270
+
271
+ const canNext = (() => {
272
+ switch (step) {
273
+ case 0: return true;
274
+ case 1: return userName.trim().length > 0;
275
+ case 2: return agentName.trim().length > 0;
276
+ case 3: return !!(provider && model && isConnected);
277
+ case 4: return true; // Whisper is optional
278
+ default: return false;
279
+ }
280
+ })();
281
+
282
+ const next = () => { if (canNext && step < TOTAL_STEPS - 1) setStep((s) => s + 1); };
283
+ const back = () => { if (step > 0) setStep((s) => s - 1); };
284
+
285
+ const handleKeyDown = (e: KeyboardEvent) => {
286
+ if (e.key === 'Enter' && canNext) next();
287
+ };
288
+
289
+ const handleComplete = async () => {
290
+ setSaving(true);
291
+ try {
292
+ await fetch('/api/onboard', {
293
+ method: 'POST',
294
+ headers: { 'Content-Type': 'application/json' },
295
+ body: JSON.stringify({
296
+ userName: userName.trim(),
297
+ agentName: agentName.trim() || 'Fluxy',
298
+ provider,
299
+ model,
300
+ apiKey: '',
301
+ baseUrl: provider === 'ollama' ? baseUrl || undefined : undefined,
302
+ whisperEnabled,
303
+ }),
304
+ });
305
+ onComplete();
306
+ } catch (err) {
307
+ console.error('Onboard failed:', err);
308
+ setSaving(false);
309
+ }
310
+ };
311
+
312
+ /* ── Styles ── */
313
+
314
+ const inputCls =
315
+ 'w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-base outline-none focus:border-primary/40 placeholder:text-white/20 transition-colors';
316
+ const inputSmCls =
317
+ 'w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none focus:border-primary/30 placeholder:text-white/20 transition-colors';
318
+
319
+ return (
320
+ <div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
321
+ <div className="absolute inset-0 bg-black/85 backdrop-blur-md" />
322
+
323
+ <motion.div
324
+ initial={{ opacity: 0, scale: 0.95 }}
325
+ animate={{ opacity: 1, scale: 1 }}
326
+ transition={{ duration: 0.3 }}
327
+ className="relative w-full max-w-[480px] bg-[#181818] border border-white/[0.06] rounded-[24px] shadow-2xl overflow-hidden"
328
+ >
329
+ {/* Step dots */}
330
+ <div className="flex justify-center gap-2 pt-6">
331
+ {Array.from({ length: TOTAL_STEPS }, (_, i) => (
332
+ <div
333
+ key={i}
334
+ className={`h-1.5 rounded-full transition-all duration-300 ${
335
+ i === step
336
+ ? 'w-7 bg-primary'
337
+ : i < step
338
+ ? 'w-1.5 bg-primary/60'
339
+ : 'w-1.5 bg-white/10'
340
+ }`}
341
+ />
342
+ ))}
343
+ </div>
344
+
345
+ {/* Content */}
346
+ <AnimatePresence mode="wait">
347
+ <motion.div
348
+ key={step}
349
+ initial={{ opacity: 0, x: 30 }}
350
+ animate={{ opacity: 1, x: 0 }}
351
+ exit={{ opacity: 0, x: -30 }}
352
+ transition={{ duration: 0.2, ease: 'easeOut' }}
353
+ className="px-8 pt-6 pb-8"
354
+ >
355
+ {/* ── Step 0: Welcome ── */}
356
+ {step === 0 && (
357
+ <div className="flex flex-col items-center text-center">
358
+ <img src="/fluxy.png" alt="Fluxy" className="h-16 w-auto mb-4" />
359
+ <h1 className="text-2xl font-bold text-white tracking-tight">
360
+ Welcome to Fluxy
361
+ </h1>
362
+ <p className="text-white/40 text-[14px] mt-2 leading-relaxed max-w-[320px]">
363
+ Let's set up your AI assistant in just a few steps.
364
+ </p>
365
+ <button
366
+ onClick={next}
367
+ className="mt-6 px-7 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center gap-2"
368
+ >
369
+ Get Started
370
+ <ArrowRight className="h-4 w-4" />
371
+ </button>
372
+ </div>
373
+ )}
374
+
375
+ {/* ── Step 1: Your name ── */}
376
+ {step === 1 && (
377
+ <div>
378
+ <h1 className="text-xl font-bold text-white tracking-tight">
379
+ What's your name?
380
+ </h1>
381
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
382
+ This is how your agent will address you.
383
+ </p>
384
+ <div className="mt-5 flex items-center gap-3">
385
+ <input
386
+ type="text"
387
+ value={userName}
388
+ onChange={(e) => setUserName(e.target.value)}
389
+ onKeyDown={handleKeyDown}
390
+ placeholder="Enter your name"
391
+ autoFocus
392
+ className={inputCls + ' flex-1'}
393
+ />
394
+ <button
395
+ onClick={next}
396
+ disabled={!canNext}
397
+ className="shrink-0 h-12 w-12 flex items-center justify-center rounded-full bg-primary hover:bg-primary/90 text-white transition-colors disabled:opacity-30"
398
+ >
399
+ <ArrowRight className="h-5 w-5" />
400
+ </button>
401
+ </div>
402
+ </div>
403
+ )}
404
+
405
+ {/* ── Step 2: Agent name ── */}
406
+ {step === 2 && (
407
+ <div>
408
+ <h1 className="text-xl font-bold text-white tracking-tight">
409
+ Name your AI agent
410
+ </h1>
411
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
412
+ Give your assistant a personality.
413
+ </p>
414
+ <div className="mt-5 flex items-center gap-3">
415
+ <input
416
+ type="text"
417
+ value={agentName}
418
+ onChange={(e) => setAgentName(e.target.value)}
419
+ onKeyDown={handleKeyDown}
420
+ placeholder="e.g. Fluxy"
421
+ autoFocus
422
+ className={inputCls + ' flex-1'}
423
+ />
424
+ <button
425
+ onClick={next}
426
+ disabled={!canNext}
427
+ className="shrink-0 h-12 w-12 flex items-center justify-center rounded-full bg-primary hover:bg-primary/90 text-white transition-colors disabled:opacity-30"
428
+ >
429
+ <ArrowRight className="h-5 w-5" />
430
+ </button>
431
+ </div>
432
+ </div>
433
+ )}
434
+
435
+ {/* ── Step 3: Provider + Auth + Model ── */}
436
+ {step === 3 && (
437
+ <div>
438
+ <h1 className="text-xl font-bold text-white tracking-tight">
439
+ Choose your AI provider
440
+ </h1>
441
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
442
+ Pick one provider to power your bot, authenticate, and select a model.
443
+ </p>
444
+
445
+ {/* Provider cards */}
446
+ <div className="flex gap-2.5 mt-4">
447
+ {PROVIDERS.map((p) => (
448
+ <button
449
+ key={p.id}
450
+ onClick={() => handleProviderChange(p.id)}
451
+ className={`flex-1 relative rounded-xl border transition-all duration-200 p-3 text-left ${
452
+ provider === p.id
453
+ ? 'bg-white/[0.04] border-primary/40'
454
+ : 'bg-transparent border-white/[0.06] hover:border-white/10 hover:bg-white/[0.02]'
455
+ }`}
456
+ >
457
+ <div className="flex flex-col items-center gap-1.5 py-0.5">
458
+ {p.icon ? (
459
+ <img src={p.icon} alt={p.name} className="w-8 h-8 rounded-lg" />
460
+ ) : (
461
+ <div className="w-8 h-8 rounded-lg bg-white/[0.06] flex items-center justify-center text-white/50 text-sm font-bold">
462
+ O
463
+ </div>
464
+ )}
465
+ <div className="text-center">
466
+ <div className="text-[13px] font-medium text-white">{p.name}</div>
467
+ <div className="text-[10px] text-white/30">{p.subtitle}</div>
468
+ </div>
469
+ </div>
470
+ {authState[p.id] === 'connected' ? (
471
+ <div className="absolute top-2 right-2 w-4 h-4 rounded-full bg-emerald-500/15 flex items-center justify-center">
472
+ <Check className="h-2.5 w-2.5 text-emerald-400" />
473
+ </div>
474
+ ) : provider === p.id ? (
475
+ <div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-primary" />
476
+ ) : null}
477
+ </button>
478
+ ))}
479
+ </div>
480
+
481
+ {/* Divider */}
482
+ <div className="border-t border-white/[0.06] mt-4 mb-3" />
483
+
484
+ {/* ── Auth flow: Anthropic ── */}
485
+ {provider === 'anthropic' && (
486
+ <div className="space-y-2.5">
487
+ {isConnected && (
488
+ <div className="bg-emerald-500/8 border border-emerald-500/15 rounded-lg px-3.5 py-2.5">
489
+ <p className="text-emerald-400/90 text-[12px]">Connected — Anthropic subscription is active.</p>
490
+ </div>
491
+ )}
492
+
493
+ {!isConnected && (
494
+ <>
495
+ {anthropicError && (
496
+ <div className="bg-red-500/8 border border-red-500/15 rounded-lg px-3.5 py-2.5">
497
+ <p className="text-red-400/90 text-[12px]">{anthropicError}</p>
498
+ </div>
499
+ )}
500
+
501
+ <div className="space-y-1.5">
502
+ {[
503
+ 'Click the button below to open Anthropic\'s login page',
504
+ 'Sign in with your Anthropic account — a code will be generated',
505
+ 'Copy the code and paste it in the field below',
506
+ ].map((text, i) => (
507
+ <div key={i} className="flex items-start gap-2">
508
+ <span className="flex-shrink-0 w-[18px] h-[18px] rounded-full bg-white/[0.06] text-white/30 text-[10px] font-medium flex items-center justify-center mt-px">{i + 1}</span>
509
+ <p className="text-white/40 text-[12px] leading-relaxed">{text}</p>
510
+ </div>
511
+ ))}
512
+ </div>
513
+
514
+ <button
515
+ onClick={handleAnthropicAuth}
516
+ className="w-full py-2.5 px-4 bg-primary hover:bg-primary/90 text-white text-[13px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
517
+ >
518
+ {oauthStarted ? (
519
+ <><ExternalLink className="h-3.5 w-3.5 opacity-60" />Open authentication page again</>
520
+ ) : (
521
+ <>Authenticate with Anthropic<ArrowRight className="h-3.5 w-3.5 opacity-60" /></>
522
+ )}
523
+ </button>
524
+
525
+ <div className="relative">
526
+ <input
527
+ type="text"
528
+ value={anthropicCode}
529
+ onChange={(e) => setAnthropicCode(e.target.value)}
530
+ onKeyDown={(e) => e.key === 'Enter' && handleAnthropicConnect()}
531
+ placeholder="Paste your code here..."
532
+ className={inputSmCls + ' pr-10 font-mono'}
533
+ />
534
+ <button
535
+ onClick={handleAnthropicPaste}
536
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-white/20 hover:text-white/50 transition-colors"
537
+ >
538
+ <ClipboardPaste className="h-3.5 w-3.5" />
539
+ </button>
540
+ </div>
541
+
542
+ <button
543
+ onClick={handleAnthropicConnect}
544
+ disabled={!anthropicCode.trim() || isExchanging}
545
+ className="w-full py-2.5 px-4 bg-primary hover:bg-primary/90 text-white text-[13px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
546
+ >
547
+ {isExchanging ? (<><LoaderCircle className="h-3.5 w-3.5 animate-spin" />Verifying...</>) : 'Connect'}
548
+ </button>
549
+
550
+ <button
551
+ onClick={handleAnthropicCheckAuth}
552
+ disabled={anthropicChecking}
553
+ className="w-full py-1.5 text-white/25 text-[11px] hover:text-white/40 transition-colors flex items-center justify-center gap-1.5 disabled:opacity-50"
554
+ >
555
+ {anthropicChecking ? (
556
+ <LoaderCircle className="h-3 w-3 animate-spin" />
557
+ ) : (
558
+ <RefreshCw className="h-3 w-3" />
559
+ )}
560
+ {anthropicChecking ? 'Checking...' : 'I\'m already authenticated'}
561
+ </button>
562
+ </>
563
+ )}
564
+ </div>
565
+ )}
566
+
567
+ {/* ── Auth flow: OpenAI ── */}
568
+ {provider === 'openai' && (
569
+ <div className="space-y-2.5">
570
+ {isConnected && (
571
+ <div className="bg-emerald-500/8 border border-emerald-500/15 rounded-lg px-3.5 py-2.5">
572
+ <p className="text-emerald-400/90 text-[12px]">Connected — ChatGPT subscription is active.</p>
573
+ </div>
574
+ )}
575
+
576
+ {!isConnected && (
577
+ <>
578
+ {openaiError && (
579
+ <div className="bg-red-500/8 border border-red-500/15 rounded-lg px-3.5 py-2.5">
580
+ <p className="text-red-400/90 text-[12px]">{openaiError}</p>
581
+ </div>
582
+ )}
583
+
584
+ <div className="space-y-1.5">
585
+ {[
586
+ 'Click the button below — your browser will open for ChatGPT sign-in',
587
+ 'Sign in with your ChatGPT Plus or Pro account',
588
+ 'Authentication completes automatically — no code to copy',
589
+ ].map((text, i) => (
590
+ <div key={i} className="flex items-start gap-2">
591
+ <span className="flex-shrink-0 w-[18px] h-[18px] rounded-full bg-white/[0.06] text-white/30 text-[10px] font-medium flex items-center justify-center mt-px">{i + 1}</span>
592
+ <p className="text-white/40 text-[12px] leading-relaxed">{text}</p>
593
+ </div>
594
+ ))}
595
+ </div>
596
+
597
+ <button
598
+ onClick={handleOpenAIAuth}
599
+ disabled={openaiWaiting}
600
+ className="w-full py-2.5 px-4 bg-white/[0.06] hover:bg-white/[0.09] text-white text-[13px] font-medium rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-60"
601
+ >
602
+ {openaiWaiting ? (
603
+ <><LoaderCircle className="h-3.5 w-3.5 animate-spin opacity-60" />Waiting for sign-in...</>
604
+ ) : (
605
+ <>Authenticate with ChatGPT<ArrowRight className="h-3.5 w-3.5 opacity-60" /></>
606
+ )}
607
+ </button>
608
+
609
+ {openaiWaiting && (
610
+ <button
611
+ onClick={handleOpenAICancel}
612
+ className="w-full py-1.5 text-white/25 text-[11px] hover:text-white/40 transition-colors"
613
+ >
614
+ Cancel
615
+ </button>
616
+ )}
617
+ </>
618
+ )}
619
+ </div>
620
+ )}
621
+
622
+ {/* ── Auth flow: Ollama ── */}
623
+ {provider === 'ollama' && (
624
+ <div className="space-y-2.5">
625
+ <div className="bg-emerald-500/8 border border-emerald-500/15 rounded-lg px-3.5 py-2.5">
626
+ <p className="text-emerald-400/90 text-[12px]">No authentication needed — Ollama runs locally.</p>
627
+ </div>
628
+ <div>
629
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">
630
+ Base URL <span className="text-white/20">(optional)</span>
631
+ </label>
632
+ <input
633
+ type="text"
634
+ value={baseUrl}
635
+ onChange={(e) => setBaseUrl(e.target.value)}
636
+ placeholder="http://localhost:11434"
637
+ className={inputSmCls}
638
+ />
639
+ </div>
640
+ </div>
641
+ )}
642
+
643
+ {/* ── Model dropdown (after auth) ── */}
644
+ {isConnected && (
645
+ <>
646
+ <div className="border-t border-white/[0.06] mt-4 mb-3" />
647
+ <label className="text-[12px] text-white/40 font-medium mb-1.5 block">
648
+ Select a model
649
+ </label>
650
+ <ModelDropdown
651
+ models={MODELS[provider] || []}
652
+ value={model}
653
+ onChange={setModel}
654
+ />
655
+ </>
656
+ )}
657
+
658
+ {/* Continue button */}
659
+ {isConnected && (
660
+ <button
661
+ onClick={next}
662
+ disabled={!canNext}
663
+ className="w-full mt-4 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
664
+ >
665
+ Continue
666
+ <ArrowRight className="h-4 w-4" />
667
+ </button>
668
+ )}
669
+ </div>
670
+ )}
671
+
672
+ {/* ── Step 4: Whisper (optional) ── */}
673
+ {step === 4 && (
674
+ <div>
675
+ <div className="flex items-center gap-2 mb-1">
676
+ <h1 className="text-xl font-bold text-white tracking-tight">
677
+ Voice Messages
678
+ </h1>
679
+ <span className="text-[11px] text-white/25 font-medium bg-white/[0.04] border border-white/[0.06] rounded-full px-2.5 py-0.5">
680
+ Optional
681
+ </span>
682
+ </div>
683
+ <p className="text-white/40 text-[13px] mt-1 leading-relaxed">
684
+ Allow users to send audio messages that are automatically transcribed.
685
+ </p>
686
+
687
+ {/* Whisper card */}
688
+ <button
689
+ onClick={() => setWhisperEnabled((v) => !v)}
690
+ className={`w-full mt-5 rounded-xl border transition-all duration-200 p-4 text-left ${
691
+ whisperEnabled
692
+ ? 'bg-white/[0.04] border-primary/40'
693
+ : 'bg-transparent border-white/[0.06] hover:border-white/10 hover:bg-white/[0.02]'
694
+ }`}
695
+ >
696
+ <div className="flex items-center gap-3.5">
697
+ <img src="/icons/openai.svg" alt="OpenAI" className="w-10 h-10 rounded-xl bg-white/[0.04] p-1.5" />
698
+ <div className="flex-1">
699
+ <div className="text-[14px] font-medium text-white">OpenAI Whisper</div>
700
+ <div className="text-[12px] text-white/35 mt-0.5 leading-relaxed">
701
+ Speech-to-text powered by OpenAI. Requires an OpenAI API key.
702
+ </div>
703
+ </div>
704
+ {/* Toggle */}
705
+ <div className={`w-10 h-[22px] rounded-full transition-colors duration-200 flex items-center px-0.5 shrink-0 ${
706
+ whisperEnabled ? 'bg-primary' : 'bg-white/[0.08]'
707
+ }`}>
708
+ <div className={`w-[18px] h-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ${
709
+ whisperEnabled ? 'translate-x-[18px]' : 'translate-x-0'
710
+ }`} />
711
+ </div>
712
+ </div>
713
+ </button>
714
+
715
+ {whisperEnabled && (
716
+ <div className="mt-3 bg-white/[0.02] border border-white/[0.06] rounded-xl px-4 py-3">
717
+ <div className="flex items-start gap-2.5">
718
+ <Mic className="h-4 w-4 text-primary/60 mt-0.5 shrink-0" />
719
+ <p className="text-white/35 text-[12px] leading-relaxed">
720
+ Users will see a microphone button in the chat. Audio is sent to OpenAI's Whisper API for transcription, then processed as a regular text message.
721
+ </p>
722
+ </div>
723
+ </div>
724
+ )}
725
+
726
+ {/* Actions */}
727
+ <button
728
+ onClick={handleComplete}
729
+ disabled={saving}
730
+ className="w-full mt-5 py-3 bg-primary hover:bg-primary/90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
731
+ >
732
+ {saving ? (
733
+ <><LoaderCircle className="h-4 w-4 animate-spin" />Setting up...</>
734
+ ) : (
735
+ <>Complete Setup<ArrowRight className="h-4 w-4" /></>
736
+ )}
737
+ </button>
738
+
739
+ {!whisperEnabled && (
740
+ <p className="text-center text-white/20 text-[11px] mt-2.5">
741
+ You can enable this later in Settings.
742
+ </p>
743
+ )}
744
+ </div>
745
+ )}
746
+ </motion.div>
747
+ </AnimatePresence>
748
+
749
+ {/* Back button */}
750
+ {step > 0 && (
751
+ <div className="px-8 pb-5 -mt-3">
752
+ <button
753
+ onClick={back}
754
+ className="text-white/25 hover:text-white/50 text-[12px] transition-colors"
755
+ >
756
+ &larr; Back
757
+ </button>
758
+ </div>
759
+ )}
760
+ </motion.div>
761
+ </div>
762
+ );
763
+ }