307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { useStore } from '@/lib/store';
|
||
import { api, auth } 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 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>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|