| <!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-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{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);} |
| |
| |
| .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 .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;}} |
| |
| |
| .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);} |
| |
| |
| .bubble-img{max-width:100%;border-radius:8px;margin-bottom:8px;display:block;} |
| |
| |
| .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;} |
| |
| |
| .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 | Puedes adjuntar fotos</div> |
| </div> |
| </div> |
|
|
| <script> |
| let pendingImage = null; |
| let isThinking = false; |
| const history = []; |
| |
| 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,'&').replace(/</g,'<').replace(/>/g,'>'); |
| } |
| |
| function renderMarkdown(text) { |
| |
| text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); |
| |
| text = text.replace(/`(.+?)`/g, '<code>$1</code>'); |
| |
| text = text.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>'); |
| text = text.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>'); |
| |
| text = text.replace(/^---+$/gm, '<hr>'); |
| |
| 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>'); |
| |
| 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(); |
| |
| |
| 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); |
| } |
| |
| 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> |
|
|