bloby-bot 0.58.0 → 0.60.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/dist-bloby/assets/{bloby-C9KZKz5D.js → bloby-8GjzRxjC.js} +8 -8
- package/dist-bloby/assets/globals-D-b6XZqk.js +26 -0
- package/dist-bloby/assets/globals-eJ7lScsq.css +2 -0
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-CBsGYiqi.js → highlighted-body-OFNGDK62-DrKKm93B.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-CxqocSKs.js +1 -0
- package/dist-bloby/assets/{onboard-BmDzpzBl.js → onboard-DJNuzfZA.js} +1 -1
- package/dist-bloby/bloby.html +3 -3
- package/dist-bloby/onboard.html +3 -3
- package/package.json +1 -1
- package/supervisor/chat/OnboardWizard.tsx +672 -24
- package/supervisor/index.ts +148 -2
- package/supervisor/scheduler.ts +46 -3
- package/worker/prompts/bloby-system-prompt-codex.txt +3 -1
- package/worker/prompts/bloby-system-prompt-pi.txt +3 -1
- package/worker/prompts/bloby-system-prompt.txt +3 -1
- package/dist-bloby/assets/globals-B2MTxIvu.js +0 -21
- package/dist-bloby/assets/globals-mDbk7EdD.css +0 -2
- package/dist-bloby/assets/mermaid-GHXKKRXX-D1dLke0q.js +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, type KeyboardEvent, type MutableRefObject } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
-
import { ArrowRight, ArrowLeft, LoaderCircle, ExternalLink, ClipboardPaste, RefreshCw, Check, ChevronDown, Mic, Eye, EyeOff, Shield, ShieldCheck, ShieldOff, Copy, Smartphone, Globe, Wifi, WifiOff, TriangleAlert, X, Plus, KeyRound } from 'lucide-react';
|
|
3
|
+
import { ArrowRight, ArrowLeft, LoaderCircle, ExternalLink, ClipboardPaste, RefreshCw, Check, ChevronDown, Mic, Eye, EyeOff, Shield, ShieldCheck, ShieldOff, Copy, Smartphone, Globe, Wifi, WifiOff, TriangleAlert, X, Plus, KeyRound, Activity, Moon, Save, CalendarClock, Clock, Zap, FileText, Pause, Play, Trash2, Download, Info } from 'lucide-react';
|
|
4
4
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
5
5
|
import { authFetch } from './src/lib/auth';
|
|
6
6
|
|
|
@@ -168,6 +168,7 @@ const SETTINGS_SCREENS: { step: number; label: string }[] = [
|
|
|
168
168
|
{ step: 4, label: 'AI Provider' },
|
|
169
169
|
{ step: 5, label: 'Voice Messages' },
|
|
170
170
|
{ step: 6, label: 'Environment Variables' },
|
|
171
|
+
{ step: 7, label: 'Pulse & Crons' },
|
|
171
172
|
];
|
|
172
173
|
|
|
173
174
|
// Compact "Go to" dropdown shown in the settings header so the user can jump to any screen.
|
|
@@ -521,6 +522,524 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
|
|
|
521
522
|
);
|
|
522
523
|
}
|
|
523
524
|
|
|
525
|
+
/* ── Settings mode: Pulse & Crons screen ── */
|
|
526
|
+
|
|
527
|
+
interface PulseConfig {
|
|
528
|
+
enabled: boolean;
|
|
529
|
+
intervalMinutes: number;
|
|
530
|
+
quietHours: { start: string; end: string };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
interface CronView {
|
|
534
|
+
id: string;
|
|
535
|
+
schedule: string;
|
|
536
|
+
task: string;
|
|
537
|
+
enabled: boolean;
|
|
538
|
+
oneShot?: boolean;
|
|
539
|
+
paused?: boolean;
|
|
540
|
+
hasTaskFile: boolean;
|
|
541
|
+
nextRun: string | null; // ISO
|
|
542
|
+
description: string; // humanized schedule
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
interface PulseCronCache { pulse: PulseConfig; original: PulseConfig | null; crons: CronView[] }
|
|
546
|
+
|
|
547
|
+
const DEFAULT_PULSE: PulseConfig = { enabled: false, intervalMinutes: 60, quietHours: { start: '22:00', end: '07:00' } };
|
|
548
|
+
|
|
549
|
+
// 8 slider stops; the last (-1) is the "Custom" sentinel.
|
|
550
|
+
const PULSE_STOPS: { mins: number; label: string }[] = [
|
|
551
|
+
{ mins: 10, label: '10m' }, { mins: 30, label: '30m' }, { mins: 60, label: '1h' },
|
|
552
|
+
{ mins: 120, label: '2h' }, { mins: 180, label: '3h' }, { mins: 360, label: '6h' },
|
|
553
|
+
{ mins: 720, label: '12h' }, { mins: -1, label: 'Custom' },
|
|
554
|
+
];
|
|
555
|
+
const PULSE_CUSTOM_INDEX = PULSE_STOPS.length - 1;
|
|
556
|
+
const PULSE_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
557
|
+
|
|
558
|
+
function pulseIndexForMinutes(mins: number): number {
|
|
559
|
+
const i = PULSE_STOPS.findIndex((s) => s.mins === mins);
|
|
560
|
+
return i === -1 ? PULSE_CUSTOM_INDEX : i;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function humanizeInterval(mins: number): string {
|
|
564
|
+
if (!Number.isFinite(mins) || mins <= 0) return 'Every —';
|
|
565
|
+
if (mins % 60 === 0) { const h = mins / 60; return `Every ${h} hour${h === 1 ? '' : 's'}`; }
|
|
566
|
+
if (mins < 60) return `Every ${mins} minute${mins === 1 ? '' : 's'}`;
|
|
567
|
+
return `Every ${Math.floor(mins / 60)}h ${mins % 60}m`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// nextRun ISO → short human string ("in 12m", "Today 3:00 PM", "Tomorrow 9:00 AM", "Jun 9, 3:00 PM").
|
|
571
|
+
function formatNextRun(iso: string | null): string | null {
|
|
572
|
+
if (!iso) return null;
|
|
573
|
+
const d = new Date(iso);
|
|
574
|
+
if (isNaN(d.getTime())) return null;
|
|
575
|
+
const now = new Date();
|
|
576
|
+
const diffMs = d.getTime() - now.getTime();
|
|
577
|
+
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
578
|
+
if (diffMs > 0 && diffMs < 60 * 60 * 1000) return `in ${Math.max(1, Math.round(diffMs / 60000))}m`;
|
|
579
|
+
if (diffMs > 0 && diffMs < 6 * 60 * 60 * 1000) return `in ${Math.round(diffMs / 3_600_000)}h`;
|
|
580
|
+
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
|
|
581
|
+
const dayDiff = Math.round((startOfDay(d).getTime() - startOfDay(now).getTime()) / 86_400_000);
|
|
582
|
+
if (dayDiff === 0) return `Today ${time}`;
|
|
583
|
+
if (dayDiff === 1) return `Tomorrow ${time}`;
|
|
584
|
+
if (dayDiff > 1 && dayDiff < 7) return `${d.toLocaleDateString([], { weekday: 'long' })} ${time}`;
|
|
585
|
+
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${time}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function PulseSection({ pulse, original, setPulse, onPulseSaved }: {
|
|
589
|
+
pulse: PulseConfig;
|
|
590
|
+
original: PulseConfig | null;
|
|
591
|
+
setPulse: (next: PulseConfig) => void;
|
|
592
|
+
onPulseSaved: (saved: PulseConfig) => void;
|
|
593
|
+
}) {
|
|
594
|
+
const [saving, setSaving] = useState(false);
|
|
595
|
+
const [saved, setSaved] = useState(false);
|
|
596
|
+
const [saveError, setSaveError] = useState('');
|
|
597
|
+
// Explicit "Custom" mode — NOT derived from the value, otherwise typing a value that happens to
|
|
598
|
+
// equal a preset (e.g. 60) would snap the slider and unmount the input mid-keystroke. Seeded from
|
|
599
|
+
// whether the loaded interval is a non-preset value.
|
|
600
|
+
const [customMode, setCustomMode] = useState(pulseIndexForMinutes(pulse.intervalMinutes) === PULSE_CUSTOM_INDEX);
|
|
601
|
+
|
|
602
|
+
const presetIdx = pulseIndexForMinutes(pulse.intervalMinutes);
|
|
603
|
+
const onCustom = customMode;
|
|
604
|
+
const sliderIdx = customMode ? PULSE_CUSTOM_INDEX : presetIdx;
|
|
605
|
+
const quietValid = PULSE_TIME_RE.test(pulse.quietHours.start) && PULSE_TIME_RE.test(pulse.quietHours.end);
|
|
606
|
+
const intervalValid = Number.isFinite(pulse.intervalMinutes) && pulse.intervalMinutes >= 1 && pulse.intervalMinutes <= 1440;
|
|
607
|
+
const dirty = original != null && (
|
|
608
|
+
pulse.enabled !== original.enabled ||
|
|
609
|
+
pulse.intervalMinutes !== original.intervalMinutes ||
|
|
610
|
+
pulse.quietHours.start !== original.quietHours.start ||
|
|
611
|
+
pulse.quietHours.end !== original.quietHours.end
|
|
612
|
+
);
|
|
613
|
+
const canSave = dirty && intervalValid && quietValid && !saving;
|
|
614
|
+
|
|
615
|
+
const patch = (p: Partial<PulseConfig>) => {
|
|
616
|
+
if (saved) setSaved(false);
|
|
617
|
+
if (saveError) setSaveError('');
|
|
618
|
+
setPulse({ ...pulse, ...p });
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const onSlider = (idx: number) => {
|
|
622
|
+
if (idx === PULSE_CUSTOM_INDEX) {
|
|
623
|
+
setCustomMode(true);
|
|
624
|
+
// Keep the current value when already custom; seed 45 only when entering from a preset.
|
|
625
|
+
const seed = (customMode || presetIdx === PULSE_CUSTOM_INDEX) ? pulse.intervalMinutes : 45;
|
|
626
|
+
patch({ intervalMinutes: seed });
|
|
627
|
+
} else {
|
|
628
|
+
setCustomMode(false);
|
|
629
|
+
patch({ intervalMinutes: PULSE_STOPS[idx].mins });
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const handleSave = async () => {
|
|
634
|
+
if (!canSave) return;
|
|
635
|
+
setSaving(true); setSaveError(''); setSaved(false);
|
|
636
|
+
try {
|
|
637
|
+
const res = await authFetch('/api/pulse', {
|
|
638
|
+
method: 'POST',
|
|
639
|
+
headers: { 'Content-Type': 'application/json' },
|
|
640
|
+
body: JSON.stringify({ enabled: pulse.enabled, intervalMinutes: pulse.intervalMinutes, quietHours: pulse.quietHours }),
|
|
641
|
+
});
|
|
642
|
+
if (!res.ok) { const d = await res.json().catch(() => ({ error: 'Save failed' })); throw new Error(d.error || 'Save failed'); }
|
|
643
|
+
onPulseSaved(pulse);
|
|
644
|
+
setSaved(true);
|
|
645
|
+
setSaving(false);
|
|
646
|
+
} catch (err: any) {
|
|
647
|
+
setSaveError(err?.message || 'Save failed');
|
|
648
|
+
setSaving(false);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
return (
|
|
653
|
+
<div>
|
|
654
|
+
<div className="flex items-center gap-3">
|
|
655
|
+
<div className="w-9 h-9 rounded-xl bg-[#0069FE]/10 flex items-center justify-center shrink-0">
|
|
656
|
+
<Activity className="h-[18px] w-[18px] text-[#0069FE]" />
|
|
657
|
+
</div>
|
|
658
|
+
<div className="flex-1 min-w-0">
|
|
659
|
+
<h2 className="text-[15px] font-bold text-white tracking-tight">Pulse</h2>
|
|
660
|
+
</div>
|
|
661
|
+
<button
|
|
662
|
+
type="button" role="switch" aria-checked={pulse.enabled} aria-label="Enable pulse"
|
|
663
|
+
onClick={() => patch({ enabled: !pulse.enabled })}
|
|
664
|
+
className={`w-10 h-[22px] rounded-full transition-colors duration-200 flex items-center px-0.5 shrink-0 ${pulse.enabled ? 'bg-gradient-brand' : 'bg-white/[0.08]'}`}
|
|
665
|
+
>
|
|
666
|
+
<div className={`w-[18px] h-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ${pulse.enabled ? 'translate-x-[18px]' : 'translate-x-0'}`} />
|
|
667
|
+
</button>
|
|
668
|
+
</div>
|
|
669
|
+
|
|
670
|
+
<p className="text-white/40 text-[13px] mt-2 leading-relaxed">
|
|
671
|
+
Pulse periodically wakes the agent inside the main session, letting it surface anything that needs attention without spamming you.
|
|
672
|
+
</p>
|
|
673
|
+
|
|
674
|
+
<div className={`mt-5 transition-opacity duration-200 ${pulse.enabled ? '' : 'opacity-50 pointer-events-none select-none'}`} aria-disabled={!pulse.enabled}>
|
|
675
|
+
<div className="flex items-center justify-between mb-2.5">
|
|
676
|
+
<span className="text-[12px] font-semibold text-white/70 uppercase tracking-wide">Frequency</span>
|
|
677
|
+
<span className="text-[11px] font-medium text-[#0069FE] bg-[#0069FE]/10 rounded-full px-2.5 py-1">
|
|
678
|
+
{onCustom ? 'Custom interval' : humanizeInterval(pulse.intervalMinutes)}
|
|
679
|
+
</span>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
<input
|
|
683
|
+
type="range" min={0} max={PULSE_CUSTOM_INDEX} step={1} value={sliderIdx}
|
|
684
|
+
onChange={(e) => onSlider(Number(e.target.value))} aria-label="Pulse interval"
|
|
685
|
+
className="w-full h-1.5 appearance-none rounded-full bg-white/[0.08] accent-[#0069FE] cursor-pointer
|
|
686
|
+
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4
|
|
687
|
+
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
|
|
688
|
+
[&::-webkit-slider-thumb]:shadow-[0_0_0_3px_rgba(0,105,254,0.35)] [&::-webkit-slider-thumb]:cursor-pointer
|
|
689
|
+
[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full
|
|
690
|
+
[&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-[0_0_0_3px_rgba(0,105,254,0.35)]"
|
|
691
|
+
/>
|
|
692
|
+
|
|
693
|
+
<div className="flex justify-between mt-2 px-[1px]">
|
|
694
|
+
{PULSE_STOPS.map((s, i) => (
|
|
695
|
+
<button
|
|
696
|
+
key={s.label} type="button" onClick={() => onSlider(i)}
|
|
697
|
+
className={`text-[10px] leading-none transition-colors ${i === sliderIdx ? 'text-[#0069FE] font-semibold' : 'text-white/30 hover:text-white/55'}`}
|
|
698
|
+
>
|
|
699
|
+
{s.label}
|
|
700
|
+
</button>
|
|
701
|
+
))}
|
|
702
|
+
</div>
|
|
703
|
+
|
|
704
|
+
{onCustom && (
|
|
705
|
+
<div className="mt-3 flex items-center gap-2.5">
|
|
706
|
+
<input
|
|
707
|
+
type="number" min={1} max={1440}
|
|
708
|
+
value={Number.isFinite(pulse.intervalMinutes) ? pulse.intervalMinutes : ''}
|
|
709
|
+
onChange={(e) => { const n = parseInt(e.target.value, 10); patch({ intervalMinutes: Number.isFinite(n) ? n : NaN }); }}
|
|
710
|
+
placeholder="45"
|
|
711
|
+
className="w-28 bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none focus:border-[#0069FE]/30 transition-colors placeholder:text-white/20 font-mono"
|
|
712
|
+
/>
|
|
713
|
+
<span className="text-[12px] text-white/40">minutes between pulses</span>
|
|
714
|
+
</div>
|
|
715
|
+
)}
|
|
716
|
+
{onCustom && !intervalValid && (
|
|
717
|
+
<p className="text-amber-400/80 text-[11px] mt-1.5">Enter a whole number of minutes (1–1440).</p>
|
|
718
|
+
)}
|
|
719
|
+
|
|
720
|
+
<div className="flex items-center gap-2 mt-6 mb-2.5">
|
|
721
|
+
<Moon className="h-3.5 w-3.5 text-[#0069FE]/70" />
|
|
722
|
+
<span className="text-[12px] font-semibold text-white/70 uppercase tracking-wide">Quiet hours</span>
|
|
723
|
+
</div>
|
|
724
|
+
<div className="flex items-center gap-3">
|
|
725
|
+
<div className="flex-1">
|
|
726
|
+
<label className="text-[11px] text-white/40 mb-1 block">Start</label>
|
|
727
|
+
<input
|
|
728
|
+
type="time" value={pulse.quietHours.start}
|
|
729
|
+
onChange={(e) => patch({ quietHours: { ...pulse.quietHours, start: e.target.value } })}
|
|
730
|
+
className="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-[#0069FE]/30 transition-colors [color-scheme:dark]"
|
|
731
|
+
/>
|
|
732
|
+
</div>
|
|
733
|
+
<ArrowRight className="h-4 w-4 text-white/25 mt-5 shrink-0" />
|
|
734
|
+
<div className="flex-1">
|
|
735
|
+
<label className="text-[11px] text-white/40 mb-1 block">End</label>
|
|
736
|
+
<input
|
|
737
|
+
type="time" value={pulse.quietHours.end}
|
|
738
|
+
onChange={(e) => patch({ quietHours: { ...pulse.quietHours, end: e.target.value } })}
|
|
739
|
+
className="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-[#0069FE]/30 transition-colors [color-scheme:dark]"
|
|
740
|
+
/>
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
<p className="text-white/35 text-[12px] mt-2 leading-relaxed">
|
|
744
|
+
No pulses fire between these times (your computer's local time). Spanning midnight is fine — e.g. 22:00 → 07:00.
|
|
745
|
+
</p>
|
|
746
|
+
</div>
|
|
747
|
+
|
|
748
|
+
{saved && !dirty && (
|
|
749
|
+
<div className="mt-4 flex items-center gap-2.5 bg-emerald-500/[0.06] border border-emerald-500/20 rounded-xl px-4 py-3">
|
|
750
|
+
<Check className="h-4 w-4 text-emerald-400 shrink-0" />
|
|
751
|
+
<p className="text-emerald-300/90 text-[12px]">Saved — the scheduler picks this up within a minute.</p>
|
|
752
|
+
</div>
|
|
753
|
+
)}
|
|
754
|
+
{saveError && (
|
|
755
|
+
<div className="mt-3 flex items-center gap-1.5 text-[12px] text-red-400">
|
|
756
|
+
<TriangleAlert className="h-3.5 w-3.5 shrink-0" />{saveError}
|
|
757
|
+
</div>
|
|
758
|
+
)}
|
|
759
|
+
{dirty && (
|
|
760
|
+
<button
|
|
761
|
+
type="button" onClick={handleSave} disabled={!canSave}
|
|
762
|
+
className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
763
|
+
>
|
|
764
|
+
{saving ? (<><LoaderCircle className="h-4 w-4 animate-spin" />Saving...</>) : (<><Save className="h-4 w-4" />Save pulse settings</>)}
|
|
765
|
+
</button>
|
|
766
|
+
)}
|
|
767
|
+
</div>
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function CronsSection({ crons, onChanged, onLocalUpdate }: {
|
|
772
|
+
crons: CronView[];
|
|
773
|
+
onChanged: () => void;
|
|
774
|
+
onLocalUpdate: (fn: (prev: CronView[]) => CronView[]) => void;
|
|
775
|
+
}) {
|
|
776
|
+
const [busy, setBusy] = useState<Record<string, 'pause' | 'delete' | undefined>>({});
|
|
777
|
+
const [confirmId, setConfirmId] = useState<string | null>(null);
|
|
778
|
+
const [downloading, setDownloading] = useState<Record<string, boolean>>({});
|
|
779
|
+
const [actionError, setActionError] = useState('');
|
|
780
|
+
|
|
781
|
+
const setRowBusy = (id: string, v: 'pause' | 'delete' | undefined) => setBusy((p) => ({ ...p, [id]: v }));
|
|
782
|
+
|
|
783
|
+
const handleTogglePause = async (c: CronView) => {
|
|
784
|
+
if (busy[c.id]) return;
|
|
785
|
+
setActionError(''); setRowBusy(c.id, 'pause');
|
|
786
|
+
try {
|
|
787
|
+
const res = await authFetch('/api/crons/pause', {
|
|
788
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
789
|
+
body: JSON.stringify({ id: c.id, paused: !c.paused }),
|
|
790
|
+
});
|
|
791
|
+
if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'Failed to update'); }
|
|
792
|
+
// Optimistic: reflect the change immediately so a failed background refetch can't leave a
|
|
793
|
+
// stale row; onChanged() then reconciles against the on-disk truth.
|
|
794
|
+
onLocalUpdate((prev) => prev.map((x) => (x.id === c.id ? { ...x, paused: !c.paused } : x)));
|
|
795
|
+
onChanged();
|
|
796
|
+
} catch (err: any) {
|
|
797
|
+
setActionError(err.message || 'Failed to update');
|
|
798
|
+
} finally {
|
|
799
|
+
setRowBusy(c.id, undefined);
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const handleDelete = async (c: CronView) => {
|
|
804
|
+
if (busy[c.id]) return;
|
|
805
|
+
setActionError(''); setRowBusy(c.id, 'delete');
|
|
806
|
+
try {
|
|
807
|
+
const res = await authFetch('/api/crons/delete', {
|
|
808
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
809
|
+
body: JSON.stringify({ id: c.id }),
|
|
810
|
+
});
|
|
811
|
+
if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'Failed to delete'); }
|
|
812
|
+
setConfirmId(null);
|
|
813
|
+
onLocalUpdate((prev) => prev.filter((x) => x.id !== c.id));
|
|
814
|
+
onChanged();
|
|
815
|
+
} catch (err: any) {
|
|
816
|
+
setActionError(err.message || 'Failed to delete');
|
|
817
|
+
} finally {
|
|
818
|
+
setRowBusy(c.id, undefined);
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// Authed download: the endpoint requires a Bearer token, so a plain <a href> would 401.
|
|
823
|
+
const handleDownloadTask = async (c: CronView) => {
|
|
824
|
+
if (downloading[c.id]) return;
|
|
825
|
+
setActionError(''); setDownloading((p) => ({ ...p, [c.id]: true }));
|
|
826
|
+
try {
|
|
827
|
+
const res = await authFetch(`/api/crons/task?id=${encodeURIComponent(c.id)}`);
|
|
828
|
+
if (!res.ok) throw new Error('Could not download task file');
|
|
829
|
+
const blob = await res.blob();
|
|
830
|
+
const url = URL.createObjectURL(blob);
|
|
831
|
+
const a = document.createElement('a');
|
|
832
|
+
a.href = url; a.download = `${c.id}.md`;
|
|
833
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
834
|
+
URL.revokeObjectURL(url);
|
|
835
|
+
} catch (err: any) {
|
|
836
|
+
setActionError(err.message || 'Download failed');
|
|
837
|
+
} finally {
|
|
838
|
+
setDownloading((p) => ({ ...p, [c.id]: false }));
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
return (
|
|
843
|
+
<div className="mt-8">
|
|
844
|
+
<div className="flex items-center gap-2">
|
|
845
|
+
<CalendarClock className="h-4 w-4 text-[#0069FE]/70 shrink-0" />
|
|
846
|
+
<h2 className="text-[15px] font-bold text-white tracking-tight">Crons</h2>
|
|
847
|
+
</div>
|
|
848
|
+
<p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
|
|
849
|
+
Crons let your agent schedule tasks inside the main session, running them daily, hourly, or on any schedule you choose. They can also be one-shot tasks that run once at a specific time.
|
|
850
|
+
</p>
|
|
851
|
+
<div className="mt-2 flex items-start gap-1.5 text-white/30 text-[12px] leading-relaxed">
|
|
852
|
+
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
|
853
|
+
<span>To change or add a cron, just ask your agent. To delete or pause, use the buttons below.</span>
|
|
854
|
+
</div>
|
|
855
|
+
|
|
856
|
+
{actionError && (
|
|
857
|
+
<div className="mt-3 flex items-center gap-1.5 text-[12px] text-red-400">
|
|
858
|
+
<TriangleAlert className="h-3.5 w-3.5 shrink-0" />{actionError}
|
|
859
|
+
</div>
|
|
860
|
+
)}
|
|
861
|
+
|
|
862
|
+
<div className="mt-4 space-y-2.5">
|
|
863
|
+
{crons.length === 0 ? (
|
|
864
|
+
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-7 text-center">
|
|
865
|
+
<CalendarClock className="h-5 w-5 text-white/25 mx-auto mb-2" />
|
|
866
|
+
<p className="text-white/40 text-[13px]">No scheduled tasks yet.</p>
|
|
867
|
+
<p className="text-white/25 text-[12px] mt-1">Ask your agent to schedule something.</p>
|
|
868
|
+
</div>
|
|
869
|
+
) : (
|
|
870
|
+
crons.map((c) => {
|
|
871
|
+
const paused = !!c.paused;
|
|
872
|
+
const disabled = c.enabled === false;
|
|
873
|
+
const parked = paused || disabled;
|
|
874
|
+
const rowBusy = busy[c.id];
|
|
875
|
+
const nextStr = formatNextRun(c.nextRun);
|
|
876
|
+
return (
|
|
877
|
+
<div key={c.id} className={`rounded-xl border p-3.5 transition-colors ${parked ? 'border-white/[0.05] bg-white/[0.015] opacity-60' : 'border-white/[0.06] bg-white/[0.02]'}`}>
|
|
878
|
+
<div className="flex items-start gap-3">
|
|
879
|
+
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${c.oneShot ? 'bg-amber-500/10' : 'bg-[#0069FE]/10'}`}>
|
|
880
|
+
{c.oneShot ? <Zap className="h-[18px] w-[18px] text-amber-400" /> : <Clock className="h-[18px] w-[18px] text-[#0069FE]" />}
|
|
881
|
+
</div>
|
|
882
|
+
<div className="flex-1 min-w-0">
|
|
883
|
+
<div className="flex items-start justify-between gap-2">
|
|
884
|
+
<p className="text-[13px] text-white leading-snug">{c.task}</p>
|
|
885
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
886
|
+
{c.oneShot && (
|
|
887
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-300/90 text-[10px] font-medium px-2 py-0.5">
|
|
888
|
+
<Zap className="h-2.5 w-2.5" />One-time
|
|
889
|
+
</span>
|
|
890
|
+
)}
|
|
891
|
+
{paused && (
|
|
892
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 text-[10px] font-medium px-2 py-0.5">
|
|
893
|
+
<Pause className="h-2.5 w-2.5" />Paused
|
|
894
|
+
</span>
|
|
895
|
+
)}
|
|
896
|
+
{disabled && !paused && (
|
|
897
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 text-[10px] font-medium px-2 py-0.5">
|
|
898
|
+
Disabled
|
|
899
|
+
</span>
|
|
900
|
+
)}
|
|
901
|
+
</div>
|
|
902
|
+
</div>
|
|
903
|
+
|
|
904
|
+
<div className="mt-1.5 flex items-center gap-1.5 text-[12px] text-white/45">
|
|
905
|
+
<Clock className="h-3 w-3 shrink-0 text-white/30" />
|
|
906
|
+
<span className="truncate">{c.description}</span>
|
|
907
|
+
</div>
|
|
908
|
+
|
|
909
|
+
<div className="mt-1.5">
|
|
910
|
+
{parked ? (
|
|
911
|
+
<span className="text-[11px] text-white/30">{paused ? "Paused — won't run" : "Disabled — won't run"}</span>
|
|
912
|
+
) : nextStr ? (
|
|
913
|
+
<span className="inline-flex items-center gap-1 rounded-md bg-[#0069FE]/[0.08] border border-[#0069FE]/15 text-[#4AA8FF] text-[11px] font-medium px-2 py-0.5">
|
|
914
|
+
Next: {nextStr}
|
|
915
|
+
</span>
|
|
916
|
+
) : (
|
|
917
|
+
<span className="text-[11px] text-white/30">No upcoming run</span>
|
|
918
|
+
)}
|
|
919
|
+
</div>
|
|
920
|
+
</div>
|
|
921
|
+
</div>
|
|
922
|
+
|
|
923
|
+
<div className="mt-3 flex items-center justify-between gap-2">
|
|
924
|
+
{c.hasTaskFile ? (
|
|
925
|
+
<button
|
|
926
|
+
type="button" onClick={() => handleDownloadTask(c)} disabled={downloading[c.id]}
|
|
927
|
+
className="group inline-flex items-center gap-1.5 rounded-md bg-white/[0.04] hover:bg-white/[0.07] border border-white/[0.06] hover:border-white/[0.12] text-white/50 hover:text-white/80 text-[11px] font-mono px-2 py-1 transition-colors disabled:opacity-50"
|
|
928
|
+
title="Download task instructions"
|
|
929
|
+
>
|
|
930
|
+
{downloading[c.id] ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <FileText className="h-3 w-3 shrink-0" />}
|
|
931
|
+
{c.id}.md
|
|
932
|
+
<Download className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
933
|
+
</button>
|
|
934
|
+
) : (<span />)}
|
|
935
|
+
|
|
936
|
+
{confirmId === c.id ? (
|
|
937
|
+
<div className="flex items-center gap-1.5">
|
|
938
|
+
<span className="text-[11px] text-white/50 mr-0.5">Delete?</span>
|
|
939
|
+
<button
|
|
940
|
+
type="button" onClick={() => handleDelete(c)} disabled={rowBusy === 'delete'}
|
|
941
|
+
className="inline-flex items-center gap-1 rounded-md bg-red-500/15 hover:bg-red-500/25 border border-red-500/30 text-red-300 text-[11px] font-medium px-2.5 py-1 transition-colors disabled:opacity-50"
|
|
942
|
+
>
|
|
943
|
+
{rowBusy === 'delete' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
|
944
|
+
Delete
|
|
945
|
+
</button>
|
|
946
|
+
<button
|
|
947
|
+
type="button" onClick={() => setConfirmId(null)} disabled={rowBusy === 'delete'}
|
|
948
|
+
className="rounded-md text-white/40 hover:text-white/70 text-[11px] px-2 py-1 transition-colors"
|
|
949
|
+
>
|
|
950
|
+
Cancel
|
|
951
|
+
</button>
|
|
952
|
+
</div>
|
|
953
|
+
) : (
|
|
954
|
+
<div className="flex items-center gap-1">
|
|
955
|
+
<button
|
|
956
|
+
type="button" onClick={() => handleTogglePause(c)} disabled={!!rowBusy}
|
|
957
|
+
className="inline-flex items-center gap-1 rounded-md text-white/45 hover:text-white/80 hover:bg-white/[0.04] text-[11px] font-medium px-2 py-1 transition-colors disabled:opacity-50"
|
|
958
|
+
>
|
|
959
|
+
{rowBusy === 'pause' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
|
|
960
|
+
{paused ? 'Resume' : 'Pause'}
|
|
961
|
+
</button>
|
|
962
|
+
<button
|
|
963
|
+
type="button" onClick={() => setConfirmId(c.id)} disabled={!!rowBusy}
|
|
964
|
+
className="inline-flex items-center gap-1 rounded-md text-white/30 hover:text-red-400 hover:bg-red-500/[0.06] text-[11px] font-medium px-2 py-1 transition-colors disabled:opacity-50"
|
|
965
|
+
>
|
|
966
|
+
<Trash2 className="h-3 w-3" />Delete
|
|
967
|
+
</button>
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
);
|
|
973
|
+
})
|
|
974
|
+
)}
|
|
975
|
+
</div>
|
|
976
|
+
</div>
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Settings-only screen (step 7). Owns the single GET /api/schedule load; caches working state in a
|
|
981
|
+
// parent ref so edits survive the per-step remount (the wizard keys each step's motion.div by step).
|
|
982
|
+
function PulseCronSettings({ cacheRef }: { cacheRef: MutableRefObject<PulseCronCache | null> }) {
|
|
983
|
+
const hadCache = useRef(cacheRef.current != null).current;
|
|
984
|
+
const cached = cacheRef.current;
|
|
985
|
+
const [loading, setLoading] = useState(!hadCache);
|
|
986
|
+
const [loadError, setLoadError] = useState('');
|
|
987
|
+
const [pulse, setPulse] = useState<PulseConfig>(cached?.pulse ?? DEFAULT_PULSE);
|
|
988
|
+
const [originalPulse, setOriginalPulse] = useState<PulseConfig | null>(cached?.original ?? null);
|
|
989
|
+
const [crons, setCrons] = useState<CronView[]>(cached?.crons ?? []);
|
|
990
|
+
|
|
991
|
+
const fetchSchedule = async (withPulse: boolean) => {
|
|
992
|
+
const res = await authFetch('/api/schedule');
|
|
993
|
+
if (!res.ok) throw new Error('Failed to load schedule');
|
|
994
|
+
const d = await res.json();
|
|
995
|
+
setCrons(Array.isArray(d.crons) ? d.crons : []);
|
|
996
|
+
if (withPulse) {
|
|
997
|
+
const p: PulseConfig = {
|
|
998
|
+
enabled: !!d.pulse?.enabled,
|
|
999
|
+
intervalMinutes: Number(d.pulse?.intervalMinutes) || 60,
|
|
1000
|
+
quietHours: { start: d.pulse?.quietHours?.start ?? '22:00', end: d.pulse?.quietHours?.end ?? '07:00' },
|
|
1001
|
+
};
|
|
1002
|
+
setPulse(p);
|
|
1003
|
+
setOriginalPulse(p);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
useEffect(() => {
|
|
1008
|
+
if (hadCache) return;
|
|
1009
|
+
let cancelled = false;
|
|
1010
|
+
fetchSchedule(true)
|
|
1011
|
+
.then(() => { if (!cancelled) setLoading(false); })
|
|
1012
|
+
.catch((err) => { if (!cancelled) { setLoadError(err.message || 'Failed to load'); setLoading(false); } });
|
|
1013
|
+
return () => { cancelled = true; };
|
|
1014
|
+
}, [hadCache]);
|
|
1015
|
+
|
|
1016
|
+
// Persist working state so navigating away and back doesn't lose pulse edits.
|
|
1017
|
+
useEffect(() => {
|
|
1018
|
+
if (loading) return;
|
|
1019
|
+
cacheRef.current = { pulse, original: originalPulse, crons };
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
return (
|
|
1023
|
+
<div>
|
|
1024
|
+
<h1 className="text-xl font-bold text-white tracking-tight">Pulse & Crons</h1>
|
|
1025
|
+
<p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
|
|
1026
|
+
Background wake-ups and scheduled tasks your agent runs on its own.
|
|
1027
|
+
</p>
|
|
1028
|
+
|
|
1029
|
+
{loading ? (
|
|
1030
|
+
<div className="flex items-center justify-center py-12 text-white/40"><LoaderCircle className="h-5 w-5 animate-spin" /></div>
|
|
1031
|
+
) : loadError ? (
|
|
1032
|
+
<div className="mt-5 flex items-center gap-2 text-[13px] text-red-400"><TriangleAlert className="h-4 w-4 shrink-0" />{loadError}</div>
|
|
1033
|
+
) : (
|
|
1034
|
+
<div className="mt-6 max-h-[50vh] overflow-y-auto pr-1 -mr-1">
|
|
1035
|
+
<PulseSection pulse={pulse} original={originalPulse} setPulse={setPulse} onPulseSaved={(saved) => setOriginalPulse(saved)} />
|
|
1036
|
+
<CronsSection crons={crons} onLocalUpdate={(fn) => setCrons(fn)} onChanged={() => { fetchSchedule(false).catch(() => {}); }} />
|
|
1037
|
+
</div>
|
|
1038
|
+
)}
|
|
1039
|
+
</div>
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
524
1043
|
/* ── Component ── */
|
|
525
1044
|
|
|
526
1045
|
interface Props {
|
|
@@ -686,6 +1205,17 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
686
1205
|
// Environment Variables screen working-state cache (settings mode). Survives the per-step
|
|
687
1206
|
// remount so edits aren't lost when navigating between settings screens; dies with the wizard.
|
|
688
1207
|
const envCacheRef = useRef<EnvCache | null>(null);
|
|
1208
|
+
// Same pattern for the Pulse & Crons screen (step 7).
|
|
1209
|
+
const pulseCacheRef = useRef<PulseCronCache | null>(null);
|
|
1210
|
+
|
|
1211
|
+
// Settings mode (hub-and-spoke): each screen saves itself. `settingsOrig` is the last-saved
|
|
1212
|
+
// snapshot used both to show a Save button only when a screen actually changed, and to build a
|
|
1213
|
+
// payload that touches only the active screen's fields (the worker's /api/onboard is a full
|
|
1214
|
+
// overwrite, so other screens' fields must be sent at their saved values).
|
|
1215
|
+
const [settingsOrig, setSettingsOrig] = useState({ userName: '', botName: '', provider: '', model: '', whisperEnabled: false, whisperKey: '' });
|
|
1216
|
+
const [settingsSaving, setSettingsSaving] = useState(false);
|
|
1217
|
+
const [settingsSaved, setSettingsSaved] = useState(false);
|
|
1218
|
+
const [settingsSaveError, setSettingsSaveError] = useState('');
|
|
689
1219
|
|
|
690
1220
|
const isConnected = authState[provider] === 'connected';
|
|
691
1221
|
|
|
@@ -737,6 +1267,9 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
737
1267
|
setExistingHandle({ username: data.handle.username, tier: data.handle.tier, url: data.handle.url });
|
|
738
1268
|
setRegistered(true);
|
|
739
1269
|
setRegisteredUrl(data.handle.url);
|
|
1270
|
+
} else if (data.agentName) {
|
|
1271
|
+
// Private-network mode has no handle — prefill the agent name so it isn't blank.
|
|
1272
|
+
setBotName(data.agentName);
|
|
740
1273
|
}
|
|
741
1274
|
if (data.portalUser) setPortalUser(data.portalUser);
|
|
742
1275
|
if (data.portalConfigured) setPortalExists(true);
|
|
@@ -749,6 +1282,16 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
749
1282
|
if (data.tunnelUrl) setTunnelUrl(data.tunnelUrl);
|
|
750
1283
|
// If user has existing handle, default to 'relay'; otherwise default to 'tunnel'
|
|
751
1284
|
if (!data.handle) setHandleChoice('tunnel');
|
|
1285
|
+
// Snapshot the saved values so settings-mode per-screen Save buttons can detect changes
|
|
1286
|
+
// and so a single-screen save never clobbers another screen's saved value.
|
|
1287
|
+
setSettingsOrig({
|
|
1288
|
+
userName: data.userName || '',
|
|
1289
|
+
botName: (data.handle && data.handle.username) || data.agentName || '',
|
|
1290
|
+
provider: data.provider || '',
|
|
1291
|
+
model: data.model || '',
|
|
1292
|
+
whisperEnabled: !!data.whisperEnabled,
|
|
1293
|
+
whisperKey: data.whisperKey || '',
|
|
1294
|
+
});
|
|
752
1295
|
prefillDone.current = true;
|
|
753
1296
|
|
|
754
1297
|
// Restore TOTP setup state if returning from authenticator app
|
|
@@ -1316,18 +1859,88 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1316
1859
|
}
|
|
1317
1860
|
case 4: return !!(provider && model && isConnected);
|
|
1318
1861
|
case 5: return true;
|
|
1319
|
-
case 6: return true; // Environment Variables (settings) — self-saving
|
|
1862
|
+
case 6: return true; // Environment Variables (settings) — self-saving
|
|
1863
|
+
case 7: return true; // Pulse & Crons (settings) — self-saving
|
|
1320
1864
|
default: return false;
|
|
1321
1865
|
}
|
|
1322
1866
|
})();
|
|
1323
1867
|
|
|
1324
|
-
|
|
1868
|
+
// Sequential advance is onboarding-only. Settings mode is hub-and-spoke: the user navigates via
|
|
1869
|
+
// the header dropdown / "Go to" menu and each screen saves itself, so there is no "Continue".
|
|
1870
|
+
const next = () => { if (isInitialSetup && canNext && step < TOTAL_STEPS - 1) setStep((s) => s + 1); };
|
|
1325
1871
|
const back = () => { if (step > 0) setStep((s) => s - 1); };
|
|
1326
1872
|
|
|
1327
1873
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1328
1874
|
if (e.key === 'Enter' && canNext) next();
|
|
1329
1875
|
};
|
|
1330
1876
|
|
|
1877
|
+
// ── Settings mode: per-screen change detection + save ──
|
|
1878
|
+
// Only the currently-shown settings screen reports "dirty"; the footer Save appears only then.
|
|
1879
|
+
const settingsScreenDirty = (() => {
|
|
1880
|
+
if (isInitialSetup) return false;
|
|
1881
|
+
switch (step) {
|
|
1882
|
+
case 1: return userName.trim() !== settingsOrig.userName;
|
|
1883
|
+
case 2: return botName.trim() !== settingsOrig.botName;
|
|
1884
|
+
case 3: return step3Phase === 'password' && portalPass.length > 0 && portalValid && (portalExists ? portalOldPassVerified : true);
|
|
1885
|
+
case 4: return (provider !== settingsOrig.provider || model !== settingsOrig.model) && isConnected && !!model;
|
|
1886
|
+
case 5: return (whisperEnabled !== settingsOrig.whisperEnabled || (whisperEnabled && whisperKey !== settingsOrig.whisperKey))
|
|
1887
|
+
&& !(whisperEnabled && (!whisperKey.startsWith('sk-') || whisperKey.length < 20));
|
|
1888
|
+
default: return false; // 0 = hub, 6 = env (self-saving)
|
|
1889
|
+
}
|
|
1890
|
+
})();
|
|
1891
|
+
|
|
1892
|
+
// Clear the transient saved/error banners whenever the user moves to another settings screen.
|
|
1893
|
+
useEffect(() => { setSettingsSaved(false); setSettingsSaveError(''); }, [step]);
|
|
1894
|
+
|
|
1895
|
+
const saveSettings = async () => {
|
|
1896
|
+
if (!settingsScreenDirty || settingsSaving) return;
|
|
1897
|
+
setSettingsSaving(true); setSettingsSaveError(''); setSettingsSaved(false);
|
|
1898
|
+
// Base the payload on the last-saved values, then override ONLY the active screen's fields —
|
|
1899
|
+
// so saving one screen never persists another screen's unsaved, in-progress edits.
|
|
1900
|
+
const payload: any = {
|
|
1901
|
+
userName: settingsOrig.userName,
|
|
1902
|
+
agentName: settingsOrig.botName || 'Bloby',
|
|
1903
|
+
provider: settingsOrig.provider,
|
|
1904
|
+
model: settingsOrig.model,
|
|
1905
|
+
apiKey: '',
|
|
1906
|
+
whisperEnabled: settingsOrig.whisperEnabled,
|
|
1907
|
+
whisperKey: settingsOrig.whisperEnabled ? settingsOrig.whisperKey : '',
|
|
1908
|
+
portalUser: portalUser.trim(),
|
|
1909
|
+
portalPass: '',
|
|
1910
|
+
};
|
|
1911
|
+
switch (step) {
|
|
1912
|
+
case 1: payload.userName = userName.trim(); break;
|
|
1913
|
+
case 2: payload.agentName = botName.trim() || 'Bloby'; break;
|
|
1914
|
+
case 3: payload.portalPass = portalPass; break;
|
|
1915
|
+
case 4: payload.provider = provider; payload.model = model; break;
|
|
1916
|
+
case 5: payload.whisperEnabled = whisperEnabled; payload.whisperKey = whisperEnabled ? whisperKey : ''; break;
|
|
1917
|
+
}
|
|
1918
|
+
try {
|
|
1919
|
+
if (onSave) await onSave(payload);
|
|
1920
|
+
else await fetch('/api/onboard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
|
1921
|
+
// Advance the saved snapshot so the Save button hides (screen no longer dirty).
|
|
1922
|
+
setSettingsOrig((o) => {
|
|
1923
|
+
switch (step) {
|
|
1924
|
+
case 1: return { ...o, userName: userName.trim() };
|
|
1925
|
+
case 2: return { ...o, botName: botName.trim() };
|
|
1926
|
+
case 4: return { ...o, provider, model };
|
|
1927
|
+
case 5: return { ...o, whisperEnabled, whisperKey };
|
|
1928
|
+
default: return o;
|
|
1929
|
+
}
|
|
1930
|
+
});
|
|
1931
|
+
if (step === 3) {
|
|
1932
|
+
// Password persisted — reset the change-password UI so it's no longer dirty.
|
|
1933
|
+
setPortalPass(''); setPortalPassConfirm(''); setPortalOldPass(''); setPortalOldPassVerified(false); setPortalChangeMode(false);
|
|
1934
|
+
setPortalExists(true);
|
|
1935
|
+
}
|
|
1936
|
+
setSettingsSaved(true);
|
|
1937
|
+
setSettingsSaving(false);
|
|
1938
|
+
} catch (err: any) {
|
|
1939
|
+
setSettingsSaveError(err.message || 'Save failed');
|
|
1940
|
+
setSettingsSaving(false);
|
|
1941
|
+
}
|
|
1942
|
+
};
|
|
1943
|
+
|
|
1331
1944
|
const handleComplete = async () => {
|
|
1332
1945
|
setSaving(true);
|
|
1333
1946
|
const payload = {
|
|
@@ -1477,13 +2090,6 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1477
2090
|
onChange={(id) => setStep(Number(id))}
|
|
1478
2091
|
/>
|
|
1479
2092
|
</div>
|
|
1480
|
-
<button
|
|
1481
|
-
onClick={next}
|
|
1482
|
-
className="mt-4 px-7 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center gap-2"
|
|
1483
|
-
>
|
|
1484
|
-
Continue
|
|
1485
|
-
<ArrowRight className="h-4 w-4" />
|
|
1486
|
-
</button>
|
|
1487
2093
|
</div>
|
|
1488
2094
|
)}
|
|
1489
2095
|
|
|
@@ -1509,6 +2115,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1509
2115
|
data-lpignore="true"
|
|
1510
2116
|
className={inputCls + ' flex-1'}
|
|
1511
2117
|
/>
|
|
2118
|
+
{isInitialSetup && (
|
|
1512
2119
|
<button
|
|
1513
2120
|
onClick={next}
|
|
1514
2121
|
disabled={!canNext}
|
|
@@ -1516,6 +2123,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1516
2123
|
>
|
|
1517
2124
|
<ArrowRight className="h-5 w-5" />
|
|
1518
2125
|
</button>
|
|
2126
|
+
)}
|
|
1519
2127
|
</div>
|
|
1520
2128
|
</div>
|
|
1521
2129
|
)}
|
|
@@ -1632,6 +2240,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1632
2240
|
</div>
|
|
1633
2241
|
)}
|
|
1634
2242
|
|
|
2243
|
+
{isInitialSetup && (
|
|
1635
2244
|
<button
|
|
1636
2245
|
onClick={next}
|
|
1637
2246
|
disabled={!canNext}
|
|
@@ -1640,6 +2249,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1640
2249
|
Continue
|
|
1641
2250
|
<ArrowRight className="h-4 w-4" />
|
|
1642
2251
|
</button>
|
|
2252
|
+
)}
|
|
1643
2253
|
</div>
|
|
1644
2254
|
)}
|
|
1645
2255
|
|
|
@@ -1710,6 +2320,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1710
2320
|
<p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
|
|
1711
2321
|
</div>
|
|
1712
2322
|
<div className="flex gap-2 mt-4">
|
|
2323
|
+
{isInitialSetup && (
|
|
1713
2324
|
<button
|
|
1714
2325
|
onClick={next}
|
|
1715
2326
|
className="flex-1 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
|
|
@@ -1717,6 +2328,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1717
2328
|
Continue
|
|
1718
2329
|
<ArrowRight className="h-4 w-4" />
|
|
1719
2330
|
</button>
|
|
2331
|
+
)}
|
|
1720
2332
|
<button
|
|
1721
2333
|
onClick={() => {
|
|
1722
2334
|
setShowChangeConfirm(true);
|
|
@@ -1971,8 +2583,8 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
1971
2583
|
</button>
|
|
1972
2584
|
)}
|
|
1973
2585
|
|
|
1974
|
-
{/* Continue after claim */}
|
|
1975
|
-
{registered && (
|
|
2586
|
+
{/* Continue after claim (onboarding only — settings is hub-and-spoke) */}
|
|
2587
|
+
{isInitialSetup && registered && (
|
|
1976
2588
|
<button
|
|
1977
2589
|
onClick={next}
|
|
1978
2590
|
className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
|
|
@@ -2419,6 +3031,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
2419
3031
|
)}
|
|
2420
3032
|
</div>
|
|
2421
3033
|
|
|
3034
|
+
{isInitialSetup && (
|
|
2422
3035
|
<button
|
|
2423
3036
|
onClick={next}
|
|
2424
3037
|
disabled={!canNext}
|
|
@@ -2427,6 +3040,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
2427
3040
|
Continue
|
|
2428
3041
|
<ArrowRight className="h-4 w-4" />
|
|
2429
3042
|
</button>
|
|
3043
|
+
)}
|
|
2430
3044
|
</motion.div>
|
|
2431
3045
|
)}
|
|
2432
3046
|
|
|
@@ -3148,7 +3762,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
3148
3762
|
</>
|
|
3149
3763
|
)}
|
|
3150
3764
|
|
|
3151
|
-
{isConnected && (
|
|
3765
|
+
{isInitialSetup && isConnected && (
|
|
3152
3766
|
<button
|
|
3153
3767
|
onClick={next}
|
|
3154
3768
|
disabled={!canNext}
|
|
@@ -3242,17 +3856,19 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
3242
3856
|
</div>
|
|
3243
3857
|
)}
|
|
3244
3858
|
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3859
|
+
{isInitialSetup && (
|
|
3860
|
+
<button
|
|
3861
|
+
onClick={handleComplete}
|
|
3862
|
+
disabled={saving || (whisperEnabled && (!whisperKey.startsWith('sk-') || whisperKey.length < 20))}
|
|
3863
|
+
className="w-full mt-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
|
|
3864
|
+
>
|
|
3865
|
+
{saving ? (
|
|
3866
|
+
<><LoaderCircle className="h-4 w-4 animate-spin" />Setting up...</>
|
|
3867
|
+
) : (
|
|
3868
|
+
<>Complete Setup<ArrowRight className="h-4 w-4" /></>
|
|
3869
|
+
)}
|
|
3870
|
+
</button>
|
|
3871
|
+
)}
|
|
3256
3872
|
|
|
3257
3873
|
{!whisperEnabled && (
|
|
3258
3874
|
<p className="text-center text-white/20 text-[11px] mt-2.5">
|
|
@@ -3373,6 +3989,9 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
3373
3989
|
|
|
3374
3990
|
{/* ── Step 6: Environment Variables (settings re-run only) ── */}
|
|
3375
3991
|
{step === 6 && !isInitialSetup && <EnvSettings cacheRef={envCacheRef} />}
|
|
3992
|
+
|
|
3993
|
+
{/* ── Step 7: Pulse & Crons (settings re-run only) ── */}
|
|
3994
|
+
{step === 7 && !isInitialSetup && <PulseCronSettings cacheRef={pulseCacheRef} />}
|
|
3376
3995
|
</motion.div>
|
|
3377
3996
|
</AnimatePresence>
|
|
3378
3997
|
|
|
@@ -3387,6 +4006,35 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
3387
4006
|
</button>
|
|
3388
4007
|
</div>
|
|
3389
4008
|
)}
|
|
4009
|
+
|
|
4010
|
+
{/* Settings mode: per-screen Save — shown only when the current screen has changes.
|
|
4011
|
+
(Env screen, step 6, saves itself inside EnvSettings.) */}
|
|
4012
|
+
{!isInitialSetup && step >= 1 && step <= 5 && (settingsScreenDirty || settingsSaving || settingsSaved || settingsSaveError) && (
|
|
4013
|
+
<div className="px-8 pb-6 -mt-2">
|
|
4014
|
+
{settingsSaveError && (
|
|
4015
|
+
<p className="text-red-400 text-[12px] mb-2 flex items-center gap-1.5">
|
|
4016
|
+
<TriangleAlert className="h-3.5 w-3.5 shrink-0" />{settingsSaveError}
|
|
4017
|
+
</p>
|
|
4018
|
+
)}
|
|
4019
|
+
{settingsSaved && !settingsScreenDirty ? (
|
|
4020
|
+
<div className="flex items-center justify-center gap-2 py-3 text-emerald-400 text-[13px] font-medium">
|
|
4021
|
+
<Check className="h-4 w-4" /> Saved
|
|
4022
|
+
</div>
|
|
4023
|
+
) : (
|
|
4024
|
+
<button
|
|
4025
|
+
onClick={saveSettings}
|
|
4026
|
+
disabled={settingsSaving || !settingsScreenDirty}
|
|
4027
|
+
className="w-full py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
4028
|
+
>
|
|
4029
|
+
{settingsSaving ? (
|
|
4030
|
+
<><LoaderCircle className="h-4 w-4 animate-spin" />Saving...</>
|
|
4031
|
+
) : (
|
|
4032
|
+
<><Check className="h-4 w-4" />Save changes</>
|
|
4033
|
+
)}
|
|
4034
|
+
</button>
|
|
4035
|
+
)}
|
|
4036
|
+
</div>
|
|
4037
|
+
)}
|
|
3390
4038
|
</motion.div>
|
|
3391
4039
|
</div>
|
|
3392
4040
|
);
|