- 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>
911 lines
29 KiB
HTML
Executable File
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
|
|
// 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 %}
|