Files
aaronai-web/components/SettingsPanel.tsx
T

383 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { api, auth } from '@/lib/api';
import type { Status, DreamerStatus } from '@/lib/api';
export default function SettingsPanel() {
const { settingsOpen, setSettingsOpen, settings, setSettings } = useStore();
const [status, setStatus] = useState<Status | null>(null);
const [memory, setMemory] = useState('');
const [editingMemory, setEditingMemory] = useState(false);
const [reindexing, setReindexing] = useState(false);
const [dreamerStatus, setDreamerStatus] = useState<DreamerStatus | null>(null);
const [dreamMode, setDreamMode] = useState('nrem');
const [dreamTask, setDreamTask] = useState('');
const [dreaming, setDreaming] = useState(false);
const [dreamStarted, setDreamStarted] = useState(false);
function formatDreamerTime(raw: string): string {
if (!raw || raw === 'never' || raw === '—') return raw;
try {
// Parse "YYYY-MM-DD HH:MM" as UTC
const utc = new Date(raw.replace(' ', 'T') + ':00Z');
return utc.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
hour12: true,
});
} catch {
return raw;
}
}
useEffect(() => {
if (!settingsOpen) return;
api.getStatus().then(setStatus).catch(console.error);
api.getDreamerStatus().then(setDreamerStatus).catch(console.error);
api.getMemory().then(d => setMemory(d.content)).catch(console.error);
}, [settingsOpen]);
async function updateSetting(key: string, value: unknown) {
const updated = { ...settings, [key]: value };
setSettings(updated);
await api.updateSettings(updated);
}
async function saveMemory() {
await api.updateMemory(memory);
setEditingMemory(false);
}
async function triggerDream() {
setDreaming(true);
setDreamStarted(false);
const result = await api.runDreamer(dreamMode, dreamTask || undefined);
setDreaming(false);
if (result.started) {
setDreamStarted(true);
setTimeout(() => setDreamStarted(false), 4000);
setTimeout(() => api.getDreamerStatus().then(setDreamerStatus), 5000);
}
}
async function triggerReindex() {
setReindexing(true);
await api.reindex();
setTimeout(() => setReindexing(false), 3000);
}
async function exportConversation() {
const messages = useStore.getState().messages;
if (!messages.length) { alert('No messages to export.'); return; }
let md = `# Conversation Export\n\nExported: ${new Date().toLocaleString()}\n\n---\n\n`;
messages.forEach(m => {
md += `**${m.role === 'user' ? 'You' : 'Aaron AI'}**\n\n${m.content}\n\n`;
if (m.sources?.length) md += `*Sources: ${m.sources.join(', ')}*\n\n`;
md += '---\n\n';
});
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([md], { type: 'text/markdown' }));
a.download = `conversation-${Date.now()}.md`;
a.click();
}
async function logout() {
await auth.logout();
window.location.href = '/login';
}
async function clearAll() {
if (!confirm('Delete all conversations permanently?')) return;
await api.clearAllConversations();
useStore.getState().setConversations([]);
useStore.getState().setCurrentId(null);
useStore.getState().setMessages([]);
}
return (
<>
<div
className={`fixed top-0 right-0 bottom-0 z-50 flex flex-col transition-transform duration-200 ${
settingsOpen ? 'translate-x-0' : 'translate-x-full'
}`}
style={{
width: '340px',
background: 'var(--bg)',
borderLeft: '1px solid var(--border)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
style={{ borderBottom: '1px solid var(--border)' }}
>
<h2 className="text-sm font-medium" style={{ color: 'var(--text)' }}>Settings</h2>
<button
onClick={() => setSettingsOpen(false)}
style={{ background: 'none', border: 'none', color: 'var(--text3)', fontSize: '20px', cursor: 'pointer', padding: '2px 6px', lineHeight: 1 }}
>
×
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
{/* Appearance */}
<Section title="Appearance">
<Row label="Dark mode" desc="Switch between light and dark theme">
<Toggle
on={settings.theme === 'dark'}
onChange={v => updateSetting('theme', v ? 'dark' : 'light')}
/>
</Row>
<Row label="Font size">
<select
value={settings.font_size}
onChange={e => updateSetting('font_size', e.target.value)}
className="rounded-md px-2 py-1 text-sm"
style={{ background: 'var(--bg3)', border: '1px solid var(--border2)', color: 'var(--text)', fontFamily: 'var(--font-sans)' }}
>
<option value="small">Small (13px)</option>
<option value="medium">Medium (15px)</option>
<option value="large">Large (17px)</option>
</select>
</Row>
</Section>
{/* Corpus */}
<Section title="Corpus">
<div className="grid grid-cols-2 gap-2 mb-3">
<StatCard number={(status?.chunk_count || 0).toLocaleString()} label="total chunks" />
<StatCard number={(status?.file_count || 0).toLocaleString()} label="files indexed" />
</div>
<Row label="Last indexed" desc={status?.last_indexed || 'Unknown'}>
<SBtn primary onClick={triggerReindex} disabled={reindexing}>
{reindexing ? 'Starting...' : 'Re-index'}
</SBtn>
</Row>
<Row label="Web search" desc="Search the web for current information">
<Toggle on={settings.web_search} onChange={v => updateSetting('web_search', v)} />
</Row>
<Row label="Show sources" desc="Display document sources under responses">
<Toggle on={settings.show_sources} onChange={v => updateSetting('show_sources', v)} />
</Row>
</Section>
{/* Memory */}
<Section title="Memory">
{editingMemory ? (
<>
<textarea
value={memory}
onChange={e => setMemory(e.target.value)}
className="w-full rounded-lg px-3 py-2 text-xs leading-relaxed resize-y mb-2"
style={{
background: 'var(--bg3)',
border: '1px solid var(--border2)',
color: 'var(--text)',
fontFamily: 'var(--font-mono)',
minHeight: '140px',
}}
/>
<div className="flex gap-2">
<SBtn primary onClick={saveMemory}>Save</SBtn>
<SBtn onClick={() => setEditingMemory(false)}>Cancel</SBtn>
</div>
</>
) : (
<>
<div
className="rounded-lg px-3 py-2 text-xs leading-relaxed mb-2 overflow-hidden"
style={{
background: 'var(--bg3)',
color: 'var(--text2)',
fontFamily: 'var(--font-mono)',
maxHeight: '90px',
whiteSpace: 'pre-wrap',
}}
>
{memory.split('\n').slice(0, 6).join('\n') || 'No memory yet'}
</div>
<SBtn primary onClick={() => setEditingMemory(true)}>Edit memory</SBtn>
</>
)}
</Section>
{/* Conversations */}
<Section title="Conversations">
<Row label="Stored" desc="">
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
{status?.conversation_count || 0}
</span>
</Row>
<Row label="Export current" desc="Download as markdown">
<SBtn onClick={exportConversation}>Export</SBtn>
</Row>
<Row label="Clear all" desc="Permanently delete history">
<SBtn danger onClick={clearAll}>Clear</SBtn>
</Row>
</Section>
{/* Dreamer */}
<Section title="Dreamer">
<div className="grid grid-cols-2 gap-2 mb-3">
<StatCard number={dreamerStatus?.last_mode?.toUpperCase() || '—'} label="last mode" />
<StatCard number={formatDreamerTime(dreamerStatus?.last_dream || '—')} label="last dream" />
</div>
<Row label="Mode">
<select
value={dreamMode}
onChange={e => setDreamMode(e.target.value)}
className="rounded-md px-2 py-1 text-sm"
style={{ background: 'var(--bg3)', border: '1px solid var(--border2)', color: 'var(--text)', fontFamily: 'var(--font-sans)' }}
>
<option value="nrem">NREM consolidation</option>
<option value="early-rem">Early REM personal</option>
<option value="late-rem">Late REM associative</option>
<option value="lucid">Lucid targeted</option>
</select>
</Row>
{dreamMode === 'lucid' && (
<div className="py-2" style={{ borderBottom: '1px solid var(--border)' }}>
<input
value={dreamTask}
onChange={e => setDreamTask(e.target.value)}
placeholder="What question should the dreamer pursue?"
className="w-full rounded-md px-3 py-2 text-xs"
style={{ background: 'var(--bg3)', border: '1px solid var(--border2)', color: 'var(--text)', fontFamily: 'var(--font-sans)' }}
/>
</div>
)}
<Row label="Dream now" desc={dreamStarted ? 'Dream started — check Journal/Dreams/' : 'Run the dreamer immediately'}>
<SBtn primary onClick={triggerDream} disabled={dreaming || (dreamMode === 'lucid' && !dreamTask.trim())}>
{dreaming ? 'Starting...' : dreamStarted ? 'Started ✓' : 'Dream'}
</SBtn>
</Row>
</Section>
{/* System */}
<Section title="System">
<StatusRow label="Aaron AI service" value={status?.aaron_ai || 'unknown'} ok={status?.aaron_ai === 'running'} />
<StatusRow label="File watcher" value={status?.watcher || 'unknown'} ok={status?.watcher === 'running'} />
<StatusRow label="Nextcloud files" value={`${(status?.file_count || 0).toLocaleString()} files`} ok={true} />
<StatusRow label="Model" value={status?.model || 'unknown'} ok={true} yellow />
</Section>
<Section title="Account">
<Row label="Sign out" desc="End your current session">
<SBtn danger onClick={logout}>Sign out</SBtn>
</Row>
</Section>
</div>
</div>
</>
);
}
// Sub-components
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--text3)', letterSpacing: '0.06em' }}>
{title}
</div>
<div>{children}</div>
</div>
);
}
function Row({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 gap-3" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="flex-1 min-w-0">
<div className="text-sm" style={{ color: 'var(--text)' }}>{label}</div>
{desc && <div className="text-xs mt-0.5" style={{ color: 'var(--text3)' }}>{desc}</div>}
</div>
{children}
</div>
);
}
function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
return (
<button
onClick={() => onChange(!on)}
className="flex-shrink-0 rounded-full relative transition-colors"
style={{
width: '36px',
height: '20px',
background: on ? 'var(--accent)' : 'var(--border2)',
border: 'none',
cursor: 'pointer',
}}
>
<span
className="absolute rounded-full bg-white transition-all"
style={{
width: '16px',
height: '16px',
top: '2px',
left: on ? '18px' : '2px',
}}
/>
</button>
);
}
function SBtn({ children, onClick, primary, danger, disabled }: {
children: React.ReactNode;
onClick?: () => void;
primary?: boolean;
danger?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="rounded-md px-3 py-1 text-xs whitespace-nowrap transition-opacity"
style={{
background: 'none',
border: `1px solid ${danger ? '#f7c1c1' : primary ? 'var(--accent-border)' : 'var(--border2)'}`,
color: danger ? '#a32d2d' : primary ? 'var(--accent)' : 'var(--text2)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
fontFamily: 'var(--font-sans)',
}}
>
{children}
</button>
);
}
function StatCard({ number, label }: { number: string; label: string }) {
return (
<div className="rounded-lg px-3 py-2" style={{ background: 'var(--accent-light)', border: '1px solid var(--accent-border)' }}>
<div className="text-xl font-medium" style={{ color: 'var(--accent)' }}>{number}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text3)' }}>{label}</div>
</div>
);
}
function StatusRow({ label, value, ok, yellow }: { label: string; value: string; ok: boolean; yellow?: boolean }) {
return (
<div className="flex items-center gap-2 py-2" style={{ borderBottom: '1px solid var(--border)' }}>
<span
className="rounded-full flex-shrink-0"
style={{
width: '8px',
height: '8px',
background: yellow ? '#c8821a' : ok ? '#2d7a3d' : '#a32d2d',
}}
/>
<span className="text-sm flex-1" style={{ color: 'var(--text)' }}>{label}</span>
<span className="text-xs" style={{ color: 'var(--text3)' }}>{value}</span>
</div>
);
}