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.
- package/bin/cli.js +469 -0
- package/client/index.html +13 -0
- package/client/public/fluxy.png +0 -0
- package/client/public/icons/claude.png +0 -0
- package/client/public/icons/codex.png +0 -0
- package/client/public/icons/openai.svg +15 -0
- package/client/src/App.tsx +81 -0
- package/client/src/components/Chat/ChatView.tsx +19 -0
- package/client/src/components/Chat/InputBar.tsx +242 -0
- package/client/src/components/Chat/MessageBubble.tsx +20 -0
- package/client/src/components/Chat/MessageList.tsx +39 -0
- package/client/src/components/Chat/TypingIndicator.tsx +10 -0
- package/client/src/components/Dashboard/ConversationAnalytics.tsx +84 -0
- package/client/src/components/Dashboard/DashboardPage.tsx +52 -0
- package/client/src/components/Dashboard/PromoCard.tsx +44 -0
- package/client/src/components/Dashboard/ReportCard.tsx +35 -0
- package/client/src/components/Dashboard/TodayStats.tsx +28 -0
- package/client/src/components/ErrorBoundary.tsx +23 -0
- package/client/src/components/FluxyFab.tsx +25 -0
- package/client/src/components/Layout/ConnectionStatus.tsx +8 -0
- package/client/src/components/Layout/DashboardHeader.tsx +90 -0
- package/client/src/components/Layout/DashboardLayout.tsx +24 -0
- package/client/src/components/Layout/Header.tsx +10 -0
- package/client/src/components/Layout/MobileNav.tsx +30 -0
- package/client/src/components/Layout/Sidebar.tsx +55 -0
- package/client/src/components/Onboard/OnboardWizard.tsx +763 -0
- package/client/src/components/ui/avatar.tsx +109 -0
- package/client/src/components/ui/badge.tsx +48 -0
- package/client/src/components/ui/button.tsx +64 -0
- package/client/src/components/ui/card.tsx +92 -0
- package/client/src/components/ui/dialog.tsx +156 -0
- package/client/src/components/ui/dropdown-menu.tsx +257 -0
- package/client/src/components/ui/input.tsx +21 -0
- package/client/src/components/ui/scroll-area.tsx +58 -0
- package/client/src/components/ui/select.tsx +190 -0
- package/client/src/components/ui/separator.tsx +28 -0
- package/client/src/components/ui/sheet.tsx +141 -0
- package/client/src/components/ui/skeleton.tsx +13 -0
- package/client/src/components/ui/switch.tsx +33 -0
- package/client/src/components/ui/tabs.tsx +89 -0
- package/client/src/components/ui/textarea.tsx +18 -0
- package/client/src/components/ui/tooltip.tsx +55 -0
- package/client/src/hooks/useChat.ts +69 -0
- package/client/src/hooks/useMobile.ts +16 -0
- package/client/src/hooks/useWebSocket.ts +24 -0
- package/client/src/lib/mock-data.ts +104 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/lib/ws-client.ts +52 -0
- package/client/src/main.tsx +10 -0
- package/client/src/styles/globals.css +55 -0
- package/components.json +20 -0
- package/dist/assets/index-BkNWpS06.css +1 -0
- package/dist/assets/index-CX3QeqQ8.js +64 -0
- package/dist/fluxy.png +0 -0
- package/dist/icons/claude.png +0 -0
- package/dist/icons/codex.png +0 -0
- package/dist/icons/openai.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/manifest.webmanifest +1 -0
- package/dist/registerSW.js +1 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-8c29f6e4.js +1 -0
- package/package.json +82 -0
- package/postcss.config.js +5 -0
- package/shared/ai.ts +141 -0
- package/shared/config.ts +37 -0
- package/shared/logger.ts +13 -0
- package/shared/paths.ts +14 -0
- package/shared/relay.ts +101 -0
- package/supervisor/fluxy.html +94 -0
- package/supervisor/index.ts +173 -0
- package/supervisor/tunnel.ts +62 -0
- package/supervisor/worker.ts +55 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +38 -0
- package/worker/claude-auth.ts +224 -0
- package/worker/codex-auth.ts +199 -0
- package/worker/db.ts +75 -0
- 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
|
+
← Back
|
|
757
|
+
</button>
|
|
758
|
+
</div>
|
|
759
|
+
)}
|
|
760
|
+
</motion.div>
|
|
761
|
+
</div>
|
|
762
|
+
);
|
|
763
|
+
}
|