Files
aaronai-web/components/SettingsPanel.tsx
T

310 lines
11 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 } from '@/lib/api';
import type { Status } 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);
useEffect(() => {
if (!settingsOpen) return;
api.getStatus().then(setStatus).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 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 clearAll() {
if (!confirm('Delete all conversations permanently?')) return;
await api.clearAllConversations();
useStore.getState().setConversations([]);
useStore.getState().setCurrentId(null);
useStore.getState().setMessages([]);
}
return (
<>
{/* Backdrop on mobile */}
{settingsOpen && (
<div
className="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setSettingsOpen(false)}
/>
)}
<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: 'min(340px, 100vw)',
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>
{/* 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>
</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>
);
}