ceae76d58f
Removed all E2EE chat endpoints, crypto logic, and related API routes, transitioning chat to plain text storage and transport. Updated chat send/receive endpoints to use signed actions and enforce DM privacy settings. Cleaned up Swarm chat inbox, key management, and related debug endpoints. Updated user profile to support DM privacy, improved search for handle queries, and refactored conversation/message logic to support the new model. Migrated bot settings and privacy settings to new locations.
942 lines
39 KiB
TypeScript
942 lines
39 KiB
TypeScript
/**
|
|
* Bot Creation Page
|
|
*
|
|
* Form for creating a new bot.
|
|
*
|
|
* Requirements: 1.1, 2.1, 3.1
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { ArrowLeftIcon } from '@/components/Icons';
|
|
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
|
|
|
|
interface ContentSource {
|
|
type: 'rss' | 'reddit' | 'news_api' | 'brave_news' | 'youtube';
|
|
url: string;
|
|
subreddit?: string;
|
|
apiKey?: string;
|
|
// Brave News config
|
|
braveQuery?: string;
|
|
braveFreshness?: 'pd' | 'pw' | 'pm' | 'py';
|
|
braveCountry?: string;
|
|
// News API config
|
|
newsProvider?: 'newsapi' | 'gnews' | 'newsdata';
|
|
newsQuery?: string;
|
|
newsCategory?: string;
|
|
newsCountry?: string;
|
|
newsLanguage?: string;
|
|
// YouTube config
|
|
youtubeChannelId?: string;
|
|
youtubePlaylistId?: string;
|
|
}
|
|
|
|
export default function NewBotPage() {
|
|
const router = useRouter();
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
|
const [uploadingBanner, setUploadingBanner] = useState(false);
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
handle: '',
|
|
bio: '',
|
|
avatarUrl: '',
|
|
headerUrl: '',
|
|
systemPrompt: '',
|
|
llmProvider: 'openai',
|
|
llmModel: 'gpt-4',
|
|
llmApiKey: '',
|
|
autonomousMode: false,
|
|
postingFrequency: 'every_4_hours',
|
|
customIntervalMinutes: 240,
|
|
});
|
|
|
|
const [sources, setSources] = useState<ContentSource[]>([]);
|
|
const [newSource, setNewSource] = useState<ContentSource>({
|
|
type: 'rss',
|
|
url: '',
|
|
braveQuery: '',
|
|
braveFreshness: 'pw',
|
|
newsProvider: 'newsapi',
|
|
newsQuery: '',
|
|
});
|
|
|
|
// Fetch previous bot settings to pre-fill LLM config
|
|
useEffect(() => {
|
|
const fetchPreviousBotSettings = async () => {
|
|
try {
|
|
const res = await fetch('/api/bots');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.bots && data.bots.length > 0) {
|
|
// Get the most recently created bot
|
|
const sortedBots = [...data.bots].sort(
|
|
(a: { createdAt: string }, b: { createdAt: string }) =>
|
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
);
|
|
const lastBot = sortedBots[0];
|
|
|
|
// Pre-fill LLM settings (but not API key for security)
|
|
setFormData(prev => ({
|
|
...prev,
|
|
llmProvider: lastBot.llmProvider || 'openai',
|
|
llmModel: lastBot.llmModel || 'gpt-4',
|
|
}));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Silently fail - not critical
|
|
console.error('Failed to fetch previous bot settings:', err);
|
|
}
|
|
};
|
|
|
|
fetchPreviousBotSettings();
|
|
}, []);
|
|
|
|
const handleAddSource = () => {
|
|
// Validate based on type
|
|
if (newSource.type === 'brave_news') {
|
|
if (!newSource.braveQuery || !newSource.apiKey) return;
|
|
// Build URL from config
|
|
const url = new URL('https://api.search.brave.com/res/v1/news/search');
|
|
url.searchParams.set('q', newSource.braveQuery);
|
|
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness);
|
|
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry);
|
|
setSources([...sources, { ...newSource, url: url.toString() }]);
|
|
} else if (newSource.type === 'news_api') {
|
|
if (!newSource.newsQuery || !newSource.apiKey) return;
|
|
// Build URL from config
|
|
let baseUrl: string;
|
|
const params = new URLSearchParams();
|
|
switch (newSource.newsProvider) {
|
|
case 'gnews':
|
|
baseUrl = 'https://gnews.io/api/v4/search';
|
|
params.set('q', newSource.newsQuery);
|
|
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
|
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
|
|
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
|
|
break;
|
|
case 'newsdata':
|
|
baseUrl = 'https://newsdata.io/api/1/news';
|
|
params.set('q', newSource.newsQuery);
|
|
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
|
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
|
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
|
|
break;
|
|
default:
|
|
baseUrl = 'https://newsapi.org/v2/everything';
|
|
params.set('q', newSource.newsQuery);
|
|
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
|
}
|
|
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]);
|
|
} else {
|
|
if (!newSource.url) return;
|
|
setSources([...sources, { ...newSource }]);
|
|
}
|
|
setNewSource({
|
|
type: 'rss',
|
|
url: '',
|
|
braveQuery: '',
|
|
braveFreshness: 'pw',
|
|
newsProvider: 'newsapi',
|
|
newsQuery: '',
|
|
});
|
|
};
|
|
|
|
const handleRemoveSource = (index: number) => {
|
|
setSources(sources.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
setError('');
|
|
|
|
try {
|
|
// Calculate interval minutes based on frequency
|
|
let intervalMinutes = 240;
|
|
if (formData.postingFrequency === 'every_2_hours') intervalMinutes = 120;
|
|
else if (formData.postingFrequency === 'every_4_hours') intervalMinutes = 240;
|
|
else if (formData.postingFrequency === 'every_6_hours') intervalMinutes = 360;
|
|
else if (formData.postingFrequency === 'daily') intervalMinutes = 1440;
|
|
|
|
const response = await fetch('/api/bots', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: formData.name,
|
|
handle: formData.handle,
|
|
bio: formData.bio,
|
|
avatarUrl: formData.avatarUrl || undefined,
|
|
headerUrl: formData.headerUrl || undefined,
|
|
personality: {
|
|
systemPrompt: formData.systemPrompt,
|
|
temperature: 0.7, // Default value since posts are limited to 400 chars
|
|
maxTokens: 500, // Default value, sufficient for 400 char posts
|
|
},
|
|
llmProvider: formData.llmProvider,
|
|
llmModel: formData.llmModel,
|
|
llmApiKey: formData.llmApiKey,
|
|
autonomousMode: formData.autonomousMode,
|
|
schedule: formData.autonomousMode ? {
|
|
type: 'interval',
|
|
intervalMinutes,
|
|
} : undefined,
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
// If sources were added, create them after bot creation
|
|
if (sources.length > 0) {
|
|
for (const source of sources) {
|
|
const sourcePayload: Record<string, unknown> = {
|
|
type: source.type,
|
|
url: source.url,
|
|
subreddit: source.subreddit,
|
|
apiKey: source.apiKey,
|
|
};
|
|
|
|
// Add config for brave_news
|
|
if (source.type === 'brave_news' && source.braveQuery) {
|
|
sourcePayload.braveNewsConfig = {
|
|
query: source.braveQuery,
|
|
freshness: source.braveFreshness,
|
|
country: source.braveCountry,
|
|
};
|
|
}
|
|
|
|
// Add config for news_api
|
|
if (source.type === 'news_api' && source.newsQuery) {
|
|
sourcePayload.newsApiConfig = {
|
|
provider: source.newsProvider,
|
|
query: source.newsQuery,
|
|
category: source.newsCategory,
|
|
country: source.newsCountry,
|
|
language: source.newsLanguage,
|
|
};
|
|
}
|
|
|
|
await fetch(`/api/bots/${data.bot.id}/sources`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(sourcePayload),
|
|
});
|
|
}
|
|
|
|
// Trigger the first post to establish a reference point for the schedule
|
|
// This runs in the background - we don't wait for it
|
|
fetch(`/api/bots/${data.bot.id}/post`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
}).catch(() => {
|
|
// Silently fail - the bot will post on the next scheduled run
|
|
});
|
|
}
|
|
|
|
router.push(`/bots/${data.bot.id}`);
|
|
} else {
|
|
const data = await response.json();
|
|
console.error('Bot creation failed:', data);
|
|
const errorMsg = data.error || 'Failed to create bot';
|
|
const detailsMsg = data.details ? '\n' + JSON.stringify(data.details, null, 2) : '';
|
|
setError(errorMsg + detailsMsg);
|
|
}
|
|
} catch (err) {
|
|
console.error('Create bot error:', err);
|
|
setError('Failed to create bot');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const renderStep = () => {
|
|
switch (step) {
|
|
case 'identity':
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
Bot Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="input"
|
|
placeholder="My Awesome Bot"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
Handle
|
|
</label>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<span style={{ color: 'var(--foreground-tertiary)' }}>@</span>
|
|
<input
|
|
type="text"
|
|
value={formData.handle}
|
|
onChange={(e) => setFormData({ ...formData, handle: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') })}
|
|
className="input"
|
|
placeholder="mybot"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</div>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
Lowercase letters, numbers, and underscores only
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
Bio
|
|
</label>
|
|
<textarea
|
|
value={formData.bio}
|
|
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
|
|
className="input"
|
|
rows={3}
|
|
placeholder="A brief description of what your bot does..."
|
|
style={{ resize: 'vertical' }}
|
|
/>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
{formData.bio.length}/400 characters
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
Avatar
|
|
</label>
|
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
|
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
|
{uploadingAvatar ? 'Uploading...' : 'Choose File'}
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setUploadingAvatar(true);
|
|
try {
|
|
const uploadData = new FormData();
|
|
uploadData.append('file', file);
|
|
const res = await fetch('/api/uploads', {
|
|
method: 'POST',
|
|
body: uploadData,
|
|
});
|
|
const data = await res.json();
|
|
if (data.url) {
|
|
setFormData(prev => ({ ...prev, avatarUrl: data.url }));
|
|
}
|
|
} catch (err) {
|
|
console.error('Avatar upload failed:', err);
|
|
setError('Avatar upload failed');
|
|
} finally {
|
|
setUploadingAvatar(false);
|
|
}
|
|
}}
|
|
disabled={uploadingAvatar}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</label>
|
|
{formData.avatarUrl && (
|
|
<div style={{ width: '48px', height: '48px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
|
<img src={formData.avatarUrl} alt="Avatar preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
</div>
|
|
)}
|
|
{formData.avatarUrl && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData(prev => ({ ...prev, avatarUrl: '' }))}
|
|
className="btn btn-ghost btn-sm"
|
|
style={{ color: 'var(--error)' }}
|
|
>
|
|
Remove
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
Square image recommended (optional)
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
Banner
|
|
</label>
|
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
|
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
|
{uploadingBanner ? 'Uploading...' : 'Choose File'}
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setUploadingBanner(true);
|
|
try {
|
|
const uploadData = new FormData();
|
|
uploadData.append('file', file);
|
|
const res = await fetch('/api/uploads', {
|
|
method: 'POST',
|
|
body: uploadData,
|
|
});
|
|
const data = await res.json();
|
|
if (data.url) {
|
|
setFormData(prev => ({ ...prev, headerUrl: data.url }));
|
|
}
|
|
} catch (err) {
|
|
console.error('Banner upload failed:', err);
|
|
setError('Banner upload failed');
|
|
} finally {
|
|
setUploadingBanner(false);
|
|
}
|
|
}}
|
|
disabled={uploadingBanner}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</label>
|
|
{formData.headerUrl && (
|
|
<div style={{ width: '120px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
|
<img src={formData.headerUrl} alt="Banner preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
</div>
|
|
)}
|
|
{formData.headerUrl && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setFormData(prev => ({ ...prev, headerUrl: '' }))}
|
|
className="btn btn-ghost btn-sm"
|
|
style={{ color: 'var(--error)' }}
|
|
>
|
|
Remove
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
Wide image recommended, e.g. 1500x500 (optional)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case 'personality':
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
System Prompt
|
|
</label>
|
|
<textarea
|
|
value={formData.systemPrompt}
|
|
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
|
|
className="input"
|
|
rows={6}
|
|
placeholder="You are a helpful bot that shares interesting tech news and engages with users about technology..."
|
|
style={{ resize: 'vertical' }}
|
|
/>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
Define your bot's personality, tone, and behavior
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
LLM Provider
|
|
</label>
|
|
<select
|
|
value={formData.llmProvider}
|
|
onChange={(e) => setFormData({ ...formData, llmProvider: e.target.value })}
|
|
className="input"
|
|
>
|
|
<option value="openai">OpenAI</option>
|
|
<option value="anthropic">Anthropic</option>
|
|
<option value="openrouter">OpenRouter</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
Model
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.llmModel}
|
|
onChange={(e) => setFormData({ ...formData, llmModel: e.target.value })}
|
|
className="input"
|
|
placeholder="gpt-4"
|
|
/>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
e.g., gpt-4, claude-3-opus, etc.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
API Key
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={formData.llmApiKey}
|
|
onChange={(e) => setFormData({ ...formData, llmApiKey: e.target.value })}
|
|
className="input"
|
|
placeholder="sk-..."
|
|
/>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
|
|
Your API key is encrypted and stored securely
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case 'sources':
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
<div>
|
|
<p style={{ fontSize: '14px', color: 'var(--foreground-secondary)', marginBottom: '16px' }}>
|
|
Add content sources for your bot to pull from (optional)
|
|
</p>
|
|
|
|
<div className="card" style={{ padding: '16px', marginBottom: '16px' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Source Type
|
|
</label>
|
|
<select
|
|
value={newSource.type}
|
|
onChange={(e) => setNewSource({ ...newSource, type: e.target.value as ContentSource['type'] })}
|
|
className="input"
|
|
>
|
|
<option value="rss">RSS Feed</option>
|
|
<option value="reddit">Reddit</option>
|
|
<option value="youtube">YouTube Channel/Playlist</option>
|
|
<option value="brave_news">Brave News Search</option>
|
|
<option value="news_api">News API (Advanced)</option>
|
|
</select>
|
|
</div>
|
|
|
|
{newSource.type === 'rss' && (
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
RSS Feed URL
|
|
</label>
|
|
<input
|
|
type="url"
|
|
value={newSource.url}
|
|
onChange={(e) => setNewSource({ ...newSource, url: e.target.value })}
|
|
className="input"
|
|
placeholder="https://example.com/feed.xml"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{newSource.type === 'youtube' && (
|
|
<>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Channel ID or Playlist ID
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newSource.youtubeChannelId || newSource.youtubePlaylistId || ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value.trim();
|
|
// Detect if it's a playlist ID (starts with PL) or channel ID (starts with UC)
|
|
if (value.startsWith('PL')) {
|
|
const rssUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${value}`;
|
|
setNewSource({ ...newSource, youtubePlaylistId: value, youtubeChannelId: '', url: rssUrl });
|
|
} else {
|
|
const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${value}`;
|
|
setNewSource({ ...newSource, youtubeChannelId: value, youtubePlaylistId: '', url: rssUrl });
|
|
}
|
|
}}
|
|
className="input"
|
|
placeholder="UCxxxx... or PLxxxx..."
|
|
/>
|
|
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
|
Channel IDs start with UC, Playlist IDs start with PL. Find in the YouTube URL.
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{newSource.type === 'reddit' && (
|
|
<>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Subreddit
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newSource.subreddit || ''}
|
|
onChange={(e) => setNewSource({ ...newSource, subreddit: e.target.value, url: `https://reddit.com/r/${e.target.value}` })}
|
|
className="input"
|
|
placeholder="technology"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{newSource.type === 'brave_news' && (
|
|
<>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Search Query
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newSource.braveQuery || ''}
|
|
onChange={(e) => setNewSource({ ...newSource, braveQuery: e.target.value })}
|
|
className="input"
|
|
placeholder="AI technology, climate change, etc."
|
|
/>
|
|
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
|
Use quotes for exact phrases, minus to exclude terms
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Freshness
|
|
</label>
|
|
<select
|
|
value={newSource.braveFreshness || 'pw'}
|
|
onChange={(e) => setNewSource({ ...newSource, braveFreshness: e.target.value as ContentSource['braveFreshness'] })}
|
|
className="input"
|
|
>
|
|
<option value="pd">Last 24 hours</option>
|
|
<option value="pw">Last 7 days</option>
|
|
<option value="pm">Last 31 days</option>
|
|
<option value="py">Last year</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Country (optional)
|
|
</label>
|
|
<select
|
|
value={newSource.braveCountry || ''}
|
|
onChange={(e) => setNewSource({ ...newSource, braveCountry: e.target.value })}
|
|
className="input"
|
|
>
|
|
<option value="">All countries</option>
|
|
<option value="US">United States</option>
|
|
<option value="GB">United Kingdom</option>
|
|
<option value="CA">Canada</option>
|
|
<option value="AU">Australia</option>
|
|
<option value="DE">Germany</option>
|
|
<option value="FR">France</option>
|
|
<option value="JP">Japan</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Brave API Key
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={newSource.apiKey || ''}
|
|
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
|
|
className="input"
|
|
placeholder="Your Brave Search API key"
|
|
/>
|
|
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
|
Get your API key at <a href="https://brave.com/search/api/" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>brave.com/search/api</a>
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{newSource.type === 'news_api' && (
|
|
<>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
News Provider
|
|
</label>
|
|
<select
|
|
value={newSource.newsProvider || 'newsapi'}
|
|
onChange={(e) => setNewSource({ ...newSource, newsProvider: e.target.value as ContentSource['newsProvider'] })}
|
|
className="input"
|
|
>
|
|
<option value="newsapi">NewsAPI.org</option>
|
|
<option value="gnews">GNews.io</option>
|
|
<option value="newsdata">NewsData.io</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Search Keywords
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newSource.newsQuery || ''}
|
|
onChange={(e) => setNewSource({ ...newSource, newsQuery: e.target.value })}
|
|
className="input"
|
|
placeholder="technology, AI, startups"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Category (optional)
|
|
</label>
|
|
<select
|
|
value={newSource.newsCategory || ''}
|
|
onChange={(e) => setNewSource({ ...newSource, newsCategory: e.target.value })}
|
|
className="input"
|
|
>
|
|
<option value="">All categories</option>
|
|
<option value="technology">Technology</option>
|
|
<option value="business">Business</option>
|
|
<option value="science">Science</option>
|
|
<option value="health">Health</option>
|
|
<option value="sports">Sports</option>
|
|
<option value="entertainment">Entertainment</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
Country (optional)
|
|
</label>
|
|
<select
|
|
value={newSource.newsCountry || ''}
|
|
onChange={(e) => setNewSource({ ...newSource, newsCountry: e.target.value })}
|
|
className="input"
|
|
>
|
|
<option value="">All countries</option>
|
|
<option value="us">United States</option>
|
|
<option value="gb">United Kingdom</option>
|
|
<option value="ca">Canada</option>
|
|
<option value="au">Australia</option>
|
|
<option value="de">Germany</option>
|
|
<option value="fr">France</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '13px', fontWeight: 500, marginBottom: '6px' }}>
|
|
API Key
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={newSource.apiKey || ''}
|
|
onChange={(e) => setNewSource({ ...newSource, apiKey: e.target.value })}
|
|
className="input"
|
|
placeholder="Your API key"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleAddSource}
|
|
className="btn btn-primary"
|
|
disabled={
|
|
(newSource.type === 'rss' && !newSource.url) ||
|
|
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
|
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
|
|
(newSource.type === 'brave_news' && (!newSource.braveQuery || !newSource.apiKey)) ||
|
|
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
|
}
|
|
>
|
|
Add Source
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{sources.length > 0 && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<h3 style={{ fontSize: '14px', fontWeight: 600, marginBottom: '8px' }}>
|
|
Added Sources ({sources.length})
|
|
</h3>
|
|
{sources.map((source, index) => (
|
|
<div key={index} className="card" style={{ padding: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div>
|
|
<div style={{ fontSize: '13px', fontWeight: 500 }}>
|
|
{source.type === 'brave_news' ? 'BRAVE NEWS' :
|
|
source.type === 'youtube' ? 'YOUTUBE' :
|
|
source.type.toUpperCase()} - {
|
|
source.type === 'brave_news' ? source.braveQuery :
|
|
source.type === 'news_api' ? source.newsQuery :
|
|
source.type === 'youtube' ? (source.youtubeChannelId || source.youtubePlaylistId) :
|
|
source.subreddit || new URL(source.url).hostname
|
|
}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveSource(index)}
|
|
className="btn btn-sm"
|
|
style={{ color: 'var(--error)' }}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case 'schedule':
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
<div>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.autonomousMode}
|
|
onChange={(e) => setFormData({ ...formData, autonomousMode: e.target.checked })}
|
|
style={{ width: '18px', height: '18px' }}
|
|
/>
|
|
<span style={{ fontSize: '14px', fontWeight: 500 }}>
|
|
Enable Autonomous Mode
|
|
</span>
|
|
</label>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginLeft: '26px' }}>
|
|
Bot will automatically post based on the schedule below
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
|
|
Posting Frequency
|
|
</label>
|
|
<select
|
|
value={formData.postingFrequency}
|
|
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
|
|
className="input"
|
|
disabled={!formData.autonomousMode}
|
|
>
|
|
<option value="every_2_hours">Every 2 Hours</option>
|
|
<option value="every_4_hours">Every 4 Hours</option>
|
|
<option value="every_6_hours">Every 6 Hours</option>
|
|
<option value="daily">Once Daily</option>
|
|
</select>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="card" style={{ padding: '16px', background: 'var(--background-tertiary)' }}>
|
|
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)' }}>
|
|
{formData.autonomousMode
|
|
? `Your bot will automatically post ${formData.postingFrequency.replace('_', ' ')}.`
|
|
: 'Autonomous mode is disabled. You can manually trigger posts from the bot dashboard.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
const steps = ['identity', 'personality', 'sources', 'schedule'] as const;
|
|
const currentStepIndex = steps.indexOf(step);
|
|
const isLastStep = currentStepIndex === steps.length - 1;
|
|
|
|
return (
|
|
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
|
|
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
|
|
<Link href="/bots" style={{ color: 'var(--foreground)' }}>
|
|
<ArrowLeftIcon />
|
|
</Link>
|
|
<div>
|
|
<h1 style={{ fontSize: '24px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<Bot size={24} />
|
|
Create New Bot
|
|
</h1>
|
|
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
|
|
Step {currentStepIndex + 1} of {steps.length}: {step.charAt(0).toUpperCase() + step.slice(1)}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
{error && (
|
|
<div className="card" style={{ padding: '16px', marginBottom: '24px', borderColor: 'var(--error)', background: 'rgba(239, 68, 68, 0.1)' }}>
|
|
<p style={{ color: 'var(--error)', fontSize: '14px', whiteSpace: 'pre-wrap', fontFamily: error.includes('{') ? 'monospace' : 'inherit' }}>
|
|
{error}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginBottom: '24px' }}>
|
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
|
|
{steps.map((s, i) => (
|
|
<div
|
|
key={s}
|
|
style={{
|
|
flex: 1,
|
|
height: '4px',
|
|
borderRadius: 'var(--radius-full)',
|
|
background: i <= currentStepIndex ? 'var(--accent)' : 'var(--border)',
|
|
transition: 'background 0.2s ease',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} onKeyDown={(e) => {
|
|
// Prevent Enter key from submitting the form except on the last step
|
|
if (e.key === 'Enter' && !isLastStep) {
|
|
e.preventDefault();
|
|
}
|
|
}}>
|
|
<div className="card" style={{ padding: '24px', marginBottom: '24px' }}>
|
|
{renderStep()}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'space-between' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (currentStepIndex > 0) {
|
|
setStep(steps[currentStepIndex - 1]);
|
|
} else {
|
|
router.back();
|
|
}
|
|
}}
|
|
className="btn"
|
|
>
|
|
{currentStepIndex === 0 ? 'Cancel' : 'Back'}
|
|
</button>
|
|
|
|
{!isLastStep ? (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setStep(steps[currentStepIndex + 1]);
|
|
}}
|
|
className="btn btn-primary"
|
|
disabled={
|
|
(step === 'identity' && (!formData.name || !formData.handle)) ||
|
|
(step === 'personality' && (!formData.systemPrompt || !formData.llmApiKey))
|
|
}
|
|
>
|
|
Next
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="btn btn-primary"
|
|
>
|
|
{loading ? 'Creating...' : 'Create Bot'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|