Files
nexusAI/packages/chat-client/src/components/SettingsView.jsx
2026-04-19 02:57:11 -07:00

501 lines
17 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 { useModels } from '../hooks/useModels';
import { getServiceHealth } from '../api/orchestration';
export default function SettingsView({ onNavigate, onBack, modelProps }) {
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 8px 0 8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<button className="btn-icon" onClick={onBack} title="Back" style={{ fontSize: '16px', padding: '4px 8px' }}></button>
<span className="text-base" style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>Settings</span>
</div>
</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">
<SettingsSectionErrorBoundary>
<ModelsSection settings={settings} saveSetting={saveSetting} saving={saving} modelProps={modelProps} />
</SettingsSectionErrorBoundary>
</SettingsSection>
{/* Global system prompt */}
<SettingsSection title="Behaviour">
<SystemPromptSetting settings={settings} saveSetting={saveSetting} saving={saving} />
</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>
);
}
// ── Error boundary ───────────────────────────────────────────
class SettingsSectionErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return (
<SettingsRow
label="Models unavailable"
description={this.state.error.message ?? 'Failed to load model settings'}
action={
<button className="btn-primary" style={{ padding: '5px 10px', fontSize: '12px' }}
onClick={() => this.setState({ error: null })}>
Retry
</button>
}
/>
);
}
return this.props.children;
}
}
// ── Layout components ────────────────────────────────────────
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 SettingsRow({ label, description, action }) {
return (
<div style={{
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
}}>
<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 NumberSetting({ label, description, value, min, max, step = 1, onSave, saving }) {
const [local, setLocal] = useState(value ?? '');
const isDirty = local !== '' && Number(local) !== value;
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 ComingSoon() {
return <span className="text-xs text-muted" style={{ fontStyle: 'italic' }}>Coming soon</span>;
}
// ── System prompt setting ────────────────────────────────────
function SystemPromptSetting({ settings, saveSetting, saving }) {
const [local, setLocal] = useState(settings?.systemPrompt ?? '');
const [savedPrompt, setSavedPrompt] = useState(settings?.systemPrompt ?? '');
useEffect(() => {
if (settings?.systemPrompt !== undefined) {
setLocal(settings.systemPrompt ?? '');
setSavedPrompt(settings.systemPrompt ?? '');
}
}, [settings?.systemPrompt]);
const isDirty = local !== savedPrompt;
async function handleSave() {
await saveSetting('systemPrompt', local.trim() || null);
setSavedPrompt(local);
}
return (
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '8px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span className="text-sm" style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
System Prompt
</span>
<span className="text-xs text-muted">
Default instruction given to the model on every request. Projects can override this.
</span>
</div>
{isDirty && (
<button
className="btn-primary"
style={{ padding: '5px 12px', fontSize: '12px', flexShrink: 0, marginLeft: '16px' }}
disabled={saving}
onClick={handleSave}
>
{saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
<textarea
value={local}
onChange={e => setLocal(e.target.value)}
rows={5}
style={{
width: '100%',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
padding: '10px 12px',
color: 'var(--text-primary)',
fontSize: '13px', lineHeight: '1.6',
resize: 'vertical', fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box',
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border)'}
/>
{!isDirty && local && (
<p className="text-xs text-muted" style={{ marginTop: '6px' }}>Saved</p>
)}
</div>
);
}
// ── Service health ───────────────────────────────────────────
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)',
}}>
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: svc.status === 'healthy' ? '#2ecc71' : '#e74c3c',
}} />
<span className="text-sm" style={{ minWidth: 90, color: 'var(--text-primary)' }}>
{svc.label}
</span>
<span className="text-xs text-muted" style={{ flex: 1 }}>
{svc.key === 'inference' && svc.detail?.model
? svc.detail.model
: svc.status === 'unreachable' ? 'Unreachable' : ''}
</span>
<span className="text-xs text-muted" style={{ flexShrink: 0 }}>
{svc.latency}ms
</span>
</div>
))}
</div>
)}
</div>
);
}
// ── Models section ───────────────────────────────────────────
function ModelsSection({ settings, saveSetting, saving, modelProps }) {
const { models, selectedModel, setSelectedModel } = useModels();
const [selectedInfo, setSelectedInfo] = useState(null);
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 settings={settings} saveSetting={saveSetting} saving={saving} />}
/>
<NumberSetting
label="Temperature"
description="Response randomness — lower is more focused, higher is more creative (02)"
value={settings?.temperature}
min={0} max={2} step={0.05}
onSave={val => saveSetting('temperature', val)}
saving={saving}
/>
<NumberSetting
label="Repeat Penalty"
description="Penalises repeated tokens — higher reduces repetition (12)"
value={settings?.repeatPenalty}
min={1} max={2} step={0.05}
onSave={val => saveSetting('repeatPenalty', val)}
saving={saving}
/>
<NumberSetting
label="Top-P"
description="Nucleus sampling — limits token pool by cumulative probability (01)"
value={settings?.topP}
min={0} max={1} step={0.05}
onSave={val => saveSetting('topP', val)}
saving={saving}
/>
<NumberSetting
label="Top-K"
description="Limits token pool to K most likely tokens per step (1100)"
value={settings?.topK}
min={1} max={100} step={1}
onSave={val => saveSetting('topK', val)}
saving={saving}
/>
<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>
}
/>
{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} />
)}
<InfoLine
label="Context"
value={modelProps?.contextWindow
? `${modelProps.contextWindow.toLocaleString()} tokens`
: '—'}
/>
<InfoLine
label="Loaded"
value={modelProps?.modelAlias ?? '—'}
mono
/>
</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({ settings, saveSetting, saving }) {
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>
);
}