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.
499 lines
25 KiB
HTML
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
},
|
|
|
|
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 }}
|