Files
hydro-site-final/layouts/chat/single.html
Dr. Jones d9961a76ad Initial commit: site code, layouts, content (images added separately)
Fixes duplicate placeholder cover image on the 3 newest blog posts,
broken aspect-ratio classes on the albums listing page, and a
non-responsive fixed sidebar on the chat page.
2026-07-08 06:18:17 +00:00

499 lines
25 KiB
HTML

{{ define "main" }}
<div class="bg-gray-900 min-h-screen py-8 px-4 sm:px-6 lg:px-8" x-data="chatInterface()" x-init="init()">
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-4xl font-extrabold text-gray-100 tracking-tight sm:text-5xl mb-4">
Chat with Dr. Jones AI
</h1>
<p class="text-xl text-gray-300 max-w-2xl mx-auto">
Get instant answers about sterile hydroponics, cannabis cultivation, and growing techniques. Drag library files into the chat to ask questions about specific documents.
</p>
</div>
<!-- Main Layout: Sidebar + Chat -->
<div class="flex flex-col lg:flex-row gap-6 lg:h-[700px]">
<!-- Left Sidebar: Library Files -->
<div class="w-full lg:w-80 flex-shrink-0 h-64 lg:h-auto bg-gray-800 rounded-lg shadow-xl border border-gray-700 flex flex-col">
<div class="p-4 border-b border-gray-700">
<h2 class="text-lg font-bold text-gray-100 flex items-center gap-2">
<svg class="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Library Files
</h2>
<p class="text-xs text-gray-400 mt-1">Drag files into chat to reference them</p>
</div>
<!-- Search/Filter -->
<div class="p-4 border-b border-gray-700">
<input
type="text"
x-model="fileSearch"
placeholder="Search files..."
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
>
</div>
<!-- Files List -->
<div class="flex-1 overflow-y-auto p-2">
<template x-if="loadingFiles">
<div class="text-center py-8 text-gray-400">
<svg class="animate-spin h-6 w-6 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading files...
</div>
</template>
<template x-if="!loadingFiles && filteredFiles.length === 0">
<div class="text-center py-8 text-gray-400 text-sm">
No files found
</div>
</template>
<div class="space-y-2" x-show="filteredFiles.length > 0">
<template x-for="file in filteredFiles" :key="file.file">
<div
draggable="true"
@dragstart="dragStart($event, file)"
@dragend="dragEnd($event)"
class="p-3 bg-gray-700 hover:bg-gray-600 rounded-lg cursor-move transition-colors border border-transparent hover:border-primary/30"
:class="{ 'border-primary': isDragging && draggedFile?.file === file.file }"
>
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-primary mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-100 text-sm leading-tight" x-text="file.title"></div>
<div class="text-xs text-gray-400 mt-1" x-text="file.category"></div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Right Side: Chat Container -->
<div class="flex-1 bg-gray-800 rounded-lg shadow-xl border border-gray-700 flex flex-col">
<!-- Messages Area -->
<div
class="flex-1 overflow-y-auto p-6 space-y-4"
x-ref="messagesContainer"
@dragover.prevent="dragOver($event)"
@drop.prevent="dropFile($event)"
:class="{ 'bg-primary/5 border-2 border-primary border-dashed': isDragOver }"
>
<!-- Welcome Message -->
<template x-if="messages.length === 0">
<div class="text-center py-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/20 mb-4">
<svg class="w-8 h-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<p class="text-gray-400 text-lg mb-2">Ask me anything about sterile hydroponics!</p>
<p class="text-gray-500 text-sm">Drag library files here to ask questions about them</p>
</div>
</template>
<!-- Messages -->
<template x-for="(message, index) in messages" :key="index">
<div class="flex" :class="message.role === 'user' ? 'justify-end' : 'justify-start'">
<div class="max-w-[80%] rounded-lg px-4 py-3"
:class="message.role === 'user'
? 'bg-primary text-white'
: 'bg-gray-700 text-gray-100'">
<div class="flex items-start gap-3">
<template x-if="message.role === 'assistant'">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
</template>
<div class="flex-1">
<p class="text-sm font-medium mb-1" x-show="message.role === 'assistant'">Dr. Jones AI</p>
<div class="prose prose-sm prose-invert max-w-none" x-html="formatMessage(message.content)"></div>
<!-- Show referenced files -->
<template x-if="message.files && message.files.length > 0">
<div class="mt-2 pt-2 border-t border-gray-600/50">
<div class="text-xs text-gray-400 mb-1">Referenced files:</div>
<div class="flex flex-wrap gap-1">
<template x-for="file in message.files" :key="file.file">
<span class="text-xs px-2 py-0.5 bg-gray-600/50 rounded" x-text="file.title"></span>
</template>
</div>
</div>
</template>
</div>
<template x-if="message.role === 'user'">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
</template>
</div>
</div>
</div>
</template>
<!-- Loading Indicator -->
<template x-if="loading">
<div class="flex justify-start">
<div class="max-w-[80%] rounded-lg px-4 py-3 bg-gray-700 text-gray-100">
<div class="flex items-center gap-2">
<div class="flex space-x-1">
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0ms"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 150ms"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 300ms"></div>
</div>
<span class="text-sm text-gray-400 ml-2">Dr. Jones AI is thinking...</span>
</div>
</div>
</div>
</template>
</div>
<!-- Input Area -->
<div class="border-t border-gray-700 p-4">
<!-- Selected Files Display -->
<div x-show="selectedFiles.length > 0" class="mb-3 pb-3 border-b border-gray-700">
<div class="text-xs text-gray-400 mb-2">Files to include in this message:</div>
<div class="flex flex-wrap gap-2">
<template x-for="file in selectedFiles" :key="file.file">
<div class="inline-flex items-center gap-2 px-3 py-1.5 bg-primary/20 text-primary rounded-lg text-xs">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span x-text="file.title"></span>
<button @click="removeSelectedFile(file)" class="hover:text-primary/70">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</template>
</div>
</div>
<form @submit.prevent="sendMessage()" class="flex gap-3">
<div class="flex-1 relative">
<input
type="text"
x-model="inputMessage"
@keydown.enter.prevent="sendMessage()"
:disabled="loading || rateLimited"
placeholder="Ask about sterile hydroponics, or drag files here to ask questions about them..."
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
>
<template x-if="rateLimited">
<p class="absolute -top-6 left-0 text-xs text-yellow-400">
Rate limit: Please wait a moment before sending another message
</p>
</template>
</div>
<button
type="submit"
:disabled="!inputMessage.trim() || loading || rateLimited"
class="px-6 py-3 bg-primary text-white rounded-lg font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-lg hover:shadow-xl"
>
<template x-if="!loading">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
<span>Send</span>
</template>
<template x-if="loading">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
</button>
</form>
<p class="text-xs text-gray-500 mt-2 text-center">
Powered by Ollama • Responses are AI-generated and should be verified
</p>
</div>
</div>
</div>
<!-- Info Section -->
<div class="mt-8 bg-gray-800 rounded-lg p-6 border border-gray-700">
<h2 class="text-xl font-bold text-gray-100 mb-4">About This Chat</h2>
<div class="grid md:grid-cols-2 gap-4 text-sm text-gray-300">
<div>
<h3 class="font-semibold text-gray-100 mb-2">What I Can Help With</h3>
<ul class="space-y-1 list-disc list-inside text-gray-400">
<li>Sterile hydroponic protocols</li>
<li>Nutrient management (EC, pH)</li>
<li>System design and setup</li>
<li>Problem diagnosis</li>
<li>Questions about library documents</li>
</ul>
</div>
<div>
<h3 class="font-semibold text-gray-100 mb-2">How to Use</h3>
<ul class="space-y-1 list-disc list-inside text-gray-400">
<li>Drag files from the sidebar into chat</li>
<li>Ask questions about specific documents</li>
<li>Up to 3 files can be referenced per message</li>
<li>Files are processed securely on the server</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
function chatInterface() {
return {
messages: [],
inputMessage: '',
loading: false,
rateLimited: false,
lastMessageTime: 0,
messageCount: 0,
rateLimitWindow: 60000,
maxMessagesPerWindow: 10,
// File selector state
libraryFiles: [],
loadingFiles: false,
fileSearch: '',
selectedFiles: [],
// Drag and drop state
isDragging: false,
draggedFile: null,
isDragOver: false,
get filteredFiles() {
if (!this.fileSearch.trim()) {
return this.libraryFiles;
}
const search = this.fileSearch.toLowerCase();
return this.libraryFiles.filter(file =>
file.title.toLowerCase().includes(search) ||
file.category.toLowerCase().includes(search)
);
},
init() {
this.apiBase = this.getApiBase();
// Load conversation history from sessionStorage
const saved = sessionStorage.getItem('chatHistory');
if (saved) {
try {
this.messages = JSON.parse(saved);
this.$nextTick(() => this.scrollToBottom());
} catch (e) {
console.error('Failed to load chat history', e);
}
}
// Load library files
this.loadLibraryFiles();
},
getApiBase() {
const host = window.location.hostname;
const isLocal = host === 'localhost' || host === '127.0.0.1' || host.startsWith('10.');
return isLocal ? 'http://localhost:5000' : window.location.origin.replace(/\/$/, '');
},
async loadLibraryFiles() {
this.loadingFiles = true;
try {
const apiUrl = `${this.apiBase}/api/library/files`;
const response = await fetch(apiUrl);
if (response.ok) {
const data = await response.json();
this.libraryFiles = data.files || [];
}
} catch (error) {
console.error('Failed to load library files:', error);
} finally {
this.loadingFiles = false;
}
},
dragStart(event, file) {
this.isDragging = true;
this.draggedFile = file;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', JSON.stringify(file));
},
dragEnd(event) {
this.isDragging = false;
this.draggedFile = null;
},
dragOver(event) {
this.isDragOver = true;
event.dataTransfer.dropEffect = 'move';
},
dropFile(event) {
this.isDragOver = false;
try {
const fileData = event.dataTransfer.getData('text/plain');
if (fileData) {
const file = JSON.parse(fileData);
this.addSelectedFile(file);
}
} catch (e) {
console.error('Error handling drop:', e);
}
},
addSelectedFile(file) {
// Limit to 3 files max
if (this.selectedFiles.length >= 3) {
alert('You can reference up to 3 files at a time.');
return;
}
// Don't add duplicates
if (!this.selectedFiles.find(f => f.file === file.file)) {
this.selectedFiles.push(file);
}
},
removeSelectedFile(file) {
this.selectedFiles = this.selectedFiles.filter(f => f.file !== file.file);
},
formatMessage(text) {
const safe = this.escapeHTML(text || '');
return safe
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>')
.replace(/`(.*?)`/g, '<code class="bg-gray-900 px-1 py-0.5 rounded text-primary">$1</code>');
},
escapeHTML(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
},
checkRateLimit() {
const now = Date.now();
if (now - this.lastMessageTime > this.rateLimitWindow) {
this.messageCount = 0;
this.lastMessageTime = now;
}
if (this.messageCount >= this.maxMessagesPerWindow) {
this.rateLimited = true;
setTimeout(() => {
this.rateLimited = false;
this.messageCount = 0;
}, this.rateLimitWindow - (now - this.lastMessageTime));
return false;
}
this.messageCount++;
this.lastMessageTime = now;
return true;
},
async sendMessage() {
const message = this.inputMessage.trim();
if (!message || this.loading || this.rateLimited) return;
if (!this.checkRateLimit()) return;
const safeForDisplay = this.escapeHTML(message);
// Store selected files for this message
const filesForMessage = [...this.selectedFiles];
// Add user message
this.messages.push({
role: 'user',
content: safeForDisplay,
files: filesForMessage.length > 0 ? filesForMessage : undefined
});
this.inputMessage = '';
this.selectedFiles = []; // Clear selected files after sending
this.loading = true;
this.saveHistory();
this.scrollToBottom();
try {
const sanitizedMessage = this.sanitizeInput(message);
const apiUrl = `${this.apiBase}/api/chat`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: sanitizedMessage,
files: filesForMessage.map(f => ({ file: f.file, title: f.title })), // Only send file references
history: this.messages.slice(-5)
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
this.messages.push({
role: 'assistant',
content: data.response || 'Sorry, I encountered an error. Please try again.'
});
} catch (error) {
console.error('Chat error:', error);
this.messages.push({
role: 'assistant',
content: 'Sorry, I\'m having trouble connecting right now. Please try again in a moment.'
});
} finally {
this.loading = false;
this.saveHistory();
this.scrollToBottom();
}
},
sanitizeInput(input) {
return input
.replace(/[<>]/g, '')
.substring(0, 1000)
.trim();
},
saveHistory() {
try {
sessionStorage.setItem('chatHistory', JSON.stringify(this.messages));
} catch (e) {
console.error('Failed to save chat history', e);
}
}
}
}
</script>
{{ end }}