diff options
Diffstat (limited to 'app/templates/ai_chat.html')
| -rw-r--r-- | app/templates/ai_chat.html | 241 |
1 files changed, 241 insertions, 0 deletions
diff --git a/app/templates/ai_chat.html b/app/templates/ai_chat.html new file mode 100644 index 0000000..82d2604 --- /dev/null +++ b/app/templates/ai_chat.html @@ -0,0 +1,241 @@ +{% extends "base.html" %} +{% block title %}Recipe Assistant — Menu Planner{% endblock %} + +{% block content %} +<div class="ai-page d-flex flex-column" style="height: calc(100vh - 130px)"> + + <!-- Header --> + <div class="d-flex align-items-center justify-content-between mb-3 flex-shrink-0"> + <div> + <h1 class="h3 fw-bold mb-0">Recipe Assistant</h1> + <p class="text-muted small mb-0">Paste any recipe — I'll parse it, adapt it for low-carb, and add it to the library.</p> + </div> + <button id="newChatBtn" class="btn btn-outline-secondary btn-sm"> + <i class="bi bi-arrow-counterclockwise me-1"></i>New Chat + </button> + </div> + + {% if not has_api_key %} + <div class="alert alert-warning"> + <i class="bi bi-exclamation-triangle me-2"></i> + <strong>API key not configured.</strong> Set <code>ANTHROPIC_API_KEY</code> in the Docker environment to enable the recipe assistant. + </div> + {% else %} + + <!-- Chat messages --> + <div id="chatMessages" class="chat-window flex-grow-1 overflow-auto mb-3 p-3"> + <div class="message message-assistant"> + <div class="message-bubble"> + <p class="mb-1">👋 Hi! I can add recipes to the planner for you. Just paste a recipe — from a website, cookbook, or your own notes — and I'll handle the rest.</p> + <p class="mb-0 text-muted small">I'll parse ingredients, estimate nutrition, categorize it, and adapt for low-carb if needed.</p> + </div> + </div> + </div> + + <!-- Input --> + <div class="chat-input-area flex-shrink-0"> + <div id="attachBadge" class="d-none mb-1"> + <span class="badge bg-secondary"><i class="bi bi-paperclip me-1"></i><span id="attachName"></span> + <button type="button" class="btn-close btn-close-white ms-1" id="clearAttach" style="font-size:0.6rem"></button> + </span> + </div> + <div class="input-group"> + <label class="btn btn-outline-secondary" title="Attach file" style="cursor:pointer"> + <i class="bi bi-paperclip"></i> + <input type="file" id="fileInput" class="d-none" + accept=".txt,.html,.htm,.md,.json,.csv,.xml,image/*"> + </label> + <textarea id="chatInput" class="form-control" rows="3" + placeholder="Paste a recipe here, or ask me something…"></textarea> + <button id="sendBtn" class="btn btn-primary px-4"> + <span id="sendLabel"><i class="bi bi-send"></i></span> + <span id="sendSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span> + </button> + </div> + <div class="d-flex justify-content-between mt-1"> + <small class="text-muted">Shift+Enter for new line · Enter to send</small> + <small class="text-muted" id="charCount"></small> + </div> + </div> + + {% endif %} +</div> +{% endblock %} + +{% block scripts %} +{% if has_api_key %} +<script> +let history = []; +let busy = false; +let attachment = null; // {name, type, text} or {name, type, b64, mime} + +const chatMessages = document.getElementById('chatMessages'); +const chatInput = document.getElementById('chatInput'); +const sendBtn = document.getElementById('sendBtn'); +const sendLabel = document.getElementById('sendLabel'); +const sendSpinner = document.getElementById('sendSpinner'); +const charCount = document.getElementById('charCount'); + +// Auto-resize textarea +chatInput.addEventListener('input', () => { + chatInput.style.height = 'auto'; + chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px'; + charCount.textContent = chatInput.value.length > 0 ? chatInput.value.length + ' chars' : ''; +}); + +// Enter to send (Shift+Enter = newline) +chatInput.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } +}); + +sendBtn.addEventListener('click', send); +document.getElementById('newChatBtn').addEventListener('click', () => { + history = []; + chatMessages.innerHTML = ` + <div class="message message-assistant"> + <div class="message-bubble"> + <p class="mb-0">New chat started. Paste a recipe or ask me anything.</p> + </div> + </div>`; + chatInput.value = ''; + chatInput.style.height = 'auto'; +}); + +// File attachment +const fileInput = document.getElementById('fileInput'); +const attachBadge = document.getElementById('attachBadge'); +const attachName = document.getElementById('attachName'); +fileInput.addEventListener('change', function() { + const file = this.files[0]; + if (!file) return; + const isImage = file.type.startsWith('image/'); + const reader = new FileReader(); + reader.onload = e => { + if (isImage) { + const b64 = e.target.result.split(',')[1]; + attachment = {name: file.name, type: 'image', b64, mime: file.type}; + } else { + attachment = {name: file.name, type: 'text', text: e.target.result}; + } + attachName.textContent = file.name; + attachBadge.classList.remove('d-none'); + }; + if (isImage) reader.readAsDataURL(file); + else reader.readAsText(file); + this.value = ''; +}); +document.getElementById('clearAttach').addEventListener('click', () => { + attachment = null; + attachBadge.classList.add('d-none'); +}); + +function setLoading(on) { + busy = on; + sendBtn.disabled = on; + chatInput.disabled = on; + sendLabel.classList.toggle('d-none', on); + sendSpinner.classList.toggle('d-none', !on); +} + +function appendMessage(role, html) { + const div = document.createElement('div'); + div.className = `message message-${role}`; + div.innerHTML = `<div class="message-bubble">${html}</div>`; + chatMessages.appendChild(div); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function appendRecipeCard(recipe) { + const div = document.createElement('div'); + div.className = 'message message-assistant'; + div.innerHTML = ` + <div class="message-bubble recipe-added-card"> + <div class="d-flex align-items-center gap-2"> + <i class="bi bi-check-circle-fill text-success fs-5"></i> + <div> + <div class="fw-semibold">${escapeHtml(recipe.name)} added!</div> + <a href="/recipes/${recipe.id}" class="small">View recipe →</a> + </div> + </div> + </div>`; + chatMessages.appendChild(div); + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function escapeHtml(s) { + return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); +} + +function mdToHtml(text) { + // Very simple markdown: **bold**, line breaks + return escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') + .replace(/\n/g, '<br>'); +} + +async function send() { + if (busy) return; + const msg = chatInput.value.trim(); + if (!msg && !attachment) return; + + const displayMsg = attachment ? `📎 ${attachment.name}${msg ? '\n' + msg : ''}` : msg; + appendMessage('user', mdToHtml(displayMsg)); + chatInput.value = ''; + chatInput.style.height = 'auto'; + charCount.textContent = ''; + const sentAttachment = attachment; + attachment = null; + attachBadge.classList.add('d-none'); + setLoading(true); + + // Thinking indicator + const thinking = document.createElement('div'); + thinking.className = 'message message-assistant thinking-msg'; + thinking.innerHTML = `<div class="message-bubble text-muted fst-italic"> + <span class="spinner-border spinner-border-sm me-2" style="width:.75rem;height:.75rem"></span>Thinking… + </div>`; + chatMessages.appendChild(thinking); + chatMessages.scrollTop = chatMessages.scrollHeight; + + try { + const payload = { message: msg, history }; + if (sentAttachment) { + if (sentAttachment.type === 'image') { + payload.image_b64 = sentAttachment.b64; + payload.image_mime = sentAttachment.mime; + payload.image_name = sentAttachment.name; + } else { + payload.file_text = sentAttachment.text; + payload.file_name = sentAttachment.name; + } + } + const res = await api('/ai/chat', { + method: 'POST', + body: JSON.stringify(payload), + }); + if (!res) return; // 401 redirect handled by api() + + const data = await res.json(); + thinking.remove(); + + if (data.error) { + appendMessage('assistant', `<span class="text-danger">${escapeHtml(data.error)}</span>`); + } else { + history = data.history; + if (data.response) appendMessage('assistant', mdToHtml(data.response)); + (data.recipes_added || []).forEach(appendRecipeCard); + } + } catch(e) { + thinking.remove(); + appendMessage('assistant', `<span class="text-danger">Network error — please try again.</span>`); + } finally { + setLoading(false); + chatInput.focus(); + } +} +</script> +{% endif %} +{% endblock %} |
