Files
studyos/client/src/pages/Settings.jsx
renato97 4ff4302a8c feat: implement 33 nice-to-have features + fix 37 code review bugs
5 SDD batches archived:
- Batch 1: UI Polish (10 features, 14 tasks)
- Batch 2: Study System (8 features, 23 tasks)
- Batch 3: Infrastructure (5 features, 22 tasks)
- Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers
- Batch 5: Core Features (5 features, 19 tasks)

37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW):
- SSE streaming now works (event.token check)
- API keys no longer exposed via GET /api/models
- FTS5 injection sanitized
- DB backup/restore with admin auth
- Buddy mode wired (buddy_meta column)
- Exam auto-submit stale closure fixed
- CSS variables aligned with design tokens
- Progress data corruption fixed
- WebSocket protocol auto-detection
- Tests infrastructure completed (vitest + node:test)
2026-06-08 18:18:47 -03:00

848 lines
26 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts';
import {
X,
Plus,
Trash2,
TestTube,
Loader2,
AlertTriangle,
} from 'lucide-react';
import {
getModels,
createModel,
updateModel,
deleteModel,
testModel,
getConfig,
updateConfig,
testVlm,
getPdfs,
deletePdf,
getProgress,
resetProgressTopic,
backupDatabase,
restoreDatabase,
downloadExamPdf,
} from '../lib/api';
function ToggleSwitch({ checked, onChange }) {
return (
<button
className={`toggle-switch ${checked ? 'on' : ''}`}
onClick={() => onChange(!checked)}
aria-label="Toggle"
/>
);
}
function Modal({ title, onClose, children }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>{title}</h3>
<button onClick={onClose} className="modal-close-btn">
<X size={16} />
</button>
</div>
{children}
</div>
</div>
);
}
export default function Settings() {
const [activeTab, setActiveTab] = useState('models');
// Models tab
const [models, setModels] = useState([]);
const [modelModalOpen, setModelModalOpen] = useState(false);
const [editingModel, setEditingModel] = useState(null);
const [modelForm, setModelForm] = useState({
name: '',
api_base: '',
api_key: '',
provider: 'openai',
is_default_main: false,
is_default_fork: false,
is_default_exam: false,
});
const [modelLoading, setModelLoading] = useState(false);
const [testResults, setTestResults] = useState({});
// PDFs & VLM tab
const [pdfs, setPdfs] = useState([]);
const [vlmUrl, setVlmUrl] = useState('');
const [vlmSaving, setVlmSaving] = useState(false);
const [vlmTestResult, setVlmTestResult] = useState(null);
// Progress tab
const [progress, setProgress] = useState([]);
const [restoreFile, setRestoreFile] = useState(null);
const [restoreLoading, setRestoreLoading] = useState(false);
const refreshModels = useCallback(async () => {
try {
const rows = await getModels();
setModels(rows);
} catch (err) {
console.error('[Settings] refresh models error:', err.message);
}
}, []);
const refreshPdfs = useCallback(async () => {
try {
const rows = await getPdfs();
setPdfs(rows);
} catch (err) {
console.error('[Settings] refresh pdfs error:', err.message);
}
}, []);
const refreshProgress = useCallback(async () => {
try {
const rows = await getProgress();
setProgress(rows);
} catch (err) {
console.error('[Settings] refresh progress error:', err.message);
}
}, []);
const loadConfig = useCallback(async () => {
try {
const cfg = await getConfig();
if (cfg.vlm_endpoint) {
setVlmUrl(cfg.vlm_endpoint);
}
} catch (err) {
console.error('[Settings] load config error:', err.message);
}
}, []);
useEffect(() => {
refreshModels();
refreshPdfs();
refreshProgress();
loadConfig();
}, []);
// Model handlers
const openAddModel = () => {
setEditingModel(null);
setModelForm({
name: '',
api_base: '',
api_key: '',
provider: 'openai',
is_default_main: false,
is_default_fork: false,
is_default_exam: false,
});
setModelModalOpen(true);
};
const openEditModel = (model) => {
setEditingModel(model);
setModelForm({
name: model.name || '',
api_base: model.api_base || '',
api_key: model.api_key || '',
provider: model.provider || 'openai',
is_default_main: !!model.is_default_main,
is_default_fork: !!model.is_default_fork,
is_default_exam: !!model.is_default_exam,
});
setModelModalOpen(true);
};
const handleSaveModel = async () => {
if (!modelForm.name || !modelForm.api_base) return;
setModelLoading(true);
try {
const body = {
name: modelForm.name,
api_base: modelForm.api_base,
api_key: modelForm.api_key,
provider: modelForm.provider,
is_default_main: modelForm.is_default_main,
is_default_fork: modelForm.is_default_fork,
is_default_exam: modelForm.is_default_exam,
};
if (editingModel) {
await updateModel(editingModel.id, body);
} else {
await createModel(body);
}
await refreshModels();
setModelModalOpen(false);
} catch (err) {
alert(err.message);
} finally {
setModelLoading(false);
}
};
const handleDeleteModel = async (id) => {
if (!window.confirm('¿Eliminar este modelo? No se puede recuperar.')) return;
try {
await deleteModel(id);
await refreshModels();
} catch (err) {
alert(err.message);
}
};
const handleTestModel = async (id) => {
setTestResults((prev) => ({ ...prev, [id]: { loading: true } }));
try {
const result = await testModel(id);
setTestResults((prev) => ({ ...prev, [id]: { loading: false, result } }));
} catch (err) {
setTestResults((prev) => ({ ...prev, [id]: { loading: false, error: err.message } }));
}
};
const handleToggleDefault = async (model, field) => {
const newValue = !model[field];
try {
await updateModel(model.id, { [field]: newValue });
await refreshModels();
} catch (err) {
alert(err.message);
}
};
// VLM handlers
const handleSaveVlm = async () => {
setVlmSaving(true);
try {
await updateConfig('vlm_endpoint', vlmUrl);
setVlmTestResult({ success: true, message: 'Guardado' });
} catch (err) {
setVlmTestResult({ success: false, message: err.message });
} finally {
setVlmSaving(false);
}
};
const handleTestVlm = async () => {
setVlmTestResult({ loading: true });
try {
const result = await testVlm(vlmUrl);
setVlmTestResult({
success: result.success,
message: result.success
? `${result.message} (${result.latency_ms}ms)`
: result.message,
});
} catch (err) {
setVlmTestResult({
success: false,
message: err.message,
});
}
};
const handleDeletePdf = async (id) => {
if (!window.confirm('¿Eliminar este PDF?')) return;
try {
await deletePdf(id);
await refreshPdfs();
} catch (err) {
alert(err.message);
}
};
// Progress handlers
const handleResetTopic = async (topic) => {
if (!window.confirm(`¿Resetear progreso de "${topic}"?`)) return;
try {
await resetProgressTopic(topic);
await refreshProgress();
} catch (err) {
alert(err.message);
}
};
const handleDownloadBackup = async () => {
try {
await backupDatabase();
} catch (err) {
alert(err.message);
}
};
const handleRestoreBackup = async () => {
if (!restoreFile) return;
if (!window.confirm('¿Restaurar base de datos? Se reemplazará el estado actual.')) return;
setRestoreLoading(true);
try {
await restoreDatabase(restoreFile);
window.location.reload();
} catch (err) {
alert(err.message);
setRestoreLoading(false);
}
};
const progressChartData = progress.map((p) => ({
name: p.topic,
pct: p.percentage,
fill:
p.percentage >= 80
? 'var(--accent-green)'
: p.percentage >= 50
? 'var(--accent-amber)'
: 'var(--accent-coral)',
}));
return (
<div className="settings-container">
<h1
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--text-primary)',
marginBottom: 24,
}}
>
Settings
</h1>
<div className="settings-tabs">
<button
className={`settings-tab ${activeTab === 'models' ? 'active' : ''}`}
onClick={() => setActiveTab('models')}
>
Modelos
</button>
<button
className={`settings-tab ${activeTab === 'pdfs' ? 'active' : ''}`}
onClick={() => setActiveTab('pdfs')}
>
PDFs & VLM
</button>
<button
className={`settings-tab ${activeTab === 'progress' ? 'active' : ''}`}
onClick={() => setActiveTab('progress')}
>
Progreso
</button>
<button
className={`settings-tab ${activeTab === 'backup' ? 'active' : ''}`}
onClick={() => setActiveTab('backup')}
>
Backup
</button>
<button
className={`settings-tab ${activeTab === 'datos' ? 'active' : ''}`}
onClick={() => setActiveTab('datos')}
>
Datos
</button>
</div>
{/* Tab 1: Modelos */}
{activeTab === 'models' && (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button className="btn btn-primary" onClick={openAddModel}>
<Plus size={14} style={{ marginRight: 6, verticalAlign: 'middle' }} />
Add Model
</button>
</div>
<table className="settings-table">
<thead>
<tr>
<th>Name</th>
<th>Provider</th>
<th>API Base</th>
<th style={{ textAlign: 'center' }}>Main</th>
<th style={{ textAlign: 'center' }}>Fork</th>
<th style={{ textAlign: 'center' }}>Exam</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{models.map((model) => (
<tr key={model.id}>
<td>
<span style={{ fontWeight: 500 }}>{model.name}</span>
</td>
<td>
<span className={`provider-badge ${model.provider}`}>
{model.provider}
</span>
</td>
<td>
<span style={{ color: 'var(--text-secondary)', fontSize: 12 }}>
{model.api_base}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<ToggleSwitch
checked={!!model.is_default_main}
onChange={() => handleToggleDefault(model, 'is_default_main')}
/>
</td>
<td style={{ textAlign: 'center' }}>
<ToggleSwitch
checked={!!model.is_default_fork}
onChange={() => handleToggleDefault(model, 'is_default_fork')}
/>
</td>
<td style={{ textAlign: 'center' }}>
<ToggleSwitch
checked={!!model.is_default_exam}
onChange={() => handleToggleDefault(model, 'is_default_exam')}
/>
</td>
<td>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<button
className="btn btn-sm btn-secondary"
onClick={() => handleTestModel(model.id)}
disabled={testResults[model.id]?.loading}
>
{testResults[model.id]?.loading ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<TestTube size={12} />
)}
Test
</button>
{testResults[model.id]?.result && (
<span style={{ fontSize: 11, color: 'var(--accent-green)' }}>
{testResults[model.id].result.latency_ms}ms
</span>
)}
{testResults[model.id]?.error && (
<span style={{ fontSize: 11, color: 'var(--accent-coral)' }}>
Error
</span>
)}
<button
className="btn btn-sm btn-secondary"
onClick={() => openEditModel(model)}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleDeleteModel(model.id)}
>
<Trash2 size={12} />
</button>
</div>
</td>
</tr>
))}
{models.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
No hay modelos configurados
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Tab 2: PDFs & VLM */}
{activeTab === 'pdfs' && (
<div>
<div style={{ marginBottom: 24 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
VLM Endpoint
</h2>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
value={vlmUrl}
onChange={(e) => setVlmUrl(e.target.value)}
placeholder="https://vlm.example.com/v1/chat"
className="vlm-input"
/>
<button className="btn btn-secondary" onClick={handleSaveVlm} disabled={vlmSaving}>
{vlmSaving ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
) : null}
Guardar
</button>
<button className="btn btn-secondary" onClick={handleTestVlm}>
Test VLM
</button>
</div>
{vlmTestResult && (
<div
style={{
marginTop: 8,
fontSize: 12,
color: vlmTestResult.loading ? 'var(--text-tertiary)' : vlmTestResult.success ? 'var(--accent-green)' : 'var(--accent-coral)',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
{vlmTestResult.loading ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : vlmTestResult.success ? (
<TestTube size={12} />
) : (
<AlertTriangle size={12} />
)}
{vlmTestResult.message}
</div>
)}
</div>
<div>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
PDFs cargados
</h2>
<table className="settings-table">
<thead>
<tr>
<th>Nombre</th>
<th>Orden</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{pdfs.map((pdf) => (
<tr key={pdf.id}>
<td>{pdf.filename}</td>
<td>{pdf.sort_order}</td>
<td>
<button
className="btn btn-sm btn-danger"
onClick={() => handleDeletePdf(pdf.id)}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
{pdfs.length === 0 && (
<tr>
<td colSpan={3} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
No hay PDFs cargados
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 3: Progreso */}
{activeTab === 'progress' && (
<div>
<div style={{ marginBottom: 24 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
Gráfico de progreso
</h2>
<div className="progress-chart-container">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={progressChartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<XAxis
dataKey="name"
tick={{ fill: 'var(--text-secondary)', fontSize: 11 }}
axisLine={{ stroke: 'var(--border)' }}
tickLine={false}
/>
<YAxis
tick={{ fill: 'var(--text-secondary)', fontSize: 11 }}
axisLine={false}
tickLine={false}
domain={[0, 100]}
unit="%"
/>
<Tooltip
contentStyle={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
color: 'var(--text-primary)',
fontSize: 12,
}}
itemStyle={{ color: 'var(--text-primary)' }}
formatter={(value) => [`${value}%`, 'Porcentaje']}
/>
<Bar dataKey="pct" radius={[4, 4, 0, 0]}>
{progressChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<table className="settings-table">
<thead>
<tr>
<th>Topic</th>
<th>Exercises Done</th>
<th>Exercises Correct</th>
<th>Percentage</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{progress.map((p) => (
<tr key={p.topic}>
<td>
<span style={{ fontWeight: 500 }}>{p.topic}</span>
</td>
<td>{p.exercises_done}</td>
<td>{p.exercises_correct}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="progress-bar-bg" style={{ width: 120 }}>
<div
className="progress-bar-fill"
style={{
width: `${p.percentage}%`,
background:
p.percentage >= 80
? 'var(--accent-green)'
: p.percentage >= 50
? 'var(--accent-amber)'
: 'var(--accent-coral)',
}}
/>
</div>
<span style={{ fontSize: 12, fontWeight: 600, minWidth: 32 }}>
{p.percentage}%
</span>
</div>
</td>
<td>
<button
className="btn btn-sm btn-danger"
onClick={() => handleResetTopic(p.topic)}
>
Reset
</button>
</td>
</tr>
))}
{progress.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
No hay progreso registrado
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Tab 4: Backup */}
{activeTab === 'backup' && (
<div>
<div style={{ marginBottom: 24 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
Base de datos
</h2>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-secondary" onClick={handleDownloadBackup}>
Download Backup
</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="file"
accept=".db"
onChange={(e) => setRestoreFile(e.target.files?.[0] || null)}
/>
<button
className="btn btn-primary"
onClick={handleRestoreBackup}
disabled={!restoreFile || restoreLoading}
>
{restoreLoading ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
) : null}
Restore Backup
</button>
</div>
</div>
</div>
</div>
)}
{/* Tab 5: Datos */}
{activeTab === 'datos' && (
<div>
<div style={{ marginBottom: 24 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 12,
}}
>
Exportar examen simulado
</h2>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 12 }}>
Genera un PDF con los 20 temas más recientes de tu progreso.
</p>
<button className="btn btn-primary" onClick={downloadExamPdf}>
Exportar examen simulado
</button>
</div>
</div>
)}
{/* Model Modal */}
{modelModalOpen && (
<Modal
title={editingModel ? 'Editar Modelo' : 'Agregar Modelo'}
onClose={() => setModelModalOpen(false)}
>
<div className="form-group">
<label>Nombre</label>
<input
value={modelForm.name}
onChange={(e) => setModelForm((f) => ({ ...f, name: e.target.value }))}
placeholder="gpt-4o"
/>
</div>
<div className="form-group">
<label>API Base</label>
<input
value={modelForm.api_base}
onChange={(e) => setModelForm((f) => ({ ...f, api_base: e.target.value }))}
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="form-group">
<label>API Key</label>
<input
type="password"
value={modelForm.api_key}
onChange={(e) => setModelForm((f) => ({ ...f, api_key: e.target.value }))}
placeholder="sk-..."
/>
</div>
<div className="form-group">
<label>Provider</label>
<select
value={modelForm.provider}
onChange={(e) => setModelForm((f) => ({ ...f, provider: e.target.value }))}
>
<option value="openai">openai</option>
<option value="anthropic">anthropic</option>
</select>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<ToggleSwitch
checked={modelForm.is_default_main}
onChange={(v) =>
setModelForm((f) => ({
...f,
is_default_main: v,
is_default_fork: v ? false : f.is_default_fork,
is_default_exam: v ? false : f.is_default_exam,
}))
}
/>
Default Main
<span className="role-dot main" style={{ marginLeft: 4 }} />
</label>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<ToggleSwitch
checked={modelForm.is_default_fork}
onChange={(v) =>
setModelForm((f) => ({
...f,
is_default_fork: v,
is_default_main: v ? false : f.is_default_main,
is_default_exam: v ? false : f.is_default_exam,
}))
}
/>
Default Fork
<span className="role-dot fork" style={{ marginLeft: 4 }} />
</label>
</div>
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<ToggleSwitch
checked={modelForm.is_default_exam}
onChange={(v) =>
setModelForm((f) => ({
...f,
is_default_exam: v,
is_default_main: v ? false : f.is_default_main,
is_default_fork: v ? false : f.is_default_fork,
}))
}
/>
Default Exam
<span className="role-dot exam" style={{ marginLeft: 4 }} />
</label>
</div>
<div className="form-actions">
<button className="btn btn-secondary" onClick={() => setModelModalOpen(false)}>
Cancelar
</button>
<button className="btn btn-primary" onClick={handleSaveModel} disabled={modelLoading}>
{modelLoading ? (
<Loader2 size={14} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />
) : null}
Guardar
</button>
</div>
</Modal>
)}
</div>
);
}