501 lines
17 KiB
JavaScript
501 lines
17 KiB
JavaScript
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 (0–1)"
|
||
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 (0–2)"
|
||
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 (1–2)"
|
||
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 (0–1)"
|
||
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 (1–100)"
|
||
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>
|
||
);
|
||
} |