Files
sarthi_lab/src/screens/SettingsScreen.jsx

445 lines
21 KiB
JavaScript

import { useState, useEffect, useCallback } from 'react';
import useLabStore from '../store/labStore';
import { listOllamaModels, getOllamaModel, setOllamaModel } from '../services/localLLM';
// ── Sub-components ────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
if (status.usesSay) {
return (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
Ready (macOS Voice)
</span>
);
}
if (status.available) {
return (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
Ready
</span>
);
}
if (status.modelsDownloaded && !status.binaryPresent) {
return (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
Binary missing
</span>
);
}
if (!status.modelsDownloaded && status.binaryPresent) {
return (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
Model not installed
</span>
);
}
return (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-white/[0.04] text-slate-500 border border-white/[0.06]">
Not installed
</span>
);
}
function ProgressBar({ progress }) {
const { status, percent, file, error } = progress || {};
if (status === 'error') {
return (
<p className="text-xs text-red-400 mb-3">
Installation failed{error ? `: ${error}` : ''}. Check your internet connection and try again.
</p>
);
}
if (status === 'exists' || status === 'complete') {
return (
<div className="flex items-center gap-2 mb-3">
<div className="w-4 h-4 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-2.5 h-2.5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<p className="text-xs text-emerald-400">{file ? `${file}` : ''}{status === 'exists' ? 'already installed' : 'done'}</p>
</div>
);
}
if (status === 'downloading' || status === 'starting') {
const pct = typeof percent === 'number' ? percent : null;
return (
<div className="space-y-1.5 mb-3">
<div className="flex-1 h-1.5 bg-white/[0.06] rounded-full overflow-hidden">
<div
className={`h-full progress-bar-fill rounded-full transition-all duration-300 ${pct === null ? 'animate-pulse' : ''}`}
style={{ width: pct !== null ? `${pct}%` : '40%' }}
/>
</div>
<p className="text-xs text-slate-400">
{pct !== null ? `${pct}%` : 'Preparing…'}
{file ? `${file}` : ''}
</p>
</div>
);
}
return null;
}
// ── Main screen ───────────────────────────────────────────────────────────────
export default function SettingsScreen() {
const { navigate, useLocalAI, setUseLocalAI, theme, setTheme } = useLabStore();
const [downloading, setDownloading] = useState({ piper: false, whisper: false });
const [status, setStatus] = useState({
piper: { available: false, modelsDownloaded: false, binaryPresent: false },
whisper: { available: false, modelsDownloaded: false, binaryPresent: false },
});
const [progress, setProgress] = useState({});
const [ollamaModels, setOllamaModels] = useState([]);
const [selectedModel, setSelectedModel] = useState(getOllamaModel());
const [modelSaved, setModelSaved] = useState(false);
const checkStatus = useCallback(async () => {
if (!window.electronAPI?.download) return;
try {
const s = await window.electronAPI.download.status();
setStatus({
piper: s.piper || { available: false, modelsDownloaded: false, binaryPresent: false },
whisper: s.whisper || { available: false, modelsDownloaded: false, binaryPresent: false },
});
} catch (err) {
console.warn('Failed to check download status:', err);
}
}, []);
useEffect(() => {
checkStatus();
listOllamaModels().then(setOllamaModels);
if (window.electronAPI) {
window.electronAPI.config.get('ollamaModel').then(saved => {
if (saved) { setOllamaModel(saved); setSelectedModel(saved); }
});
window.electronAPI.config.get('useLocalAI').then(saved => {
if (saved != null) setUseLocalAI(saved);
});
}
// Register progress listener — capture the returned cleanup fn
let cleanup;
if (window.electronAPI?.download?.onProgress) {
cleanup = window.electronAPI.download.onProgress((data) => {
setProgress(prev => ({ ...prev, [data.type]: data }));
if (data.status === 'complete' || data.status === 'error') {
setDownloading(prev => ({ ...prev, [data.type]: false }));
checkStatus();
}
});
}
return () => { if (typeof cleanup === 'function') cleanup(); };
}, [checkStatus, setUseLocalAI]);
const downloadModel = async (type) => {
if (!window.electronAPI?.download) return;
setDownloading(prev => ({ ...prev, [type]: true }));
setProgress(prev => ({ ...prev, [type]: { status: 'starting', percent: 0 } }));
try {
await window.electronAPI.download.start(type);
await checkStatus();
} catch (err) {
console.error('Download failed:', err);
setProgress(prev => ({ ...prev, [type]: { status: 'error', error: err.message } }));
} finally {
setDownloading(prev => ({ ...prev, [type]: false }));
}
};
const saveOllamaModel = async (model) => {
setOllamaModel(model);
setSelectedModel(model);
if (window.electronAPI) await window.electronAPI.config.set('ollamaModel', model);
setModelSaved(true);
setTimeout(() => setModelSaved(false), 2000);
};
const toggleLocalAI = async (value) => {
setUseLocalAI(value);
if (window.electronAPI) await window.electronAPI.config.set('useLocalAI', value);
};
const isElectron = !!window.electronAPI;
// ── Model card helper ───────────────────────────────────────────────────────
const platform = window.electronAPI?.platform;
const ModelCard = ({ type, label, subtitle, iconColor, iconBg, icon, size }) => {
const s = status[type];
const isDownloading = downloading[type];
const prog = progress[type];
// macOS TTS is always ready via built-in 'say' — no install needed
const isAutoReady = s.usesSay;
const alreadyInstalled = s.modelsDownloaded && s.binaryPresent;
return (
<div className="glass rounded-2xl p-5 mb-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl ${iconBg} flex items-center justify-center`}>
<svg className={`w-5 h-5 ${iconColor}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
{icon}
</svg>
</div>
<div>
<h3 className="font-semibold text-sm">{label}</h3>
<p className="text-xs text-slate-500">{subtitle}</p>
</div>
</div>
<StatusBadge status={s} />
</div>
{/* Binary missing warning */}
{!isAutoReady && s.modelsDownloaded && !s.binaryPresent && (
<div className="bg-amber-500/10 border border-amber-500/20 rounded-xl px-3 py-2 mb-3">
<p className="text-xs text-amber-400">
Model installed but the {type === 'piper' ? 'Piper' : 'Whisper'} runtime is missing.
Click Install to download it automatically.
</p>
</div>
)}
<p className="text-xs text-slate-400 mb-4">
{isAutoReady
? 'Uses macOS built-in voice synthesis — works offline with no setup required.'
: `Installs an offline ${type === 'piper' ? 'voice synthesis' : 'speech recognition'} engine (${size}). Enables ${type === 'piper' ? 'voice output' : 'voice input'} without internet after installation.`
}
</p>
{/* Progress bar (shown while installing) */}
{isDownloading && <ProgressBar progress={prog} />}
{/* Install button — hidden for auto-ready and while installing */}
{!isAutoReady && !isDownloading && (
<button
onClick={() => downloadModel(type)}
disabled={!isElectron || alreadyInstalled}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
alreadyInstalled
? 'bg-white/[0.04] text-slate-500 cursor-not-allowed'
: 'accent-gradient accent-glow-sm text-white'
}`}
>
{alreadyInstalled ? 'Installed' : 'Install'}
</button>
)}
</div>
);
};
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="min-h-screen bg-mesh text-white flex flex-col">
<header className="glass border-0 border-b border-white/[0.06] px-5 py-3 flex items-center gap-3">
<button
onClick={() => navigate('dashboard')}
className="text-slate-400 hover:text-white transition-colors p-1.5 rounded-lg hover:bg-white/[0.05]"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
<p className="font-semibold text-sm">Settings</p>
</header>
<main className="flex-1 p-6 max-w-2xl mx-auto w-full animate-slide-up">
<h1 className="text-xl font-bold mb-6 tracking-tight">AI Models</h1>
{!isElectron && (
<div className="bg-amber-500/10 border border-amber-500/20 rounded-2xl p-4 mb-6 animate-fade-in">
<p className="text-amber-400 text-sm">
Model downloads are only available in the desktop app. You are currently running in browser mode.
</p>
</div>
)}
{/* TTS */}
<ModelCard
type="piper"
label="Text-to-Speech (TTS)"
subtitle="Piper — Offline voice synthesis"
iconColor="text-blue-400"
iconBg="bg-blue-500/15"
size="~63 MB"
icon={
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
}
/>
{/* STT */}
<ModelCard
type="whisper"
label="Speech-to-Text (STT)"
subtitle="Whisper — Offline voice recognition"
iconColor="text-rose-400"
iconBg="bg-rose-500/15"
size="~75 MB"
icon={
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
}
/>
{/* AI Source */}
<div className="glass rounded-2xl p-5 mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/15 flex items-center justify-center">
<svg className="w-5 h-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-sm">AI Source</h3>
<p className="text-xs text-slate-500">
{useLocalAI ? 'Using local Ollama model' : 'Using Sarthi Cloud AI (default)'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs ${!useLocalAI ? 'text-indigo-400 font-semibold' : 'text-slate-600'}`}>Cloud</span>
<button
onClick={() => toggleLocalAI(!useLocalAI)}
disabled={useLocalAI && ollamaModels.length === 0}
className={`relative w-11 h-6 rounded-full transition-colors ${
useLocalAI ? 'bg-emerald-500' : 'bg-white/[0.1]'
} disabled:opacity-40`}
>
<span className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-all ${
useLocalAI ? 'left-6' : 'left-1'
}`} />
</button>
<span className={`text-xs ${useLocalAI ? 'text-emerald-400 font-semibold' : 'text-slate-600'}`}>Local</span>
</div>
</div>
{useLocalAI && ollamaModels.length === 0 && (
<p className="text-xs text-amber-400 mt-3">
Ollama not detected at localhost:11434. Install Ollama and pull a model to use local AI.
</p>
)}
{!useLocalAI && (
<p className="text-xs text-slate-500 mt-3">
Sarthi Cloud uses the full NCERT curriculum prompt and tracks progress. Recommended for most students.
</p>
)}
</div>
{/* Ollama model selector */}
{useLocalAI && ollamaModels.length > 0 && (
<div className="glass rounded-2xl p-5 mb-4 animate-fade-in">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center">
<svg className="w-5 h-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-sm">Local AI Model (Ollama)</h3>
<p className="text-xs text-slate-500">Choose which model to use for offline chat</p>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{ollamaModels.map(m => (
<button
key={m}
onClick={() => saveOllamaModel(m)}
className={`px-3 py-1.5 rounded-lg text-xs font-mono transition-all ${
selectedModel === m
? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20'
: 'bg-white/[0.05] text-slate-300 hover:bg-white/[0.08] border border-white/[0.06]'
}`}
>
{m}
</button>
))}
</div>
{modelSaved && <p className="text-xs text-emerald-400 mt-2 animate-fade-in">Saved!</p>}
</div>
)}
{/* Theme */}
<div className="glass rounded-2xl p-5 mb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 0 0 5.304 0l6.401-6.402M6.75 21A3.75 3.75 0 0 1 3 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 0 0 3.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008Z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-sm">Theme</h3>
<p className="text-xs text-slate-500">Change the look and feel</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'indigo', name: 'Nebula', desc: 'Indigo & purple', colors: ['from-indigo-500 to-purple-600', 'from-purple-500 to-indigo-600', 'from-blue-500 to-indigo-500'] },
{ id: 'emerald', name: 'Forest', desc: 'Emerald & teal', colors: ['from-emerald-500 to-teal-600', 'from-teal-500 to-cyan-600', 'from-cyan-500 to-emerald-500'] },
].map(t => (
<button
key={t.id}
onClick={async () => { setTheme(t.id); if (window.electronAPI) await window.electronAPI.config.set('theme', t.id); }}
className={`relative rounded-xl p-4 border-2 transition-all ${
theme === t.id
? `border-${t.id === 'indigo' ? 'indigo' : 'emerald'}-500 bg-${t.id === 'indigo' ? 'indigo' : 'emerald'}-500/10`
: 'border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12]'
}`}
>
<div className="flex items-center gap-2 mb-3">
{t.colors.map((c, i) => (
<div key={i} className={`w-4 h-4 rounded-full bg-gradient-to-br ${c}`} />
))}
</div>
<p className="text-sm font-semibold text-white">{t.name}</p>
<p className="text-xs text-slate-500">{t.desc}</p>
{theme === t.id && (
<div className={`absolute top-2 right-2 w-5 h-5 rounded-full ${t.id === 'indigo' ? 'bg-indigo-500' : 'bg-emerald-500'} flex items-center justify-center`}>
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
)}
</button>
))}
</div>
</div>
{/* Info */}
<div className="glass rounded-2xl p-5">
<h3 className="font-semibold text-sm mb-2">About Offline Setup</h3>
<ul className="text-xs text-slate-400 space-y-1.5 mb-3">
<li className="flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-emerald-500/60 flex-shrink-0 mt-1.5" />
<span><strong className="text-slate-300">TTS (macOS):</strong> Uses the macOS built-in voice engine no installation needed, works offline automatically.</span>
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-slate-600 flex-shrink-0 mt-1.5" />
<span><strong className="text-slate-300">TTS (Linux):</strong> Click Install to download the Piper engine and voice model (~63 MB).</span>
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 rounded-full bg-slate-600 flex-shrink-0 mt-1.5" />
<span><strong className="text-slate-300">STT:</strong> Click Install to download the Whisper speech recognition model (~75 MB). Works fully offline after setup.</span>
</li>
</ul>
<p className="text-xs text-slate-500">
Models are stored in your user data folder and persist across app updates.
One-time install per device no internet required after setup.
</p>
</div>
</main>
</div>
);
}