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)
848 lines
26 KiB
JavaScript
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>
|
|
);
|
|
}
|