Refactor chat system to remove E2EE and simplify messaging

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.
This commit is contained in:
Christopher
2026-01-28 13:05:34 -08:00
parent b52b3f9804
commit ceae76d58f
44 changed files with 1945 additions and 3058 deletions
File diff suppressed because it is too large Load Diff
-732
View File
@@ -1,732 +0,0 @@
/**
* Bot Detail/Edit Page
*
* View and edit bot configuration, manage sources, view logs.
*
* Requirements: 1.3, 4.6, 8.2
*/
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Play, Pause, Rss, Activity, Settings, Sparkles, Clock, Trash2, Pencil } from 'lucide-react';
import { useToast } from '@/lib/contexts/ToastContext';
export default function BotDetailPage() {
const params = useParams();
const router = useRouter();
const { showToast } = useToast();
const botId = params.id as string;
const [bot, setBot] = useState<any>(null);
const [sources, setSources] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [showAddSource, setShowAddSource] = useState(false);
const [newSource, setNewSource] = useState({
type: 'rss' as 'rss' | 'reddit' | 'news_api' | 'brave_news',
url: '',
subreddit: '',
apiKey: '',
// Brave News config
braveQuery: '',
braveFreshness: 'pw' as 'pd' | 'pw' | 'pm' | 'py',
braveCountry: '',
// News API config
newsProvider: 'newsapi' as 'newsapi' | 'gnews' | 'newsdata',
newsQuery: '',
newsCategory: '',
newsCountry: '',
newsLanguage: '',
});
useEffect(() => {
fetchBot();
fetchSources();
}, [botId]);
const fetchBot = async () => {
try {
const response = await fetch(`/api/bots/${botId}`);
if (response.ok) {
const data = await response.json();
setBot(data.bot);
}
} catch (error) {
console.error('Failed to fetch bot:', error);
} finally {
setLoading(false);
}
};
const fetchSources = async () => {
try {
const response = await fetch(`/api/bots/${botId}/sources`);
if (response.ok) {
const data = await response.json();
setSources(data.sources || []);
}
} catch (error) {
console.error('Failed to fetch sources:', error);
}
};
const handleTriggerPost = async () => {
setActionLoading(true);
try {
const response = await fetch(`/api/bots/${botId}/post`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
showToast('Post triggered successfully!', 'success');
fetchBot();
} else {
const data = await response.json();
showToast(`Failed to trigger post: ${data.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showToast('Failed to trigger post', 'error');
} finally {
setActionLoading(false);
}
};
const handleToggleActive = async () => {
setActionLoading(true);
try {
const response = await fetch(`/api/bots/${botId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !bot.isActive }),
});
if (response.ok) {
fetchBot();
}
} catch (error) {
console.error('Failed to toggle bot:', error);
} finally {
setActionLoading(false);
}
};
const handleAddSource = async () => {
setActionLoading(true);
try {
let url = newSource.url;
const payload: Record<string, unknown> = {
type: newSource.type,
apiKey: newSource.apiKey || undefined,
};
// Build URL and config based on type
if (newSource.type === 'brave_news') {
if (!newSource.braveQuery || !newSource.apiKey) {
showToast('Search query and API key are required for Brave News', 'error');
setActionLoading(false);
return;
}
const braveUrl = new URL('https://api.search.brave.com/res/v1/news/search');
braveUrl.searchParams.set('q', newSource.braveQuery);
if (newSource.braveFreshness) braveUrl.searchParams.set('freshness', newSource.braveFreshness);
if (newSource.braveCountry) braveUrl.searchParams.set('country', newSource.braveCountry);
url = braveUrl.toString();
payload.braveNewsConfig = {
query: newSource.braveQuery,
freshness: newSource.braveFreshness,
country: newSource.braveCountry || undefined,
};
} else if (newSource.type === 'news_api') {
if (!newSource.newsQuery || !newSource.apiKey) {
showToast('Search query and API key are required for News API', 'error');
setActionLoading(false);
return;
}
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);
}
url = `${baseUrl}?${params.toString()}`;
payload.newsApiConfig = {
provider: newSource.newsProvider,
query: newSource.newsQuery,
category: newSource.newsCategory || undefined,
country: newSource.newsCountry || undefined,
language: newSource.newsLanguage || undefined,
};
} else if (newSource.type === 'reddit') {
payload.subreddit = newSource.subreddit || undefined;
}
payload.url = url;
const response = await fetch(`/api/bots/${botId}/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.ok) {
setShowAddSource(false);
setNewSource({
type: 'rss',
url: '',
subreddit: '',
apiKey: '',
braveQuery: '',
braveFreshness: 'pw',
braveCountry: '',
newsProvider: 'newsapi',
newsQuery: '',
newsCategory: '',
newsCountry: '',
newsLanguage: '',
});
fetchSources();
} else {
const data = await response.json();
showToast(`Failed to add source: ${data.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showToast('Failed to add source', 'error');
} finally {
setActionLoading(false);
}
};
const handleFetchSource = async (sourceId: string) => {
setActionLoading(true);
try {
const response = await fetch(`/api/bots/${botId}/sources/${sourceId}/fetch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
const data = await response.json();
showToast(`Fetched ${data.itemsFetched || 0} items successfully!`, 'success');
fetchSources();
} else {
const data = await response.json();
showToast(`Failed to fetch content: ${data.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showToast('Failed to fetch content', 'error');
} finally {
setActionLoading(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
if (!bot) {
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<p style={{ color: 'var(--foreground-tertiary)' }}>Bot not found</p>
<Link href="/settings/bots" className="btn" style={{ marginTop: '16px' }}>
Back to Bots
</Link>
</div>
</div>
);
}
const scheduleConfig = typeof bot.scheduleConfig === 'string'
? JSON.parse(bot.scheduleConfig)
: bot.scheduleConfig || null;
const personalityConfig = typeof bot.personalityConfig === 'string'
? JSON.parse(bot.personalityConfig)
: bot.personalityConfig || {};
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings/bots" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>{bot.name}</h1>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '4px 10px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Autonomous
</span>
)}
</div>
<p style={{ fontSize: '14px', color: 'var(--foreground-tertiary)' }}>
@{bot.handle}
</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{bot.isSuspended ? (
<span className="status-pill suspended">Suspended</span>
) : bot.isActive ? (
<span className="status-pill active">Active</span>
) : (
<span className="status-pill">Inactive</span>
)}
</div>
</header>
{/* Quick Actions */}
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Play size={18} />
Quick Actions
</h2>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={handleTriggerPost}
disabled={actionLoading || bot.isSuspended}
className="btn btn-primary"
>
<Play size={16} />
Trigger Post
</button>
<button
onClick={handleToggleActive}
disabled={actionLoading || bot.isSuspended}
className="btn"
>
{bot.isActive ? <Pause size={16} /> : <Play size={16} />}
{bot.isActive ? 'Deactivate' : 'Activate'}
</button>
<Link
href={`/settings/bots/${botId}/edit`}
className="btn"
>
<Pencil size={16} />
Edit Bot
</Link>
</div>
</div>
{/* Bot Info */}
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={18} />
Bot Information
</h2>
<div style={{ display: 'grid', gap: '12px' }}>
{bot.bio && (
<div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
Bio
</div>
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
{bot.bio}
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
Last Post
</div>
<div style={{ fontSize: '14px' }}>
{bot.lastPostAt ? new Date(bot.lastPostAt).toLocaleString() : 'Never'}
</div>
</div>
<div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
Created
</div>
<div style={{ fontSize: '14px' }}>
{new Date(bot.createdAt).toLocaleDateString()}
</div>
</div>
<div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginBottom: '4px' }}>
LLM Provider
</div>
<div style={{ fontSize: '14px' }}>
{bot.llmProvider} / {bot.llmModel}
</div>
</div>
</div>
</div>
</div>
{/* Schedule */}
{bot.autonomousMode && scheduleConfig && (
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Clock size={18} />
Posting Schedule
</h2>
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
{scheduleConfig.type === 'interval' && scheduleConfig.intervalMinutes
? `Posts every ${scheduleConfig.intervalMinutes} minutes`
: 'Custom schedule configured'}
</div>
</div>
)}
{/* Personality */}
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Sparkles size={18} />
Personality
</h2>
<div style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
background: 'var(--background-tertiary)',
padding: '12px',
borderRadius: 'var(--radius-md)',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
}}>
{personalityConfig?.systemPrompt || 'No system prompt configured'}
</div>
</div>
{/* Content Sources */}
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rss size={18} />
Content Sources ({sources.length})
</h2>
<button
onClick={() => setShowAddSource(!showAddSource)}
className="btn btn-sm btn-primary"
>
{showAddSource ? 'Cancel' : '+ Add Source'}
</button>
</div>
{showAddSource && (
<div className="card" style={{ padding: '16px', marginBottom: '16px', background: 'var(--background-tertiary)' }}>
<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 typeof newSource.type })}
className="input"
>
<option value="rss">RSS Feed</option>
<option value="reddit">Reddit</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 === '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}
onChange={(e) => setNewSource({ ...newSource, braveFreshness: e.target.value as typeof newSource.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}
onChange={(e) => setNewSource({ ...newSource, newsProvider: e.target.value as typeof newSource.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
onClick={handleAddSource}
disabled={
actionLoading ||
(newSource.type === 'rss' && !newSource.url) ||
(newSource.type === 'reddit' && !newSource.subreddit) ||
(newSource.type === 'brave_news' && (!newSource.braveQuery || !newSource.apiKey)) ||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
}
className="btn btn-primary"
>
{actionLoading ? 'Adding...' : 'Add Source'}
</button>
</div>
</div>
)}
{sources.length === 0 ? (
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', textAlign: 'center', padding: '24px 0' }}>
No content sources configured. Add a source to enable posting.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{sources.map((source: any) => {
let displayName = '';
try {
if (source.type === 'brave_news') {
const urlObj = new URL(source.url);
displayName = urlObj.searchParams.get('q') || 'Brave News';
} else if (source.subreddit) {
displayName = source.subreddit;
} else if (source.url) {
displayName = new URL(source.url).hostname;
} else {
displayName = 'Unknown';
}
} catch {
displayName = source.url || 'Unknown';
}
return (
<div
key={source.id}
style={{
padding: '12px',
background: 'var(--background-tertiary)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px' }}>
{source.type === 'brave_news' ? 'BRAVE NEWS' : source.type.toUpperCase()} - {displayName}
</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
{source.lastFetchAt ? `Last fetched: ${new Date(source.lastFetchAt).toLocaleString()}` : 'Never fetched'}
{source.lastError && <span style={{ color: 'var(--error)' }}> Error: {source.lastError}</span>}
</div>
</div>
<span className={`status-pill ${source.isActive ? 'active' : ''}`}>
{source.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Danger Zone */}
<div className="card" style={{ padding: '20px', borderColor: 'var(--error)' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '16px', color: 'var(--error)', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Trash2 size={18} />
Danger Zone
</h2>
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', marginBottom: '12px' }}>
Deleting a bot is permanent and cannot be undone. All associated data will be removed.
</p>
<button
onClick={() => {
if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) {
fetch(`/api/bots/${botId}`, { method: 'DELETE' })
.then(() => router.push('/settings/bots'))
.catch(() => showToast('Failed to delete bot', 'error'));
}
}}
className="btn"
style={{ color: 'var(--error)', borderColor: 'var(--error)' }}
>
<Trash2 size={16} />
Delete Bot
</button>
</div>
</div>
);
}
-941
View File
@@ -1,941 +0,0 @@
/**
* 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(`/settings/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="/settings/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>
);
}
-211
View File
@@ -1,211 +0,0 @@
/**
* Bot Management Page
*
* Lists user's bots and provides creation interface.
*
* Requirements: 1.3
*/
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Plus, Sparkles } from 'lucide-react';
interface BotData {
id: string;
name: string;
handle: string;
bio: string;
avatarUrl: string | null;
isActive: boolean;
isSuspended: boolean;
autonomousMode: boolean;
lastPostAt: Date | null;
createdAt: Date;
}
export default function BotsPage() {
const router = useRouter();
const [bots, setBots] = useState<BotData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchBots();
}, []);
const fetchBots = async () => {
try {
const response = await fetch('/api/bots');
if (response.ok) {
const data = await response.json();
setBots(data.bots || []);
}
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<div style={{ textAlign: 'center', padding: '48px 24px', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
</div>
);
}
return (
<div style={{ maxWidth: '700px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '32px' }}>
<Link href="/settings" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={24} />
My Bots
</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Create and manage your automated bots
</p>
</div>
<Link href="/settings/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Bot
</Link>
</header>
{bots.length === 0 ? (
<div className="card" style={{ padding: '48px 24px', textAlign: 'center' }}>
<Bot size={48} style={{ margin: '0 auto 16px', color: 'var(--foreground-tertiary)', opacity: 0.5 }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
No bots yet
</h2>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px', marginBottom: '24px' }}>
Create your first bot to start automating posts and interactions
</p>
<Link href="/settings/bots/new" className="btn btn-primary">
<Plus size={18} />
Create Your First Bot
</Link>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{bots.map((bot) => (
<div
key={bot.id}
className="card"
style={{
padding: '20px',
cursor: 'pointer',
transition: 'border-color 0.15s ease',
}}
onClick={() => router.push(`/settings/bots/${bot.id}`)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '12px' }}>
<div style={{ display: 'flex', gap: '12px', flex: 1, minWidth: 0 }}>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
className="avatar"
style={{
width: '48px',
height: '48px',
flexShrink: 0,
fontSize: '18px',
}}
>
{bot.avatarUrl ? (
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
) : (
bot.name.charAt(0).toUpperCase()
)}
</Link>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{bot.name}</h2>
{bot.autonomousMode && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
padding: '3px 8px',
borderRadius: 'var(--radius-full)',
background: 'var(--accent-muted)',
color: 'var(--accent)',
}}>
<Sparkles size={12} />
Auto
</span>
)}
</div>
<Link
href={`/u/${bot.handle}`}
onClick={(e) => e.stopPropagation()}
style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}
>
@{bot.handle}
</Link>
{bot.bio && (
<p style={{
fontSize: '13px',
color: 'var(--foreground-secondary)',
marginTop: '8px',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}>
{bot.bio}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{bot.isSuspended ? (
<span className="status-pill suspended">
Suspended
</span>
) : bot.isActive ? (
<span className="status-pill active">
Active
</span>
) : (
<span className="status-pill">
Inactive
</span>
)}
</div>
</div>
<div style={{
display: 'flex',
gap: '16px',
fontSize: '12px',
color: 'var(--foreground-tertiary)',
paddingTop: '12px',
borderTop: '1px solid var(--border)',
}}>
<span>
Last post: {bot.lastPostAt
? new Date(bot.lastPostAt).toLocaleDateString()
: 'Never'}
</span>
<span>
Created: {new Date(bot.createdAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
+105 -103
View File
@@ -1,124 +1,126 @@
'use client';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { Rocket, Shield, Bell, Bot, Eye, UserX } from 'lucide-react';
import { Rocket, Shield, Bell, Eye, UserX } from 'lucide-react';
export default function SettingsPage() {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
padding: '16px',
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'var(--background)',
zIndex: 10,
backdropFilter: 'blur(12px)',
}}>
<Link href="/" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Settings</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Manage your account
</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Settings</h1>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Link href="/settings/bots" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bot size={18} />
Bots
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Create and manage automated bots
</div>
</Link>
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<Link href="/settings/content" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Eye size={18} />
Content Settings
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
NSFW preferences and content visibility
</div>
</Link>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Link href="/settings/moderation" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserX size={18} />
Moderation
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Blocked users and muted nodes
</div>
</Link>
<Link href="/settings/migration" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rocket size={18} />
Export Account
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Download a backup of your account and content
</div>
</Link>
<Link href="/settings/content" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Eye size={18} />
Content Settings
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
NSFW preferences and content visibility
</div>
</Link>
<Link href="/settings/security" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} />
Security
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Change password
</div>
</Link>
<Link href="/settings/privacy" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} />
Privacy & Safety
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Control who can message you
</div>
</Link>
<div className="card" style={{
display: 'block',
padding: '20px',
opacity: 0.5,
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bell size={18} />
Notifications
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Notification preferences (coming soon)
<Link href="/settings/moderation" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserX size={18} />
Moderation
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Blocked users and muted nodes
</div>
</Link>
<Link href="/settings/migration" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Rocket size={18} />
Export Account
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Download a backup of your account and content
</div>
</Link>
<Link href="/settings/security" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={18} />
Security
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Change password
</div>
</Link>
<div className="card" style={{
display: 'block',
padding: '20px',
opacity: 0.5,
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Bell size={18} />
Notifications
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Notification preferences (coming soon)
</div>
</div>
</div>
</div>
</div>
</>
);
}
+201
View File
@@ -0,0 +1,201 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeftIcon } from '@/components/Icons';
import { MessageSquare, Check } from 'lucide-react';
export default function PrivacySettingsPage() {
const router = useRouter();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dmPrivacy, setDmPrivacy] = useState<'everyone' | 'following' | 'none'>('everyone');
const [status, setStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user);
setDmPrivacy(data.user.dmPrivacy || 'everyone');
}
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const handleSave = async (newValue: 'everyone' | 'following' | 'none') => {
setDmPrivacy(newValue);
setSaving(true);
setStatus(null);
try {
const res = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dmPrivacy: newValue }),
});
if (res.ok) {
setStatus({ type: 'success', message: 'Settings saved successfully' });
setTimeout(() => setStatus(null), 3000);
} else {
const data = await res.json();
setStatus({ type: 'error', message: data.error || 'Failed to save settings' });
}
} catch (error) {
setStatus({ type: 'error', message: 'An error occurred' });
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
Loading privacy settings...
</div>
);
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<header style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '32px',
}}>
<button
onClick={() => router.back()}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--foreground)',
display: 'flex',
alignItems: 'center'
}}
>
<ArrowLeftIcon />
</button>
<div>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>Privacy & Safety</h1>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
Message and social privacy preferences
</p>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
<MessageSquare size={20} style={{ color: 'var(--accent)' }} />
<h2 style={{ fontSize: '18px', fontWeight: 600, margin: 0 }}>Direct Messages</h2>
</div>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', marginBottom: '20px' }}>
Control who can send you direct messages on Synapsis.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
onClick={() => handleSave('everyone')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'everyone' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'everyone' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>Everyone</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>Anyone can message you</div>
</div>
{dmPrivacy === 'everyone' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
<button
onClick={() => handleSave('following')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'following' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'following' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>Following Only</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>Only accounts you follow can message you</div>
</div>
{dmPrivacy === 'following' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
<button
onClick={() => handleSave('none')}
disabled={saving}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: dmPrivacy === 'none' ? 'var(--accent-muted)' : 'var(--background-secondary)',
border: '1px solid',
borderColor: dmPrivacy === 'none' ? 'var(--accent)' : 'var(--border)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.15s ease',
width: '100%',
color: 'var(--foreground)',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>None</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>No one can send you new messages</div>
</div>
{dmPrivacy === 'none' && <Check size={18} style={{ color: 'var(--accent)' }} />}
</button>
</div>
</div>
{status && (
<div style={{
padding: '12px',
borderRadius: '8px',
background: status.type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: status.type === 'success' ? '#22c55e' : '#ef4444',
fontSize: '14px',
textAlign: 'center',
marginTop: '16px',
}}>
{status.message}
</div>
)}
</div>
</div>
);
}