Initial commit: StudyOS platform

This commit is contained in:
renato97
2026-06-08 16:53:18 -03:00
commit b7d1e7319f
39 changed files with 9815 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, X, ChevronDown, Check } from 'lucide-react';
export default function ChatInput({ onSend, isStreaming, availablePdfs = [] }) {
const [text, setText] = useState('');
const [attachedFiles, setAttachedFiles] = useState([]);
const [selectedPdfIds, setSelectedPdfIds] = useState([]);
const [pdfDropdownOpen, setPdfDropdownOpen] = useState(false);
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const dropdownRef = useRef(null);
// Auto-resize textarea (max ~5 lines ~120px)
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
const newHeight = Math.min(el.scrollHeight, 120);
el.style.height = `${newHeight}px`;
}, [text]);
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(e) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setPdfDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSend = () => {
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
const attachmentTexts = attachedFiles.map((f) => f.text);
onSend(trimmed, selectedPdfIds, attachmentTexts);
setText('');
setAttachedFiles([]);
setSelectedPdfIds([]);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
};
const handleFileChange = async (e) => {
const files = Array.from(e.target.files || []);
const loaded = await Promise.all(
files.map(
(file) =>
new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (ev) =>
resolve({ name: file.name, text: ev.target.result });
reader.onerror = () =>
resolve({ name: file.name, text: '' });
reader.readAsText(file);
})
)
);
setAttachedFiles((prev) => [...prev, ...loaded]);
e.target.value = '';
};
const removeAttachment = (index) => {
setAttachedFiles((prev) => prev.filter((_, i) => i !== index));
};
const togglePdf = (id) => {
setSelectedPdfIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const canSend = text.trim().length > 0 && !isStreaming;
return (
<div
style={{
borderTop: '1px solid var(--border)',
padding: '10px 16px',
background: 'var(--bg-surface)',
flexShrink: 0,
}}
>
{/* Attached files chips */}
{attachedFiles.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{attachedFiles.map((file, idx) => (
<span key={idx} className="attachment-chip">
{file.name}
<button onClick={() => removeAttachment(idx)}>
<X size={12} />
</button>
</span>
))}
</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
{/* Attachment button */}
<button
onClick={() => fileInputRef.current?.click()}
title="Adjuntar archivo (.txt, .md, .ics)"
className="icon-btn"
>
<Paperclip size={18} />
</button>
<input
ref={fileInputRef}
type="file"
accept=".txt,.md,.ics"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{/* PDF selector */}
<div ref={dropdownRef} style={{ position: 'relative' }}>
<button
onClick={() => setPdfDropdownOpen((s) => !s)}
title="Seleccionar PDFs"
className="icon-btn"
style={{
color: selectedPdfIds.length ? 'var(--accent-green)' : undefined,
}}
>
<FileIcon size={18} />
{selectedPdfIds.length > 0 && (
<span style={{ marginLeft: 4, fontSize: 10, fontWeight: 700 }}>
{selectedPdfIds.length}
</span>
)}
<ChevronDown size={14} style={{ marginLeft: 2 }} />
</button>
{pdfDropdownOpen && (
<div className="pdf-dropdown-menu">
{availablePdfs.length === 0 && (
<div
style={{
padding: '8px 12px',
fontSize: 12,
color: 'var(--text-tertiary)',
}}
>
No hay PDFs
</div>
)}
{availablePdfs.map((pdf) => {
const checked = selectedPdfIds.includes(pdf.id);
return (
<label
key={pdf.id}
className="pdf-dropdown-item"
>
<input
type="checkbox"
checked={checked}
onChange={() => togglePdf(pdf.id)}
style={{ cursor: 'pointer' }}
/>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={pdf.original_name}
>
{pdf.original_name}
</span>
{checked && (
<Check size={12} style={{ marginLeft: 'auto', color: 'var(--accent-green)' }} />
)}
</label>
);
})}
</div>
)}
</div>
{/* Textarea */}
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe un mensaje..."
rows={1}
disabled={isStreaming}
className="chat-input-textarea"
/>
{/* Send button */}
<button
onClick={handleSend}
disabled={!canSend}
className="chat-input-send-btn"
style={{
background: canSend ? 'var(--accent-green)' : 'var(--bg-elevated)',
color: canSend ? '#fff' : 'var(--text-tertiary)',
}}
>
<Send size={18} />
</button>
</div>
</div>
);
}
function FileIcon(props) {
// Simple file icon similar to lucide FileText
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={props.size || 18}
height={props.size || 18}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useState, useEffect, useRef } from 'react';
import { GitBranch, X, GitMerge, Loader2, Send } from 'lucide-react';
import useChat from '../hooks/useChat';
import MessageBubble from './MessageBubble';
function StreamingIndicator() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 8px',
color: 'var(--text-tertiary)',
fontSize: 11,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: 'var(--accent-green)',
animation: 'pulse-dot 1.5s ease-in-out infinite',
}}
/>
<span>Pensando...</span>
</div>
);
}
export default function ForkPanel({ forkId, forkTitle, onClose, onMerge }) {
const [merging, setMerging] = useState(false);
const [inputText, setInputText] = useState('');
const messagesEndRef = useRef(null);
const chatHook = useChat({
conversationId: forkId ?? null,
});
useEffect(() => {
if (forkId) {
chatHook.setActiveId(forkId);
setInputText('');
}
}, [forkId]);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [chatHook.messages, chatHook.isStreaming]);
const handleSend = () => {
const trimmed = inputText.trim();
if (!trimmed || chatHook.isStreaming) return;
chatHook.sendMessage(trimmed);
setInputText('');
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleMerge = async () => {
if (!forkId || merging) return;
setMerging(true);
try {
await onMerge();
} finally {
setMerging(false);
}
};
const isOpen = forkId !== null;
return (
<aside className={`fork-panel ${isOpen ? 'open' : ''}`}>
{isOpen && (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: 280,
}}
>
{/* Header */}
<div className="fork-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
<GitBranch size={14} style={{ color: 'var(--accent-amber)', flexShrink: 0 }} />
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={forkTitle}
>
{forkTitle || 'Fork'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<button
onClick={handleMerge}
disabled={merging}
className="fork-merge-btn"
>
{merging ? (
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
) : (
<GitMerge size={12} />
)}
{merging ? 'Merging...' : 'Merge & Close'}
</button>
<button
onClick={onClose}
className="fork-close-btn"
title="Close without merge"
>
<X size={14} />
<span style={{ marginLeft: 2 }}>Close</span>
</button>
</div>
</div>
{/* Compact chat area */}
<div className="fork-chat">
{chatHook.messages.length === 0 && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-tertiary)',
fontSize: 11,
padding: 16,
}}
>
Escribe un mensaje para comenzar
</div>
)}
{chatHook.messages.map((msg) => (
<MessageBubble key={msg.id ?? msg.created_at} message={msg} />
))}
{chatHook.isStreaming && <StreamingIndicator />}
<div ref={messagesEndRef} />
</div>
{/* Compact chat input */}
<div style={{
borderTop: '1px solid var(--border)',
padding: '6px 8px',
display: 'flex',
gap: 6,
alignItems: 'flex-end',
background: 'var(--bg-surface)',
flexShrink: 0,
}}>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escribe..."
rows={1}
disabled={chatHook.isStreaming}
style={{
flex: 1,
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
fontSize: 12,
padding: '4px 8px',
resize: 'none',
outline: 'none',
maxHeight: 60,
}}
onInput={(e) => {
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 60) + 'px';
}}
/>
<button
onClick={handleSend}
disabled={!inputText.trim() || chatHook.isStreaming}
style={{
background: inputText.trim() && !chatHook.isStreaming ? 'var(--accent-green)' : 'var(--bg-elevated)',
color: inputText.trim() && !chatHook.isStreaming ? '#fff' : 'var(--text-tertiary)',
border: 'none',
borderRadius: 'var(--radius-sm)',
padding: '6px',
cursor: inputText.trim() && !chatHook.isStreaming ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
opacity: inputText.trim() && !chatHook.isStreaming ? 1 : 0.5,
}}
>
<Send size={14} />
</button>
</div>
</div>
)}
</aside>
);
}

