240 lines
8.1 KiB
JavaScript
240 lines
8.1 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { useSettings } from '../hooks/useSettings';
|
||
import { getServiceHealth } from '../api/orchestration';
|
||
|
||
export default function SettingsView({ onNavigate }) {
|
||
const { settings, saveSetting, saving } = useSettings();
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--bg-base)' }}>
|
||
<div className="panel-header" style={{ padding: '0 24px' }}>
|
||
<span className="text-base" style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>Settings</span>
|
||
</div>
|
||
|
||
<div className="flex-1 scroll-y" style={{ padding: '24px' }}>
|
||
|
||
<SettingsSection title="Memory">
|
||
<SettingsRow
|
||
label="Memory Viewer"
|
||
description="Browse, search, and delete stored episodes"
|
||
action={<button className="btn-primary" style={{ padding: '6px 14px', fontSize: '13px' }}
|
||
onClick={() => onNavigate('memory')}>Open →</button>}
|
||
/>
|
||
<NumberSetting
|
||
label="Recent Episode Limit"
|
||
description="Recent episodes injected into each prompt"
|
||
value={settings?.recentEpisodeLimit}
|
||
min={1} max={20}
|
||
onSave={val => saveSetting('recentEpisodeLimit', val)}
|
||
saving={saving}
|
||
/>
|
||
<NumberSetting
|
||
label="Semantic Search Limit"
|
||
description="Max episodes retrieved via vector search per query"
|
||
value={settings?.semanticLimit}
|
||
min={1} max={20}
|
||
onSave={val => saveSetting('semanticLimit', val)}
|
||
saving={saving}
|
||
/>
|
||
<NumberSetting
|
||
label="Score Threshold"
|
||
description="Minimum similarity score for semantic results (0–1)"
|
||
value={settings?.scoreThreshold}
|
||
min={0} max={1} step={0.05}
|
||
onSave={val => saveSetting('scoreThreshold', val)}
|
||
saving={saving}
|
||
/>
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Models">
|
||
<SettingsRow label="Active Model" description="Model used for inference" action={<ComingSoon />} />
|
||
<SettingsRow label="Temperature" description="Response creativity / randomness" action={<ComingSoon />} />
|
||
<SettingsRow label="Context Window" description="Max tokens per request (-c flag)" action={<ComingSoon />} />
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="About">
|
||
<SettingsRow
|
||
label="Service Health"
|
||
description="Ping all four services"
|
||
action={<ServiceHealth />}
|
||
/>
|
||
<SettingsRow
|
||
label="Version"
|
||
description="NexusAI"
|
||
action={<span className="text-sm text-muted">v0.1.0</span>}
|
||
/>
|
||
</SettingsSection>
|
||
|
||
<SettingsSection title="Appearance">
|
||
<SettingsRow label="Theme" description="UI colour scheme" action={<ComingSoon />} />
|
||
</SettingsSection>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SettingsSection({ title, children }) {
|
||
return (
|
||
<div style={{ marginBottom: '32px' }}>
|
||
<p className="label-upper" style={{ marginBottom: '12px', color: 'var(--text-secondary)' }}>
|
||
{title}
|
||
</p>
|
||
<div style={{
|
||
background: 'var(--bg-surface)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 'var(--radius-lg)',
|
||
overflow: 'hidden',
|
||
}}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NumberSetting({ label, description, value, min, max, step = 1, onSave, saving }) {
|
||
const [local, setLocal] = useState(value ?? '');
|
||
const isDirty = local !== '' && Number(local) !== value;
|
||
|
||
// Sync when settings load from API
|
||
useEffect(() => {
|
||
if (value !== undefined) setLocal(value);
|
||
}, [value]);
|
||
|
||
return (
|
||
<SettingsRow
|
||
label={label}
|
||
description={description}
|
||
action={
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<input
|
||
type="number"
|
||
value={local}
|
||
min={min} max={max} step={step}
|
||
onChange={e => setLocal(e.target.value)}
|
||
style={{
|
||
width: '64px', padding: '5px 8px', textAlign: 'center',
|
||
background: 'var(--bg-elevated)', border: '1px solid var(--border)',
|
||
borderRadius: 'var(--radius-md)', color: 'var(--text-primary)',
|
||
fontSize: '13px', outline: 'none',
|
||
}}
|
||
/>
|
||
{isDirty && (
|
||
<button
|
||
className="btn-primary"
|
||
style={{ padding: '5px 10px', fontSize: '12px' }}
|
||
disabled={saving}
|
||
onClick={() => onSave(Number(local))}
|
||
>
|
||
Save
|
||
</button>
|
||
)}
|
||
</div>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function SettingsRow({ label, description, action }) {
|
||
return (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '14px 16px',
|
||
borderBottom: '1px solid var(--border)',
|
||
}}
|
||
// Remove bottom border on last child via CSS would need a class;
|
||
// easiest to just let it render — the section border-radius clips it cleanly
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<span className="text-sm" style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{label}</span>
|
||
{description && <span className="text-xs text-muted">{description}</span>}
|
||
</div>
|
||
<div style={{ flexShrink: 0, marginLeft: 16 }}>
|
||
{action}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComingSoon() {
|
||
return <span className="text-xs text-muted" style={{ fontStyle: 'italic' }}>Coming soon</span>;
|
||
}
|
||
|
||
function ServiceHealth() {
|
||
const [services, setServices] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [lastChecked, setLastChecked] = useState(null);
|
||
|
||
const check = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
setServices(await getServiceHealth());
|
||
setLastChecked(new Date());
|
||
} catch (err) {
|
||
console.error('[ServiceHealth]', err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<button
|
||
className="btn-primary"
|
||
style={{ padding: '5px 12px', fontSize: '12px' }}
|
||
disabled={loading}
|
||
onClick={check}
|
||
>
|
||
{loading ? 'Checking…' : 'Check Now'}
|
||
</button>
|
||
{lastChecked && (
|
||
<span className="text-xs text-muted">
|
||
{lastChecked.toLocaleTimeString()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{services && (
|
||
<div style={{
|
||
display: 'flex', flexDirection: 'column',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 'var(--radius-md)',
|
||
overflow: 'hidden', marginTop: 4,
|
||
}}>
|
||
{services.map((svc, i) => (
|
||
<div key={svc.key} style={{
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
padding: '8px 12px',
|
||
borderBottom: i < services.length - 1 ? '1px solid var(--border)' : 'none',
|
||
background: 'var(--bg-elevated)',
|
||
}}>
|
||
{/* Status dot */}
|
||
<div style={{
|
||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||
background: svc.status === 'healthy' ? '#2ecc71' : '#e74c3c',
|
||
}} />
|
||
|
||
{/* Label */}
|
||
<span className="text-sm" style={{ minWidth: 90, color: 'var(--text-primary)' }}>
|
||
{svc.label}
|
||
</span>
|
||
|
||
{/* Detail — show model for inference, nothing extra for others */}
|
||
<span className="text-xs text-muted" style={{ flex: 1 }}>
|
||
{svc.key === 'inference' && svc.detail?.model
|
||
? svc.detail.model
|
||
: svc.status === 'unreachable' ? 'Unreachable' : ''}
|
||
</span>
|
||
|
||
{/* Latency */}
|
||
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>
|
||
{svc.latency}ms
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} |