TOOLKIT / static /asistente.html
IA-PLATE's picture
Asistente: mas padding en bubble, input centrado, markdown fix, tono mas humano
600b88f
Raw
History Blame Contribute Delete
15.2 kB
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PLATE · Asistente</title>
<style>
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap");
:root{
--black:#111111;--dark:#1a1a1a;--card:#1f1f1f;--border:#2e2e2e;
--green:#6abf4b;--blue:#2b9fd8;--white:#f0f0f0;--muted:#888;
--bubble-user:#1e3a4a;--bubble-ai:#1f1f1f;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--black);color:var(--white);font-family:"IBM Plex Sans",sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden;}
header{display:flex;align-items:center;justify-content:space-between;padding:16px 32px;border-bottom:1px solid var(--border);background:var(--dark);flex-shrink:0;}
.header-left{display:flex;align-items:center;gap:14px;}
.btn-back{font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--muted);text-decoration:none;letter-spacing:0.1em;padding:6px 12px;border:1px solid var(--border);border-radius:6px;transition:color .15s,border-color .15s;}
.btn-back:hover{color:var(--white);border-color:var(--muted);}
.logo-wrap{display:flex;align-items:center;gap:12px;}
.logo-text{font-family:"IBM Plex Mono",monospace;font-size:16px;font-weight:600;color:var(--white);}
.logo-sep{width:1px;height:24px;background:var(--border);}
.logo-sub{font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--muted);letter-spacing:0.15em;text-transform:uppercase;}
.badge{background:rgba(43,159,216,0.15);color:var(--blue);font-family:"IBM Plex Mono",monospace;font-size:10px;padding:4px 10px;border-radius:4px;letter-spacing:0.1em;}
/* Chat area */
.chat-container{flex:1;overflow-y:auto;padding:24px 0;display:flex;flex-direction:column;gap:0;}
.chat-inner{max-width:720px;width:100%;margin:0 auto;padding:0 20px;display:flex;flex-direction:column;gap:16px;}
/* Welcome */
.welcome{text-align:center;padding:48px 20px 32px;}
.welcome-icon{width:56px;height:56px;background:rgba(43,159,216,0.12);border-radius:14px;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-family:"IBM Plex Mono",monospace;font-size:18px;font-weight:600;color:var(--blue);}
.welcome h2{font-size:20px;font-weight:600;margin-bottom:8px;}
.welcome p{font-size:14px;color:var(--muted);line-height:1.6;max-width:420px;margin:0 auto 24px;}
.chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;}
.chip{background:var(--card);border:1px solid var(--border);border-radius:20px;padding:8px 16px;font-size:13px;color:var(--muted);cursor:pointer;transition:border-color .15s,color .15s;font-family:"IBM Plex Sans",sans-serif;}
.chip:hover{border-color:var(--blue);color:var(--white);}
/* Messages */
.msg{display:flex;gap:12px;animation:fadein .2s ease;}
@keyframes fadein{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:translateY(0);}}
.msg.user{flex-direction:row-reverse;}
.msg-avatar{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:"IBM Plex Mono",monospace;font-size:11px;font-weight:600;flex-shrink:0;margin-top:2px;}
.avatar-ai{background:rgba(43,159,216,0.15);color:var(--blue);}
.avatar-user{background:rgba(106,191,75,0.15);color:var(--green);}
.msg-body{max-width:75%;}
.msg-bubble{padding:16px 20px;border-radius:12px;font-size:14px;line-height:1.7;}
.msg.ai .msg-bubble{background:var(--card);border:1px solid var(--border);color:var(--white);border-radius:4px 12px 12px 12px;}
.msg.user .msg-bubble{background:var(--bubble-user);color:var(--white);border-radius:12px 4px 12px 12px;}
.msg-time{font-size:11px;color:var(--muted);font-family:"IBM Plex Mono",monospace;margin-top:4px;padding:0 4px;}
.msg.user .msg-time{text-align:right;}
/* Thinking */
.thinking .msg-bubble{color:var(--muted);font-style:italic;}
.dots span{animation:blink 1.2s infinite;}
.dots span:nth-child(2){animation-delay:.2s;}
.dots span:nth-child(3){animation-delay:.4s;}
@keyframes blink{0%,80%,100%{opacity:0;}40%{opacity:1;}}
/* Image preview */
.img-preview-wrap{position:relative;display:inline-block;margin-bottom:8px;}
.img-preview{max-width:200px;max-height:140px;border-radius:8px;border:1px solid var(--border);display:block;}
.img-remove{position:absolute;top:-6px;right:-6px;background:#333;color:var(--muted);border:1px solid var(--border);border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:12px;line-height:1;}
.img-remove:hover{color:var(--white);}
/* Attached image in bubble */
.bubble-img{max-width:100%;border-radius:8px;margin-bottom:8px;display:block;}
/* Input area */
.input-area{flex-shrink:0;border-top:1px solid var(--border);background:var(--dark);padding:16px 20px;}
.input-inner{max-width:720px;margin:0 auto;}
.input-row{display:flex;align-items:center;gap:10px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:10px 12px;transition:border-color .15s;}
.input-row:focus-within{border-color:var(--blue);}
.attach-btn{background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;display:flex;align-items:center;transition:color .15s;flex-shrink:0;}
.attach-btn:hover{color:var(--blue);}
.attach-btn svg{width:18px;height:18px;}
#msg-input{flex:1;background:none;border:none;color:var(--white);font-family:"IBM Plex Sans",sans-serif;font-size:14px;resize:none;outline:none;min-height:24px;max-height:120px;line-height:1.5;padding:2px 0;}
#msg-input::placeholder{color:var(--muted);}
.send-btn{background:var(--blue);color:white;border:none;border-radius:8px;width:36px;height:36px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;transition:background .15s;margin-bottom:0;}
.send-btn:hover:not(:disabled){background:#1e7cb5;}
.send-btn:disabled{opacity:0.4;cursor:default;}
.send-btn svg{width:16px;height:16px;}
.input-hint{font-size:11px;color:var(--muted);font-family:"IBM Plex Mono",monospace;margin-top:8px;text-align:center;letter-spacing:0.05em;}
/* Markdown-like rendering */
.msg-bubble strong{font-weight:600;}
.msg-bubble em{font-style:italic;}
.msg-bubble code{font-family:"IBM Plex Mono",monospace;font-size:12px;background:rgba(255,255,255,0.08);padding:1px 5px;border-radius:3px;}
.msg-bubble ul,.msg-bubble ol{padding-left:20px;margin-top:6px;}
.msg-bubble li{margin-bottom:3px;}
.msg-bubble p{margin-bottom:8px;}
.msg-bubble p:last-child{margin-bottom:0;}
.msg-bubble h3,.msg-bubble h4{font-weight:600;margin-bottom:6px;margin-top:10px;}
.msg-bubble hr{border:none;border-top:1px solid var(--border);margin:10px 0;}
</style>
</head>
<body>
<header>
<div class="logo-wrap">
<span class="logo-text">PLATE</span>
<div class="logo-sep"></div>
<span class="logo-sub" id="tool-subtitle">Asistente Digital</span>
</div>
<a class="btn-back" href="/">← Toolkit</a>
</header>
<div class="chat-container" id="chat">
<div class="chat-inner" id="chat-inner">
<div class="welcome" id="welcome">
<div class="welcome-icon">AI</div>
<h2>Asistente Digital</h2>
<p>Pregunta sobre LPS, Buildpeer o procedimientos de campo. Manda fotos de lo que ves en obra y te doy contexto.</p>
<div class="chips">
<div class="chip" onclick="sendChip(this)">¿Cuándo se congela el plan semanal?</div>
<div class="chip" onclick="sendChip(this)">¿Cómo se calcula el PPC?</div>
<div class="chip" onclick="sendChip(this)">¿Qué es un CNC y cuáles hay?</div>
<div class="chip" onclick="sendChip(this)">¿Para qué sirve el Lookahead?</div>
<div class="chip" onclick="sendChip(this)">¿Cómo registro un RFI en Buildpeer?</div>
<div class="chip" onclick="sendChip(this)">¿Qué va en el Daily Huddle?</div>
</div>
</div>
</div>
</div>
<div class="input-area">
<div class="input-inner">
<div id="img-preview-wrap" style="display:none;" class="img-preview-wrap">
<img id="img-preview" class="img-preview" src="" alt="">
<button class="img-remove" onclick="removeImage()" title="Quitar imagen">×</button>
</div>
<div class="input-row">
<button class="attach-btn" onclick="document.getElementById('file-input').click()" title="Adjuntar imagen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
</button>
<input type="file" id="file-input" accept="image/*" style="display:none" onchange="handleImage(event)">
<textarea id="msg-input" placeholder="Escribe tu pregunta..." rows="1"
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="send-btn" id="send-btn" onclick="sendMessage()">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
<div class="input-hint">LPS · Buildpeer · Protocolos de Obra &nbsp;|&nbsp; Puedes adjuntar fotos</div>
</div>
</div>
<script>
let pendingImage = null; // { base64, mimeType, previewUrl }
let isThinking = false;
const history = []; // [{role, content}] para contexto multi-turno
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function handleImage(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const dataUrl = ev.target.result;
const base64 = dataUrl.split(',')[1];
const mimeType = file.type;
pendingImage = { base64, mimeType, previewUrl: dataUrl };
document.getElementById('img-preview').src = dataUrl;
document.getElementById('img-preview-wrap').style.display = 'inline-block';
};
reader.readAsDataURL(file);
e.target.value = '';
}
function removeImage() {
pendingImage = null;
document.getElementById('img-preview-wrap').style.display = 'none';
document.getElementById('img-preview').src = '';
}
function hideWelcome() {
const w = document.getElementById('welcome');
if (w) w.remove();
}
function formatTime() {
return new Date().toLocaleTimeString('es-MX', {hour:'2-digit', minute:'2-digit'});
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderMarkdown(text) {
// Bold before italic to avoid conflicts
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Code inline
text = text.replace(/`(.+?)`/g, '<code>$1</code>');
// Headers
text = text.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
text = text.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
// HR
text = text.replace(/^---+$/gm, '<hr>');
// Lists: lines starting with -, *, •, or number.
const lines = text.split('\n');
const out = [];
let inList = false;
for (const line of lines) {
const liMatch = line.match(/^[\s]*[-•*]\s+(.+)$/) || line.match(/^[\s]*\d+\.\s+(.+)$/);
if (liMatch) {
if (!inList) { out.push('<ul>'); inList = true; }
out.push('<li>' + liMatch[1] + '</li>');
} else {
if (inList) { out.push('</ul>'); inList = false; }
out.push(line);
}
}
if (inList) out.push('</ul>');
// Rejoin and wrap paragraphs
text = out.join('\n').split(/\n\n+/).map(p => {
p = p.trim();
if (!p) return '';
if (p.startsWith('<h') || p.startsWith('<ul') || p.startsWith('<hr')) return p;
return '<p>' + p.replace(/\n/g, '<br>') + '</p>';
}).filter(Boolean).join('');
return text;
}
function appendMsg(role, text, imgUrl) {
hideWelcome();
const inner = document.getElementById('chat-inner');
const div = document.createElement('div');
div.className = 'msg ' + role;
const avatarLabel = role === 'ai' ? 'AI' : 'TÚ';
const avatarClass = role === 'ai' ? 'avatar-ai' : 'avatar-user';
let imgHtml = imgUrl ? `<img class="bubble-img" src="${imgUrl}" alt="Imagen adjunta">` : '';
let bubbleContent = imgHtml + (role === 'ai' ? renderMarkdown(text) : '<p>' + escapeHtml(text) + '</p>');
div.innerHTML = `
<div class="msg-avatar ${avatarClass}">${avatarLabel}</div>
<div class="msg-body">
<div class="msg-bubble">${bubbleContent}</div>
<div class="msg-time">${formatTime()}</div>
</div>`;
inner.appendChild(div);
scrollBottom();
return div;
}
function appendThinking() {
hideWelcome();
const inner = document.getElementById('chat-inner');
const div = document.createElement('div');
div.className = 'msg ai thinking';
div.id = 'thinking-bubble';
div.innerHTML = `
<div class="msg-avatar avatar-ai">AI</div>
<div class="msg-body">
<div class="msg-bubble"><span class="dots"><span>●</span><span>●</span><span>●</span></span></div>
</div>`;
inner.appendChild(div);
scrollBottom();
}
function removeThinking() {
const t = document.getElementById('thinking-bubble');
if (t) t.remove();
}
function scrollBottom() {
const chat = document.getElementById('chat');
chat.scrollTop = chat.scrollHeight;
}
function sendChip(el) {
document.getElementById('msg-input').value = el.textContent;
sendMessage();
}
async function sendMessage() {
if (isThinking) return;
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text && !pendingImage) return;
const imgUrl = pendingImage ? pendingImage.previewUrl : null;
const imgBase64 = pendingImage ? pendingImage.base64 : null;
const imgMime = pendingImage ? pendingImage.mimeType : null;
appendMsg('user', text, imgUrl);
input.value = '';
input.style.height = 'auto';
removeImage();
// Guardar en historial
history.push({ role: 'user', content: text, hasImage: !!imgBase64 });
isThinking = true;
document.getElementById('send-btn').disabled = true;
appendThinking();
try {
const form = new FormData();
form.append('mensaje', text);
if (imgBase64) {
form.append('imagen_base64', imgBase64);
form.append('imagen_mime', imgMime);
}
// Enviar historial previo (sin imágenes para no inflar)
const prevHistory = history.slice(0, -1).map(h => ({role: h.role, content: h.content}));
form.append('historial', JSON.stringify(prevHistory));
const resp = await fetch('/chat', { method: 'POST', body: form });
const data = await resp.json();
removeThinking();
if (data.ok) {
appendMsg('ai', data.respuesta);
history.push({ role: 'assistant', content: data.respuesta });
} else {
appendMsg('ai', '⚠️ Error: ' + (data.error || 'Respuesta inesperada del servidor.'));
}
} catch (err) {
removeThinking();
appendMsg('ai', '⚠️ No se pudo conectar con el servidor. Intenta de nuevo.');
}
isThinking = false;
document.getElementById('send-btn').disabled = false;
document.getElementById('msg-input').focus();
}
</script>
</body>
</html>