nordabiz/templates/chat.html
Maciej Pienczyn 83b52a1b24 feat: Add clickable email addresses and fix URL trailing punctuation in AI chat
- Email addresses now become mailto: links
- URLs properly strip trailing punctuation (comma, period, etc.)
- Both link types handle trailing punctuation gracefully

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:41:23 +01:00

911 lines
29 KiB
HTML
Executable File

{% extends "base.html" %}
{% block title %}Chat AI - Norda Biznes Hub{% endblock %}
{% block extra_css %}
<style>
.chat-container {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-lg);
height: calc(100vh - 200px);
}
.chat-main {
background: white;
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
}
.chat-header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xs);
}
.chat-header h1 {
font-size: var(--font-size-xl);
margin: 0;
}
.chat-header p {
font-size: var(--font-size-sm);
opacity: 0.9;
}
.model-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(255, 255, 255, 0.2);
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
backdrop-filter: blur(10px);
}
.model-badge-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
scroll-behavior: smooth;
}
.message {
display: flex;
gap: var(--spacing-md);
max-width: 80%;
animation: messageSlide 0.3s ease-out;
}
@keyframes messageSlide {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 600;
color: white;
}
.message.user .message-avatar {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
}
.message.assistant .message-avatar {
background: linear-gradient(135deg, var(--secondary), #94a3b8);
}
.message-content {
background: var(--background);
padding: var(--spacing-md);
border-radius: var(--radius-lg);
line-height: 1.6;
}
.message.user .message-content {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
}
.message.assistant .message-content {
background: white;
border: 1px solid var(--border);
}
.message.assistant .message-content a {
color: var(--primary);
text-decoration: underline;
word-break: break-all;
}
.message.assistant .message-content a:hover {
color: var(--primary-dark, #1e40af);
text-decoration: none;
}
.message-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.typing-indicator {
display: none;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
max-width: 80px;
}
.typing-indicator.active {
display: flex;
}
.typing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-secondary);
animation: typing 1.4s infinite;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.7;
}
30% {
transform: translateY(-10px);
opacity: 1;
}
}
.chat-input-container {
padding: var(--spacing-lg);
border-top: 1px solid var(--border);
background: var(--background);
}
.chat-input-wrapper {
display: flex;
gap: var(--spacing-md);
}
.chat-input {
flex: 1;
padding: var(--spacing-md);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
font-size: var(--font-size-base);
font-family: var(--font-family);
resize: none;
min-height: 50px;
max-height: 150px;
}
.chat-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.send-button {
padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-weight: 600;
}
.send-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--text-secondary);
padding: var(--spacing-2xl);
}
.empty-state svg {
opacity: 0.3;
margin-bottom: var(--spacing-lg);
}
.empty-state h2 {
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.suggestion-chips {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
justify-content: center;
margin-top: var(--spacing-lg);
}
.suggestion-chip {
padding: var(--spacing-sm) var(--spacing-md);
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: var(--transition);
font-size: var(--font-size-sm);
}
.suggestion-chip:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--background);
}
/* Feedback buttons */
.message-feedback {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
.feedback-btn {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--border);
background: transparent;
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.feedback-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.feedback-btn.active.positive {
background: rgba(16, 185, 129, 0.1);
border-color: var(--success);
color: var(--success);
}
.feedback-btn.active.negative {
background: rgba(239, 68, 68, 0.1);
border-color: var(--error);
color: var(--error);
}
.feedback-btn svg {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.chat-container {
height: calc(100vh - 150px);
}
.message {
max-width: 90%;
}
.chat-input-wrapper {
flex-direction: column;
}
}
/* Tech Info Panel - pokazuje metadane techniczne odpowiedzi AI */
.tech-info-panel {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm);
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid #e2e8f0;
border-radius: var(--radius);
font-size: 11px;
color: #64748b;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
}
.tech-info-panel summary {
cursor: pointer;
font-weight: 500;
color: #475569;
display: flex;
align-items: center;
gap: 6px;
}
.tech-info-panel summary::before {
content: '⚙️';
}
.tech-info-panel[open] summary {
margin-bottom: var(--spacing-sm);
border-bottom: 1px solid #e2e8f0;
padding-bottom: var(--spacing-xs);
}
.tech-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--spacing-xs);
}
.tech-info-item {
display: flex;
flex-direction: column;
padding: 4px 8px;
background: white;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
.tech-info-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #94a3b8;
margin-bottom: 2px;
}
.tech-info-value {
font-weight: 600;
color: #334155;
}
.tech-info-value.free {
color: #10b981;
}
.tech-info-value.tokens {
color: #6366f1;
}
.free-tier-bar {
margin-top: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: white;
border-radius: 4px;
border: 1px solid #e2e8f0;
}
.free-tier-progress {
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
margin-top: 4px;
}
.free-tier-progress-fill {
height: 100%;
background: linear-gradient(90deg, #10b981 0%, #34d399 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
.free-tier-progress-fill.warning {
background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%);
}
.free-tier-progress-fill.danger {
background: linear-gradient(90deg, #ef4444 0%, #f87171 100%);
}
</style>
{% endblock %}
{% block content %}
<div class="chat-container">
<div class="chat-main">
<div class="chat-header">
<div class="chat-header-top">
<h1>🤖 AI Asystent</h1>
<div class="model-badge" id="modelBadge">
<span class="model-badge-dot"></span>
<span id="modelName">Ładowanie...</span>
</div>
</div>
<p>Firmy, usługi, rekomendacje i aktualności Norda Biznes</p>
</div>
<div class="chat-messages" id="chatMessages">
<!-- Empty state -->
<div class="empty-state" id="emptyState">
<svg width="100" height="100" viewBox="0 0 100 100" fill="none">
<circle cx="50" cy="50" r="35" stroke="currentColor" stroke-width="3"/>
<path d="M35 45h30M35 60h20" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
<circle cx="38" cy="32" r="3" fill="currentColor"/>
<circle cx="62" cy="32" r="3" fill="currentColor"/>
</svg>
<h2>Zadaj pytanie AI</h2>
<p>Przykładowe zapytania:</p>
<div class="suggestion-chips">
<button class="suggestion-chip" onclick="sendSuggestion('Kto jest prezesem PIXLAB?')">
Kto jest prezesem PIXLAB?
</button>
<button class="suggestion-chip" onclick="sendSuggestion('Która firma ma najlepsze Google opinie?')">
Najlepsze Google opinie?
</button>
<button class="suggestion-chip" onclick="sendSuggestion('Kiedy następne spotkanie?')">
Kiedy następne spotkanie?
</button>
<button class="suggestion-chip" onclick="sendSuggestion('Kto ma najwięcej fanów na Facebooku?')">
Najwięcej fanów FB?
</button>
</div>
</div>
<!-- Typing indicator -->
<div class="typing-indicator" id="typingIndicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
<div class="chat-input-container">
<form id="chatForm" class="chat-input-wrapper">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea
id="messageInput"
class="chat-input"
placeholder="Wpisz swoją wiadomość..."
rows="1"
required
></textarea>
<button type="submit" class="send-button" id="sendButton">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
</svg>
Wyślij
</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
// Get conversation_id from URL if present
const urlParams = new URLSearchParams(window.location.search);
let conversationId = urlParams.get('conversation_id') ? parseInt(urlParams.get('conversation_id')) : null;
const messagesContainer = document.getElementById('chatMessages');
const emptyState = document.getElementById('emptyState');
const typingIndicator = document.getElementById('typingIndicator');
const messageInput = document.getElementById('messageInput');
const sendButton = document.getElementById('sendButton');
const chatForm = document.getElementById('chatForm');
const modelNameElement = document.getElementById('modelName');
// Load conversation history if conversation_id is present
async function loadConversationHistory() {
if (!conversationId) {
console.log('No conversation_id, skipping history load');
return;
}
console.log('Loading history for conversation:', conversationId);
try {
const response = await fetch(`/api/chat/${conversationId}/history`, {
credentials: 'include'
});
console.log('History response status:', response.status);
if (!response.ok) {
console.error('History fetch failed:', response.status, response.statusText);
return;
}
const data = await response.json();
console.log('History data:', data);
if (data.success && data.messages && data.messages.length > 0) {
// Hide empty state
if (emptyState) {
emptyState.style.display = 'none';
}
// Add all messages to UI
data.messages.forEach(msg => {
addMessage(msg.role, msg.content, msg.role === 'assistant' ? msg.id : null);
});
scrollToBottom();
console.log('Loaded', data.messages.length, 'messages');
} else {
console.log('No messages in history or request failed');
}
} catch (error) {
console.error('Failed to load conversation history:', error);
}
}
// Fetch and display current AI model
async function loadModelInfo() {
try {
const response = await fetch('/api/model-info', {
credentials: 'include'
});
const data = await response.json();
if (data.success) {
// Display model name with version
const displayName = data.model.replace('gemini-', 'Gemini ');
modelNameElement.textContent = displayName;
} else {
modelNameElement.textContent = 'Niedostępny';
}
} catch (error) {
console.error('Failed to load model info:', error);
modelNameElement.textContent = 'Błąd';
}
}
// Load model info and conversation history on page load
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM loaded, conversation_id:', conversationId);
loadModelInfo();
loadConversationHistory();
});
// Auto-resize textarea
messageInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
});
// Handle form submission
chatForm.addEventListener('submit', async function(e) {
e.preventDefault();
const message = messageInput.value.trim();
if (!message) return;
await sendMessage(message);
});
// Send message function
async function sendMessage(message) {
// Disable input
messageInput.disabled = true;
sendButton.disabled = true;
// Hide empty state
if (emptyState) {
emptyState.style.display = 'none';
}
// Add user message to UI
addMessage('user', message);
// Clear input
messageInput.value = '';
messageInput.style.height = 'auto';
// Show typing indicator
typingIndicator.classList.add('active');
scrollToBottom();
try {
// Start conversation if needed
if (!conversationId) {
const startResponse = await fetch('/api/chat/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrf_token]').value
},
body: JSON.stringify({
title: message.substring(0, 50)
})
});
const startData = await startResponse.json();
if (startData.success) {
conversationId = startData.conversation_id;
// Update URL with conversation_id for history persistence
const newUrl = new URL(window.location);
newUrl.searchParams.set('conversation_id', conversationId);
window.history.replaceState({}, '', newUrl);
console.log('Updated URL with conversation_id:', conversationId);
} else {
throw new Error(startData.error || 'Failed to start conversation');
}
}
// Send message
const response = await fetch(`/api/chat/${conversationId}/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrf_token]').value
},
body: JSON.stringify({ message })
});
const data = await response.json();
// Hide typing indicator
typingIndicator.classList.remove('active');
if (data.success) {
// Add assistant response with message_id and tech_info for feedback
addMessage('assistant', data.message, data.message_id, data.tech_info);
} else {
throw new Error(data.error || 'Failed to send message');
}
} catch (error) {
typingIndicator.classList.remove('active');
addMessage('assistant', 'Błąd połączenia. Spróbuj ponownie.');
console.error('Chat error:', error);
} finally {
// Re-enable input
messageInput.disabled = false;
sendButton.disabled = false;
messageInput.focus();
}
}
// Convert URLs and email addresses in text to clickable links
function linkifyText(text) {
// First escape HTML to prevent XSS
let escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
// Convert URLs to links
// Match http://, https://, and www. URLs
const urlRegex = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi;
escaped = escaped.replace(urlRegex, function(url) {
// Remove trailing punctuation (comma, period, etc.) that's not part of the URL
let cleanUrl = url.replace(/[.,;:!?)\]]+$/, '');
const trailingPunct = url.slice(cleanUrl.length);
const href = cleanUrl.startsWith('www.') ? 'https://' + cleanUrl : cleanUrl;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${cleanUrl}</a>${trailingPunct}`;
});
// Convert email addresses to mailto: links
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/gi;
escaped = escaped.replace(emailRegex, function(email) {
// Remove trailing punctuation that's not part of the email
let cleanEmail = email.replace(/[.,;:!?)\]]+$/, '');
const trailingPunct = email.slice(cleanEmail.length);
return `<a href="mailto:${cleanEmail}">${cleanEmail}</a>${trailingPunct}`;
});
return escaped;
}
// Add message to UI
function addMessage(role, content, messageId = null, techInfo = null) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${role}`;
const avatar = document.createElement('div');
avatar.className = 'message-avatar';
avatar.textContent = role === 'user' ? 'U' : 'AI';
const contentWrapper = document.createElement('div');
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
// Use innerHTML with linkified text for assistant messages (URLs become clickable)
if (role === 'assistant') {
contentDiv.innerHTML = linkifyText(content);
} else {
contentDiv.textContent = content;
}
contentWrapper.appendChild(contentDiv);
// Add feedback buttons for assistant messages
if (role === 'assistant' && messageId) {
const feedbackDiv = document.createElement('div');
feedbackDiv.className = 'message-feedback';
feedbackDiv.innerHTML = `
<button class="feedback-btn positive" onclick="sendFeedback(${messageId}, 2, this)" title="Pomocne">
<svg viewBox="0 0 20 20" fill="currentColor">
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z"/>
</svg>
</button>
<button class="feedback-btn negative" onclick="sendFeedback(${messageId}, 1, this)" title="Do poprawy">
<svg viewBox="0 0 20 20" fill="currentColor">
<path d="M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.105-1.79l-.05-.025A4 4 0 0011.055 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z"/>
</svg>
</button>
`;
contentWrapper.appendChild(feedbackDiv);
}
// Add tech info panel for assistant messages
if (role === 'assistant' && techInfo) {
const techPanel = createTechInfoPanel(techInfo);
contentWrapper.appendChild(techPanel);
}
messageDiv.appendChild(avatar);
messageDiv.appendChild(contentWrapper);
messagesContainer.insertBefore(messageDiv, typingIndicator);
scrollToBottom();
}
// Create tech info panel HTML
function createTechInfoPanel(info) {
const panel = document.createElement('details');
panel.className = 'tech-info-panel';
// Calculate progress bar width and color
const usagePercent = (info.free_tier.requests_today / info.free_tier.daily_limit) * 100;
let progressClass = '';
if (usagePercent > 80) progressClass = 'danger';
else if (usagePercent > 50) progressClass = 'warning';
panel.innerHTML = `
<summary>Info techniczne</summary>
<div class="tech-info-grid">
<div class="tech-info-item">
<span class="tech-info-label">Model AI</span>
<span class="tech-info-value">${info.model}</span>
</div>
<div class="tech-info-item">
<span class="tech-info-label">Źródło danych</span>
<span class="tech-info-value">${info.data_source}</span>
</div>
<div class="tech-info-item">
<span class="tech-info-label">Architektura</span>
<span class="tech-info-value">${info.architecture}</span>
</div>
<div class="tech-info-item">
<span class="tech-info-label">Tokeny (in/out)</span>
<span class="tech-info-value tokens">${info.tokens_input} / ${info.tokens_output}</span>
</div>
<div class="tech-info-item">
<span class="tech-info-label">Czas odpowiedzi</span>
<span class="tech-info-value">${info.latency_ms} ms</span>
</div>
<div class="tech-info-item">
<span class="tech-info-label">Koszt teoretyczny</span>
<span class="tech-info-value">$${info.theoretical_cost_usd.toFixed(6)}</span>
</div>
<div class="tech-info-item">
<span class="tech-info-label">Koszt faktyczny</span>
<span class="tech-info-value free">$0.00 (Free Tier)</span>
</div>
</div>
<div class="free-tier-bar">
<div style="display: flex; justify-content: space-between; font-size: 10px;">
<span>Free Tier: ${info.free_tier.requests_today}/${info.free_tier.daily_limit} req/dzień</span>
<span>${info.free_tier.tokens_today.toLocaleString()} tokenów dziś</span>
</div>
<div class="free-tier-progress">
<div class="free-tier-progress-fill ${progressClass}" style="width: ${Math.min(usagePercent, 100)}%"></div>
</div>
<div class="free-tier-info" style="font-size: 9px; color: #888; margin-top: 6px; line-height: 1.4;">
<strong>Limity Google Gemini Free Tier:</strong><br>
• RPD (Requests Per Day) = 1500 req/dzień - główny limit<br>
• RPM (Requests Per Minute) = 15 req/min<br>
• TPM (Tokens Per Minute) = 1M tokenów/min<br>
<em>Limit dzienny dotyczy requestów - tokeny są tylko monitorowane.</em>
</div>
</div>
`;
return panel;
}
// Send feedback
async function sendFeedback(messageId, rating, button) {
try {
const response = await fetch('/api/chat/feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrf_token]').value
},
body: JSON.stringify({ message_id: messageId, rating: rating })
});
const data = await response.json();
if (data.success) {
// Update button states
const feedbackDiv = button.parentElement;
feedbackDiv.querySelectorAll('.feedback-btn').forEach(btn => {
btn.classList.remove('active');
});
button.classList.add('active');
}
} catch (error) {
console.error('Feedback error:', error);
}
}
// Scroll to bottom
function scrollToBottom() {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Send suggestion
function sendSuggestion(text) {
messageInput.value = text;
chatForm.dispatchEvent(new Event('submit'));
}
// Enable Enter to send (Shift+Enter for new line)
messageInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
chatForm.dispatchEvent(new Event('submit'));
}
});
{% endblock %}