summaryrefslogtreecommitdiffstats
path: root/app/templates/ai_chat.html
diff options
context:
space:
mode:
authorKen D'Ambrosio <ken@claude>2026-05-25 00:46:10 +0000
committerKen D'Ambrosio <ken@claude>2026-05-25 00:46:10 +0000
commit55bcec90c14db6f2956ed51cf4df1503c0767f81 (patch)
treef25bfb8c46366b5d3dc6b4f66e242c65094b4ada /app/templates/ai_chat.html
Initial commit — menu.jots.org Flask/SQLite meal planner
Full-featured weekly menu planner with: - Recipe library with ratings, comments, cuisine/nationality, added-by attribution - AI recipe assistant (Claude) with URL fetching and file upload - Weekly meal plan grid with shopping list generation - Sort by name, prep/cook time, or rating - Print and copy-link support - Deployed on LXC container (192.168.10.51) behind Apache reverse proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app/templates/ai_chat.html')
-rw-r--r--app/templates/ai_chat.html241
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+}
+
+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 %}