View File

@@ -0,0 +1,163 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { MessageSquare, GitBranch, X, Maximize2 } from 'lucide-react';
import MessageBubble from './MessageBubble';
import ModelSelector from './ModelSelector';
function CoordinatePlane({ data }) {
try {
const config = JSON.parse(data);
const { width = 260, height = 200, points = [], segments = [], grid = [-6, 6, -6, 6] } = config;
const pad = 30;
const [xMin, xMax, yMin, yMax] = grid;
const sx = (x) => pad + ((x - xMin) / (xMax - xMin)) * (width - 2 * pad);
const sy = (y) => height - pad - ((y - yMin) / (yMax - yMin)) * (height - 2 * pad);
const gridLines = [];
for (let i = Math.ceil(xMin); i <= xMax; i++) gridLines.push(<line key={`gx${i}`} x1={sx(i)} y1={pad} x2={sx(i)} y2={height - pad} stroke="var(--border)" strokeWidth="0.5" />);
for (let i = Math.ceil(yMin); i <= yMax; i++) gridLines.push(<line key={`gy${i}`} x1={pad} y1={sy(i)} x2={width - pad} y2={sy(i)} stroke="var(--border)" strokeWidth="0.5" />);
return (
<svg width={width} height={height} style={{ display: 'block', margin: '0 auto', background: 'var(--bg-base)', borderRadius: 'var(--radius-md)' }}>
{gridLines}
<line x1={sx(xMin)} y1={sy(0)} x2={sx(xMax)} y2={sy(0)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
<line x1={sx(0)} y1={sy(yMin)} x2={sx(0)} y2={sy(yMax)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
{segments.map((seg, i) => <line key={`s${i}`} x1={sx(seg[0])} y1={sy(seg[1])} x2={sx(seg[2])} y2={sy(seg[3])} stroke="var(--accent-info)" strokeWidth="2" />)}
{points.map((pt, i) => (
<g key={`p${i}`}>
<circle cx={sx(pt[0])} cy={sy(pt[1])} r="4" fill="var(--accent-info)" />
<text x={sx(pt[0]) + 6} y={sy(pt[1]) - 6} fill="var(--text-primary)" fontSize="10" fontFamily="var(--font-mono)">({pt[0]},{pt[1]})</text>
</g>
))}
{sx(0) >= pad && sx(0) <= width - pad && sy(0) >= pad && sy(0) <= height - pad && (
<text x={sx(0) + 5} y={sy(0) - 5} fill="var(--text-tertiary)" fontSize="10">O</text>
)}
</svg>
);
} catch { return <div style={{ color: 'var(--accent-coral)', fontSize: 11 }}>Error al renderizar</div>; }
}
function StreamingIndicator() {
return (
<div className="streaming-indicator">
<span className="streaming-indicator-dot" />
<span>Pensando...</span>
</div>
);
}
function EmptyState({ hasConversation }) {
return (
<div className="chat-empty-state">
<MessageSquare size={28} className="chat-empty-state-icon" />
<div>
{hasConversation
? 'Escribe un mensaje para comenzar'
: 'Seleccioná una conversación del sidebar o creá una nueva'}
</div>
</div>
);
}
function GraphPanel({ messages, onClose }) {
const graphs = useMemo(() => {
const result = [];
for (const msg of messages) {
if (msg.role !== 'assistant') continue;
const content = msg.content || '';
const regex = /```graph\n([\s\S]*?)```/g;
const matches = [...content.matchAll(regex)];
for (const match of matches) {
try { JSON.parse(match[1]); result.push({ data: match[1], key: msg.id + '-' + result.length }); } catch {}
}
}
return result;
}, [messages]);
if (graphs.length === 0) return null;
return (
<div style={{ width: 360, flexShrink: 0, borderLeft: '1px solid var(--border)', background: 'var(--bg-surface)', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'rgba(129,140,248,0.06)' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--accent-info)', display: 'flex', alignItems: 'center', gap: 6 }}>📐 Gráficos ({graphs.length})</span>
<button onClick={onClose} className="icon-btn"><X size={14} /></button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
{graphs.map((g) => (
<div key={g.key} style={{ marginBottom: 12, background: 'var(--bg-base)', borderRadius: 'var(--radius-md)', padding: 10, border: '1px solid var(--border)' }}>
<CoordinatePlane data={g.data} />
</div>
))}
</div>
</div>
);
}
function extractGraphs(messages) {
const graphs = [];
for (const msg of messages) {
if (msg.role !== 'assistant') continue;
const content = msg.content || '';
const regex = /```graph\n([\s\S]*?)```/g;
const matches = [...content.matchAll(regex)];
for (const match of matches) {
try {
const data = JSON.parse(match[1]);
graphs.push({ data, msgId: msg.id, element: null });
} catch {}
}
}
return graphs;
}
export default function MainChat({
conversation, modelo, modelos, onModelChange, onFork,
messages, isStreaming, MessageBubbleComponent = MessageBubble,
}) {
const messagesEndRef = useRef(null);
const containerRef = useRef(null);
const [graphPanelOpen, setGraphPanelOpen] = useState(false);
const handleForkFromMessage = (topic) => { if (onFork) onFork(topic); };
const hasGraphs = useMemo(() => {
return messages.some(m => m.role === 'assistant' && (m.content || '').includes('```graph'));
}, [messages]);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isStreaming]);
return (
<div style={{ display: 'flex', flex: 1, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100%', overflow: 'hidden', background: 'var(--bg-base)' }}>
{/* Topbar */}
<div className="mainchat-topbar">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<h2 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={conversation?.title}>
{conversation?.title || 'Selecciona una conversación'}
</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<ModelSelector selectedModelId={modelo?.id ?? null} onSelect={(id) => { const s = modelos.find(m => m.id === id); onModelChange(s || null); }} models={modelos} />
{hasGraphs && (
<button onClick={() => setGraphPanelOpen(!graphPanelOpen)}
style={{ background: graphPanelOpen ? 'var(--accent-info)' : 'transparent', border: graphPanelOpen ? 'none' : '1px solid var(--border)', color: graphPanelOpen ? '#fff' : 'var(--text-secondary)', borderRadius: 'var(--radius-sm)', padding: '4px 10px', fontSize: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontWeight: 500 }}>
<Maximize2 size={14} /> Gráficos
</button>
)}
</div>
</div>
{/* Message list */}
<div ref={containerRef} style={{ flex: 1, overflowY: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{messages.length === 0 && <EmptyState hasConversation={!!conversation} />}
{messages.map((msg) => (
<MessageBubbleComponent key={msg.id ?? msg.created_at} message={msg} onFork={msg.role === 'assistant' ? handleForkFromMessage : undefined} />
))}
{isStreaming && <StreamingIndicator />}
<div ref={messagesEndRef} />
</div>
</div>
{graphPanelOpen && hasGraphs && <GraphPanel messages={messages} onClose={() => setGraphPanelOpen(false)} />}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import React, { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { GitMerge, GitBranch } from 'lucide-react';
import katex from 'katex';
function LatexRenderer({ text }) {
const parts = useMemo(() => {
const result = [];
const regex = /(\$\$[\s\S]*?\$\$|\$[^$\n]+?\$)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) result.push({ type: 'text', content: text.slice(lastIndex, match.index) });
const latex = match[0];
const isBlock = latex.startsWith('$$');
const formula = latex.slice(isBlock ? 2 : 1, latex.length - (isBlock ? 2 : 1));
try {
const html = katex.renderToString(formula, { throwOnError: false, displayMode: isBlock });
result.push({ type: 'latex', html, isBlock });
} catch { result.push({ type: 'text', content: latex }); }
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) result.push({ type: 'text', content: text.slice(lastIndex) });
return result;
}, [text]);
return parts.map((part, i) =>
part.type === 'latex' ? (
<span key={i} dangerouslySetInnerHTML={{ __html: part.html }}
style={part.isBlock ? { display: 'block', margin: '12px 0', textAlign: 'center' } : {}} />
) : <React.Fragment key={i}>{part.content}</React.Fragment>
);
}
function CoordinatePlane({ data }) {
try {
const config = JSON.parse(data);
const { width = 260, height = 200, points = [], segments = [], grid = [-6, 6, -6, 6] } = config;
const pad = 30;
const [xMin, xMax, yMin, yMax] = grid;
const sx = (x) => pad + ((x - xMin) / (xMax - xMin)) * (width - 2 * pad);
const sy = (y) => height - pad - ((y - yMin) / (yMax - yMin)) * (height - 2 * pad);
const gridLines = [];
for (let i = Math.ceil(xMin); i <= xMax; i++) gridLines.push(<line key={`gx${i}`} x1={sx(i)} y1={pad} x2={sx(i)} y2={height - pad} stroke="var(--border)" strokeWidth="0.5" />);
for (let i = Math.ceil(yMin); i <= yMax; i++) gridLines.push(<line key={`gy${i}`} x1={pad} y1={sy(i)} x2={width - pad} y2={sy(i)} stroke="var(--border)" strokeWidth="0.5" />);
const origin = { x: sx(0), y: sy(0) };
return (
<div style={{ margin: '12px 0', display: 'flex', justifyContent: 'center' }}>
<svg width={width} height={height} style={{ background: 'var(--bg-surface)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
{gridLines}
<line x1={sx(xMin)} y1={sy(0)} x2={sx(xMax)} y2={sy(0)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
<line x1={sx(0)} y1={sy(yMin)} x2={sx(0)} y2={sy(yMax)} stroke="var(--text-tertiary)" strokeWidth="1.5" />
<polygon points={`${sx(xMax)-5},${sy(0)-3} ${sx(xMax)},${sy(0)} ${sx(xMax)-5},${sy(0)+3}`} fill="var(--text-tertiary)" />
<polygon points={`${sx(0)-3},${sy(yMin)+5} ${sx(0)},${sy(yMin)} ${sx(0)+3},${sy(yMin)+5}`} fill="var(--text-tertiary)" />
{segments.map((seg, i) => <line key={`seg${i}`} x1={sx(seg[0])} y1={sy(seg[1])} x2={sx(seg[2])} y2={sy(seg[3])} stroke="var(--accent-green)" strokeWidth="2" />)}
{points.map((pt, i) => (
<g key={`pt${i}`}>
<circle cx={sx(pt[0])} cy={sy(pt[1])} r="4" fill="var(--accent-green)" />
<text x={sx(pt[0]) + 6} y={sy(pt[1]) - 6} fill="var(--text-primary)" fontSize="10" fontFamily="var(--font-mono)">
({pt[0]},{pt[1]})
</text>
</g>
))}
{origin.x >= pad && origin.x <= width - pad && origin.y >= pad && origin.y <= height - pad && (
<text x={origin.x + 5} y={origin.y - 5} fill="var(--text-tertiary)" fontSize="10">O</text>
)}
</svg>
</div>
);
} catch { return <div style={{ color: 'var(--accent-coral)', fontSize: 12 }}>Error al renderizar gráfico</div>; }
}
function MarkdownContent({ content }) {
return (
<div className="markdown-body">
<ReactMarkdown
components={{
code({ node, className, children, ...props }) {
const code = String(children).trim();
if (className === 'language-graph') return <CoordinatePlane data={code} />;
const isInline = !className || !className.startsWith('language-');
if (isInline) return <code {...props}>{children}</code>;
return <pre><code className={className} {...props}>{children}</code></pre>;
},
p({ children }) {
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.map(c => typeof c === 'string' ? c : '').join('') : '';
return <p style={{ marginBottom: 8 }}><LatexRenderer text={text} /></p>;
},
h1({ children }) { return <h1 style={{ fontSize: 18, fontWeight: 700, marginBottom: 10, marginTop: 6, background: 'linear-gradient(135deg, var(--accent-green), var(--accent-info))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>{children}</h1>; },
h2({ children }) { return <h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 8, marginTop: 6 }}>{children}</h2>; },
h3({ children }) { return <h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 6, marginTop: 4 }}>{children}</h3>; },
li({ children }) { return <li style={{ marginBottom: 4 }}>{children}</li>; },
ul({ children }) { return <ul style={{ paddingLeft: 18, marginBottom: 8 }}>{children}</ul>; },
ol({ children }) { return <ol style={{ paddingLeft: 18, marginBottom: 8 }}>{children}</ol>; },
blockquote({ children }) { return <blockquote style={{ borderLeft: '3px solid var(--accent-green)', paddingLeft: 12, paddingRight: 8, paddingTop: 6, paddingBottom: 6, marginBottom: 8, background: 'rgba(74,222,128,0.05)', borderRadius: '0 var(--radius-sm) var(--radius-sm) 0' }}>{children}</blockquote>; },
a({ children, href }) { return <a href={href} target="_blank" rel="noreferrer">{children}</a>; },
}}
>
{content || ''}
</ReactMarkdown>
</div>
);
}
export default function MessageBubble({ message, onFork }) {
const { role, content, created_at } = message;
if (role === 'system') return null;
if (!content) return null;
if (role === 'context_merge') {
return (
<div className="context-merge-badge">
<GitMerge size={14} style={{ color: 'var(--accent-green)', flexShrink: 0 }} />
<span style={{ lineHeight: 1.4 }}>{content}</span>
</div>
);
}
const isUser = role === 'user';
return (
<div className="message-bubble" style={{ display: 'flex', justifyContent: isUser ? 'flex-end' : 'flex-start', width: '100%' }}>
<div style={{ maxWidth: '80%', padding: isUser ? '10px 14px' : '0', background: isUser ? 'var(--bg-elevated)' : 'transparent', borderRadius: isUser ? '12px 12px 4px 12px' : '0', color: 'var(--text-primary)', fontSize: 14, lineHeight: 1.5, wordBreak: 'break-word' }}>
{isUser ? <div>{content}</div> : <MarkdownContent content={content} />}
{!isUser && onFork && (
<button
onClick={() => {
const topic = (content || '').split('\n')[0].replace(/^#+\s*/, '').substring(0, 50) || 'Estudio';
onFork(topic);
}}
title="Abrir fork sobre este tema"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'rgba(192,132,252,0.08)', border: '1px solid rgba(192,132,252,0.2)',
color: 'var(--accent-purple)', borderRadius: 'var(--radius-sm)', padding: '3px 8px',
fontSize: 10, cursor: 'pointer', marginTop: 6, fontWeight: 500,
transition: 'all var(--transition-fast)',
}}
>
<GitBranch size={11} /> Fork
</button>
)}
{created_at && <div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginTop: 4, textAlign: isUser ? 'right' : 'left' }}>{new Date(created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown, Check } from 'lucide-react';
export default function ModelSelector({ selectedModelId, onSelect, models }) {
const [open, setOpen] = useState(false);
const containerRef = useRef(null);
const selected = models.find((m) => m.id === selectedModelId) || models[0] || null;
useEffect(() => {
function handleClickOutside(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (model) => {
onSelect(model.id);
setOpen(false);
};
return (
<div className="model-selector" ref={containerRef}>
<button
onClick={() => setOpen((o) => !o)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
borderRadius: 'var(--radius-md)',
padding: '6px 10px',
fontSize: 12,
fontFamily: 'var(--font-ui)',
cursor: 'pointer',
outline: 'none',
transition: 'border-color var(--transition-fast), box-shadow var(--transition-fast)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--text-tertiary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)';
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-green)';
e.currentTarget.style.boxShadow = '0 0 0 1px rgba(59, 109, 17, 0.2)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{selected && (
<span
className={`provider-badge ${selected.provider}`}
style={{ fontSize: 9 }}
>
{selected.provider}
</span>
)}
<span style={{ fontWeight: 500 }}>
{selected?.name || 'Select model'}
</span>
<ChevronDown size={12} style={{ color: 'var(--text-tertiary)' }} />
</button>
{open && (
<div className="model-selector-dropdown">
{models.map((model) => (
<div
key={model.id}
className={`model-selector-item ${model.id === selectedModelId ? 'active' : ''}`}
onClick={() => handleSelect(model)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`provider-badge ${model.provider}`}>
{model.provider}
</span>
<span
style={{
fontSize: 12,
fontWeight: 500,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{model.name}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2 }}>
{model.is_default_main && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
<span className="role-dot main" />
main
</span>
)}
{model.is_default_fork && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
<span className="role-dot fork" />
fork
</span>
)}
{model.is_default_exam && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, fontSize: 10, color: 'var(--text-tertiary)' }}>
<span className="role-dot exam" />
exam
</span>
)}
</div>
</div>
{model.id === selectedModelId && (
<Check size={14} style={{ color: 'var(--accent-green)', flexShrink: 0 }} />
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import React, { useState, useRef, useCallback } from 'react';
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors,
} from '@dnd-kit/core';
import {
arrayMove, SortableContext, verticalListSortingStrategy, useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
Settings, GripVertical, Trash2, FileText, Plus, StickyNote, Search, ChevronDown, ChevronRight,
} from 'lucide-react';
function DragOverlayItem({ pdf }) {
return (
<div className="dnd-drag-overlay">
<GripVertical size={14} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{pdf.original_name}</span>
</div>
);
}
function SortablePdfItem({ pdf, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pdf.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
return (
<div ref={setNodeRef} style={style} className="sidebar-pdf-item">
<button className="sidebar-drag-handle" {...attributes} {...listeners}><GripVertical size={14} /></button>
<span className="sidebar-pdf-name" title={pdf.original_name}>{pdf.original_name}</span>
<button className="sidebar-pdf-delete" onClick={() => onDelete(pdf.id)} title="Eliminar"><Trash2 size={12} /></button>
</div>
);
}
export default function Sidebar({
collapsed, onToggle, pdfs, progress, conversations, activeConversation, notes,
onSelectConversation, onNewConversation, onUploadPdf, onReorderPdf, onDeletePdf,
onResetTopic, onNavigateSettings,
}) {
const fileInputRef = useRef(null);
const [pdfItems, setPdfItems] = useState(() => pdfs);
const [pdfSearch, setPdfSearch] = useState('');
const [pdfsExpanded, setPdfsExpanded] = useState(true);
const [progressExpanded, setProgressExpanded] = useState(true);
const [chatsExpanded, setChatsExpanded] = useState(true);
const [notesExpanded, setNotesExpanded] = useState(true);
React.useEffect(() => { setPdfItems(pdfs); }, [pdfs]);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const handleDragEnd = (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = pdfItems.findIndex(p => p.id === active.id);
const newIndex = pdfItems.findIndex(p => p.id === over.id);
setPdfItems(arrayMove(pdfItems, oldIndex, newIndex));
onReorderPdf(active.id, newIndex);
};
const handleFileChange = (e) => { const f = e.target.files?.[0]; if (f) onUploadPdf(f); e.target.value = ''; };
const filteredPdfs = pdfSearch ? pdfItems.filter(p => p.original_name.toLowerCase().includes(pdfSearch.toLowerCase())) : pdfItems;
const SectionHeader = ({ icon, label, expanded, onToggle, extra }) => (
<div className="sidebar-section-header" onClick={onToggle} style={{ cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
<span>{icon} {label}</span>
</div>
{extra}
</div>
);
if (collapsed) {
return (
<aside className="app-sidebar" style={{ width: 48, alignItems: 'center', paddingTop: 12 }}>
<button onClick={onToggle} title="Expandir" className="sidebar-collapsed-btn"><FileText size={20} /></button>
</aside>
);
}
return (
<aside className="app-sidebar" style={{ width: 260 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 14px', borderBottom: '1px solid var(--border)' }}>
<h1 style={{ fontSize: 18, fontWeight: 800, letterSpacing: '-0.03em', background: 'linear-gradient(135deg, var(--accent-info), var(--accent-purple))', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>StudyOS</h1>
<div className="avatar-dot">U</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '12px', display: 'flex', flexDirection: 'column', gap: 18 }}>
{/* PDFs */}
<section>
<SectionHeader icon="📄" label="PDFs" expanded={pdfsExpanded} onToggle={() => setPdfsExpanded(!pdfsExpanded)} />
{pdfsExpanded && (
<>
{pdfItems.length > 3 && (
<div style={{ position: 'relative', marginBottom: 8 }}>
<Search size={12} style={{ position: 'absolute', left: 8, top: 7, color: 'var(--text-tertiary)' }} />
<input value={pdfSearch} onChange={e => setPdfSearch(e.target.value)} placeholder="Buscar..."
style={{ width: '100%', padding: '5px 8px 5px 24px', background: 'var(--bg-elevated)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', color: 'var(--text-primary)', fontSize: 11, outline: 'none', fontFamily: 'var(--font-ui)' }} />
</div>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={filteredPdfs.map(p => p.id)} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, maxHeight: pdfSearch ? 200 : 160, overflowY: 'auto' }}>
{filteredPdfs.map(pdf => <SortablePdfItem key={pdf.id} pdf={pdf} onDelete={onDeletePdf} />)}
{filteredPdfs.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>{pdfSearch ? 'Sin resultados' : 'Sin PDFs'}</span>}
</div>
</SortableContext>
</DndContext>
<button className="sidebar-upload-btn" onClick={() => fileInputRef.current?.click()}><Plus size={14} /> Subir PDF</button>
<input ref={fileInputRef} type="file" accept=".pdf" style={{ display: 'none' }} onChange={handleFileChange} />
</>
)}
</section>
{/* Progress */}
<section>
<SectionHeader icon="📊" label="Progreso" expanded={progressExpanded} onToggle={() => setProgressExpanded(!progressExpanded)} />
{progressExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{progress.map(p => {
const color = p.percentage >= 80 ? 'var(--accent-green)' : p.percentage >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)';
return (
<div key={p.topic}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4, color: 'var(--text-secondary)' }}>
<span style={{ fontWeight: 500 }}>{p.topic}</span>
<span style={{ color: 'var(--text-tertiary)' }}>{p.exercises_correct}/{p.exercises_done}</span>
</div>
<div className="sidebar-progress-bar"><div style={{ width: `${p.percentage}%`, background: color, boxShadow: `0 0 8px ${color}40` }} /></div>
</div>
);
})}
</div>
)}
</section>
{/* Chats */}
<section>
<SectionHeader icon="💬" label="Chats" expanded={chatsExpanded} onToggle={() => setChatsExpanded(!chatsExpanded)}
extra={<button onClick={onNewConversation} className="icon-btn" style={{ padding: 2 }} title="Nueva"><Plus size={14} /></button>} />
{chatsExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{conversations.map(conv => {
const isActive = activeConversation?.id === conv.id;
return (
<button key={conv.id} onClick={() => onSelectConversation(conv)} className={`sidebar-conv-item ${isActive ? 'active' : ''}`}>
<FileText size={14} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{conv.title}</span>
</button>
);
})}
</div>
)}
</section>
{/* Notes */}
<section>
<SectionHeader icon="📝" label="Notas" expanded={notesExpanded} onToggle={() => setNotesExpanded(!notesExpanded)}
extra={<button className="icon-btn" style={{ padding: 2 }} title="Nueva" onClick={onNavigateSettings}><Plus size={14} /></button>} />
{notesExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{notes.slice(0, 8).map(note => (
<button key={note.id} className="sidebar-note-item">
<StickyNote size={12} /> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{note.title}</span>
</button>
))}
{notes.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: 4 }}>Sin notas</span>}
</div>
)}
</section>
</div>
<div style={{ borderTop: '1px solid var(--border)', padding: '10px 14px' }}>
<button onClick={onNavigateSettings} className="sidebar-nav-btn"><Settings size={16} /><span>Settings</span></button>
</div>
</aside>
);
}