Files
nexusAI/packages/chat-client/src/components/SettingsView.jsx
2026-04-18 02:28:01 -07:00

360 lines
12 KiB
JavaScript
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.
import React, { useState, useEffect, useCallback } from 'react';
import { useSettings } from '../hooks/useSettings';
import { getServiceHealth } from '../api/orchestration';
import { useModels } from '../hooks/useModels';
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 (01)"
value={settings?.scoreThreshold}
min={0} max={1} step={0.05}
onSave={val => saveSetting('scoreThreshold', val)}
saving={saving}
/>
</SettingsSection>
<SettingsSection title="Models">
<ModelsSection />
</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: 'flex-start', 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>
);
}
function ModelsSection({ onNavigate }) {
const { models, selectedModel, setSelectedModel } = useModels();
const [selectedInfo, setSelectedInfo] = useState(null);
// Sync info panel when selection changes
useEffect(() => {
const m = models.find(m => m.value === selectedModel);
setSelectedInfo(m ?? null);
}, [selectedModel, models]);
return (
<>
<SettingsRow
label="Models Folder"
description="Path to folder containing .gguf files"
action={<ModelsFolderSetting />}
/>
<SettingsRow
label="Active Model"
description="Model used for inference"
action={
<select
value={selectedModel}
onChange={e => setSelectedModel(e.target.value)}
style={{
padding: '6px 10px', fontSize: '13px',
background: 'var(--bg-elevated)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)', color: 'var(--text-primary)',
cursor: 'pointer', outline: 'none', maxWidth: '220px',
}}
>
{models.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
}
/>
{/* Model info panel */}
{selectedInfo && (
<div style={{
margin: '0', padding: '14px 16px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-elevated)',
display: 'flex', flexDirection: 'column', gap: 8,
}}>
<p className="label-upper" style={{ color: 'var(--text-muted)' }}>Model Info</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<InfoLine label="File" value={selectedInfo.value} mono />
<InfoLine label="Size" value={selectedInfo.size ?? '—'} />
{selectedInfo.description && (
<InfoLine label="Description" value={selectedInfo.description} />
)}
</div>
<p className="text-xs text-muted" style={{ marginTop: 4, fontStyle: 'italic' }}>
Model loading and parameter configuration coming soon
</p>
</div>
)}
</>
);
}
function InfoLine({ label, value, mono }) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
<span className="text-xs text-muted" style={{ minWidth: 72, flexShrink: 0 }}>{label}</span>
<span style={{
fontSize: 12, color: 'var(--text-secondary)',
fontFamily: mono ? 'monospace' : 'inherit',
wordBreak: 'break-all',
}}>{value}</span>
</div>
);
}
function ModelsFolderSetting() {
const { settings, saveSetting, saving } = useSettings();
const [local, setLocal] = useState('');
const [error, setError] = useState(null);
useEffect(() => {
if (settings?.modelsFolderPath) setLocal(settings.modelsFolderPath);
}, [settings?.modelsFolderPath]);
const isDirty = local !== '' && local !== settings?.modelsFolderPath;
async function handleSave() {
setError(null);
try {
await saveSetting('modelsFolderPath', local);
} catch (err) {
setError('Path not accessible');
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
value={local}
onChange={e => { setLocal(e.target.value); setError(null); }}
style={{
width: '220px', padding: '5px 8px', fontSize: '12px',
fontFamily: 'monospace',
background: 'var(--bg-elevated)', border: `1px solid ${error ? '#e74c3c' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)', color: 'var(--text-primary)', outline: 'none',
}}
/>
{isDirty && (
<button className="btn-primary" style={{ padding: '5px 10px', fontSize: '12px' }}
disabled={saving} onClick={handleSave}>
Save
</button>
)}
</div>
{error && <span className="text-xs" style={{ color: '#e74c3c' }}>{error}</span>}
</div>
);
}