{% extends "base.html" %} {% block title %}NordaGPT - Norda Biznes Partner{% endblock %} {% block container_class %}chat-container-override{% endblock %} {% block extra_css %} {% endblock %} {% block content %}
NordaGPT

NordaGPT

Dziś
0%
Portal
0%

🤖 NordaGPT — Jak to działa

Czym jest NordaGPT?

NordaGPT to spersonalizowany asystent AI Izby Norda Biznes. Zna Ciebie, Twoją firmę i wszystkich członków Izby. Wyszukuje firmy pasujące do Twojego pytania, podaje ich dane kontaktowe i pomaga nawiązać kontakty biznesowe.

✨ Co potrafi NordaGPT?

Zna Ciebie Wie kim jesteś, z jakiej firmy, jakie masz plany biznesowe — i personalizuje odpowiedzi.
Pamięta Zapamiętuje o czym rozmawialiście — nawiązuje do wcześniejszych tematów i wniosków.
Szuka partnerów Przeszukuje bazę firm po branży, usługach, kompetencjach i lokalizacji.
Podaje kontakty Telefony, strony WWW, adresy — wszystko z weryfikowanej bazy portalu.
Zna aktualności Informuje o wydarzeniach Izby, przetargach PEJ, ogłoszeniach B2B i tematach forum.

🎯 Jak działa dopasowywanie firm?

NordaGPT nie zgaduje — przeszukuje bazę danych Izby na 5 sposobów: po kategorii branżowej, opisach usług, kompetencjach, kodach PKD i danych ze stron internetowych firm. Poleca tylko te firmy, które naprawdę pasują do Twojego pytania.

Każda wymieniona firma to prawdziwy członek Izby z aktywnym profilem na portalu — kliknij w nazwę, żeby zobaczyć pełny profil.

⚡ Szybkość odpowiedzi

NordaGPT automatycznie dobiera tryb pracy do złożoności pytania:

Szybka odpowiedź 2-4 sekundy — powitania, proste pytania, dane kontaktowe
Standardowa 5-10 sekund — wyszukiwanie firm, porównania
Dokładna analiza 10-20 sekund — złożone pytania strategiczne, rekomendacje partnerów

Tekst odpowiedzi pojawia się na żywo, słowo po słowie — nie musisz czekać na całą odpowiedź.

💡 Jak pisać pytania?

Bądź konkretny "Szukam firmy budowlanej do budowy hali" zadziała lepiej niż "kto buduje?"
Dopytuj Po odpowiedzi kliknij przyciski lub dopisz pytanie — NordaGPT pamięta kontekst rozmowy.
Pytaj o wszystko Firmy, wydarzenia, ogłoszenia B2B, forum, dane o PEJ — wszystko jest w bazie wiedzy.

📊 Limity

Korzystanie z NordaGPT jest wliczone w członkostwo w Izbie. Każdy użytkownik ma indywidualny dzienny limit zapytań widoczny na pasku u góry.

📜 Historia rozwoju NordaGPT

07.02.2026
Paid Tier 1 + Gemini 3 Pro

Przejście na płatny tier Google AI. Domyślnie Flash (thinking mode), opcja Pro dla najlepszych odpowiedzi. Hint „Spróbuj Pro" przy każdej odpowiedzi.

Aktualna wersja
28.01.2026
Gemini 3 Flash (Preview)

Najnowsza generacja AI od Google. 7x lepsze rozumowanie, thinking mode, 78% na benchmarku kodowania.

Upgrade modelu
14.01.2026
Gemini 2.5 Flash-Lite

8x dłuższe odpowiedzi, pełny thinking mode, 4x większy limit dzienny.

Poprzednia wersja
13.01.2026
Historia konwersacji

Sidebar z historią rozmów - możliwość powrotu do wcześniejszych konwersacji.

Nowa funkcja
Grudzień 2025
Gemini 2.0 Flash

Pierwszy model AI w NordaGPT. Kontekst 1M tokenów.

Poprzednia wersja
Listopad 2025
Uruchomienie NordaGPT

Premiera asystenta AI dla członków Norda Biznes. Integracja z bazą {{ COMPANY_COUNT }} firm.

Premiera

✨ Co działa w NordaGPT?

  • Wybór modelu — Flash (thinking mode, domyślny) lub Pro (premium, najlepsza analiza)
  • Baza {{ COMPANY_COUNT }} firm — pełna wiedza o członkach Izby
  • Forum i wydarzenia — dostęp do dyskusji i kalendarza
  • Linki w odpowiedziach — bezpośrednie odnośniki do profili firm i osób
  • Transparentne koszty — widoczny koszt każdej odpowiedzi i miesięczne zużycie
  • Historia rozmów — pełna historia konwersacji w sidebarze

🎬 Jak korzystać z NordaGPT?

Krótki przewodnik (40 sekund)

💡 Szybkie wskazówki

  • Znajdź firmę: "Kto oferuje usługi IT?"
  • Sprawdź prezesa: "Kto jest prezesem PIXLAB?"
  • Wydarzenia: "Kiedy następne spotkanie Norda?"
  • Rekomendacje: "Poleć drukarnie z dobrymi opiniami"
NordaGPT

NordaGPT - Asystent AI Norda Biznes

Mogę pomóc Ci znaleźć firmy, usługi, sprawdzić kalendarz wydarzeń, rekomendacje i wiele więcej.

{% endblock %} {% block extra_js %} // NordaGPT Chat - State const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; const AI_AVATAR_HTML = 'AI'; {% if current_user and current_user.avatar_path %} const USER_AVATAR_HTML = '{{ current_user.name[:1] }}'; {% else %} const USER_AVATAR_HTML = '{{ current_user.name[:1].upper() if current_user else "U" }}'; {% endif %} let currentConversationId = null; let conversations = []; let currentModel = 'flash'; // Default model (Gemini 3 Flash - thinking mode, 10K RPD) let monthlyUsageCost = 0; // Koszt miesięczny użytkownika const IS_ADMIN = {{ 'true' if current_user.is_admin or current_user.can_access_admin_panel() else 'false' }}; // ============================================ // Model Selection Toggle Functions // ============================================ const MODEL_CONFIG = { 'flash': { label: 'Flash', icon: '⚡', desc: 'Thinking' }, 'pro': { label: 'Pro', icon: '🧠', desc: 'Analiza' } }; function toggleModelDropdown() { const toggle = document.getElementById('modelToggle'); toggle.classList.toggle('open'); } function setModel(model) { currentModel = model; const config = MODEL_CONFIG[model]; // Update UI document.getElementById('modelLabel').textContent = config.label; document.getElementById('modelIcon').textContent = config.icon; // Update active state document.querySelectorAll('.thinking-option').forEach(opt => { opt.classList.toggle('active', opt.dataset.model === model); }); // Close dropdown document.getElementById('modelToggle').classList.remove('open'); // Save preference to server saveModelPreference(model); console.log('Model set to:', model); } async function saveModelPreference(model) { try { await fetch('/api/chat/settings', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ model: model }) }); } catch (error) { console.error('Failed to save model preference:', error); } } function updateMonthlyCost(cost) { monthlyUsageCost += cost; // Refresh usage bars after each message refreshUsageBars(); } function updateUsageBars(usage) { if (!usage) return; // Daily bar (user) const dailyBar = document.getElementById('dailyUsageBar'); const dailyPct = document.getElementById('dailyUsagePct'); if (dailyBar && dailyPct) { const dp = Math.min(usage.daily_percent || 0, 100); dailyBar.style.width = dp + '%'; dailyPct.textContent = Math.round(dp) + '%'; dailyBar.className = 'usage-bar-fill' + (dp >= 90 ? ' danger' : dp >= 60 ? ' warning' : ''); } // Global bar (portal) const globalBar = document.getElementById('globalUsageBar'); const globalPct = document.getElementById('globalUsagePct'); if (globalBar && globalPct) { const gp = Math.min(usage.global_monthly_percent || 0, 100); globalBar.style.width = gp + '%'; globalPct.textContent = Math.round(gp) + '%'; globalBar.className = 'usage-bar-fill global' + (gp >= 90 ? ' danger' : gp >= 60 ? ' warning' : ''); } // Check if any limit is close or exceeded const maxPct = Math.max(usage.daily_percent || 0, usage.weekly_percent || 0, usage.monthly_percent || 0); if (maxPct >= 100) { showLimitBanner(usage); } } function showLimitBanner(usage) { const banner = document.getElementById('limitExceededBanner'); const msg = document.getElementById('limitExceededMsg'); if (!banner || !msg) return; let text = ''; if ((usage.monthly_percent || 0) >= 100) { text = 'Wykorzystano miesięczny limit pytań do NordaGPT. Nowe pytania będą dostępne od 1. dnia kolejnego miesiąca.'; } else if ((usage.weekly_percent || 0) >= 100) { text = 'Wykorzystano tygodniowy limit pytań do NordaGPT. Nowe pytania będą dostępne od poniedziałku.'; } else if ((usage.daily_percent || 0) >= 100) { text = 'Wykorzystano dzisiejszy limit pytań do NordaGPT. Jutro będziesz mógł zadać kolejne pytania.'; } if (text) { msg.textContent = text; banner.classList.add('active'); } } async function requestHigherLimits() { try { const response = await fetch('/api/chat/request-higher-limits', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken } }); const data = await response.json(); if (data.success) { const btn = document.querySelector('.limit-exceeded-banner .limit-btn'); if (btn) { btn.textContent = 'Zgłoszenie wysłane'; btn.disabled = true; btn.style.opacity = '0.6'; } } } catch (error) { console.error('Error requesting higher limits:', error); } } async function refreshUsageBars() { try { const response = await fetch('/api/chat/settings'); const data = await response.json(); if (data.success && data.usage) { updateUsageBars(data.usage); } } catch (e) { console.log('Could not refresh usage bars'); } } // Close model dropdown when clicking outside document.addEventListener('click', function(e) { const toggle = document.getElementById('modelToggle'); if (toggle && !toggle.contains(e.target)) { toggle.classList.remove('open'); } }); // ============================================ // Model Info Modal Functions // ============================================ function openModelInfoModal() { document.getElementById('modelInfoModal').classList.add('active'); document.body.style.overflow = 'hidden'; } function closeModelInfoModal() { document.getElementById('modelInfoModal').classList.remove('active'); document.body.style.overflow = ''; } // Close modal on backdrop click document.addEventListener('click', function(e) { const modal = document.getElementById('modelInfoModal'); if (e.target === modal) { closeModelInfoModal(); } }); // Close modal on Escape key document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { closeModelInfoModal(); closeVideoHelpModal(); } }); // ============================================ // Video Help Modal Functions // ============================================ function openVideoHelpModal() { document.getElementById('videoHelpModal').classList.add('active'); document.body.style.overflow = 'hidden'; // Auto-play video when modal opens const video = document.getElementById('helpVideo'); if (video) { video.currentTime = 0; // Don't autoplay - let user control } } function closeVideoHelpModal() { document.getElementById('videoHelpModal').classList.remove('active'); document.body.style.overflow = ''; // Pause video when modal closes const video = document.getElementById('helpVideo'); if (video) { video.pause(); } } // Close video modal on backdrop click document.addEventListener('click', function(e) { const videoModal = document.getElementById('videoHelpModal'); if (e.target === videoModal) { closeVideoHelpModal(); } }); // Initialize on page load document.addEventListener('DOMContentLoaded', function() { loadConversations(); autoResizeTextarea(); loadModelSettings(); }); // Load model settings and usage from server async function loadModelSettings() { try { const response = await fetch('/api/chat/settings'); const data = await response.json(); if (data.success) { // Always start with Flash - ignore saved preference currentModel = 'flash'; const config = MODEL_CONFIG['flash']; document.getElementById('modelLabel').textContent = config.label; document.getElementById('modelIcon').textContent = config.icon; document.querySelectorAll('.thinking-option').forEach(opt => { opt.classList.toggle('active', opt.dataset.model === 'flash'); }); // Load usage bars if (data.monthly_cost !== undefined) { monthlyUsageCost = data.monthly_cost; } if (data.usage) { updateUsageBars(data.usage); } // Hide usage bars for unlimited users if (data.is_unlimited) { const container = document.getElementById('usageBarsContainer'); if (container) container.style.display = 'none'; } } } catch (error) { console.log('Using default model:', currentModel); } } // Load conversations list async function loadConversations() { try { const response = await fetch('/api/chat/conversations'); const data = await response.json(); if (data.success) { conversations = data.conversations; renderConversationsList(); } } catch (error) { console.error('Error loading conversations:', error); document.getElementById('conversationsList').innerHTML = ''; } } // Render conversations in sidebar function renderConversationsList() { const list = document.getElementById('conversationsList'); if (conversations.length === 0) { list.innerHTML = ''; return; } const pinned = conversations.filter(c => c.is_pinned); const unpinned = conversations.filter(c => !c.is_pinned); let html = ''; if (pinned.length > 0) { html += '
Przypięte
'; html += pinned.map(conv => renderConversationItem(conv, true)).join(''); } if (pinned.length > 0 && unpinned.length > 0) { html += '
Historia
'; } html += unpinned.map(conv => renderConversationItem(conv, false)).join(''); list.innerHTML = html; } function renderConversationItem(conv, isPinned) { return `
${isPinned ? '' : ''} ${escapeHtml(conv.title)}
`; } // Toggle pin/unpin conversation async function togglePin(conversationId) { try { const response = await fetch(`/api/chat/${conversationId}/pin`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken } }); const data = await response.json(); if (data.success) { // Update local state const conv = conversations.find(c => c.id === conversationId); if (conv) conv.is_pinned = data.is_pinned; // Re-sort: pinned first, then by updated_at conversations.sort((a, b) => { if (a.is_pinned !== b.is_pinned) return b.is_pinned ? 1 : -1; return parseUTC(b.updated_at) - parseUTC(a.updated_at); }); renderConversationsList(); } } catch (error) { console.error('Error toggling pin:', error); } } // Start inline rename function startRename(conversationId) { const item = document.querySelector(`.conversation-item[data-id="${conversationId}"]`); if (!item) return; const titleSpan = item.querySelector('.conversation-title'); const currentTitle = titleSpan.textContent; // Replace title with input const input = document.createElement('input'); input.type = 'text'; input.className = 'conversation-rename-input'; input.value = currentTitle; input.maxLength = 255; titleSpan.replaceWith(input); input.focus(); input.select(); // Prevent click from loading conversation const originalOnclick = item.onclick; item.onclick = null; function finishRename(save) { const newName = input.value.trim(); if (save && newName && newName !== currentTitle) { saveRename(conversationId, newName); } // Restore title span const span = document.createElement('span'); span.className = 'conversation-title'; span.textContent = save && newName ? newName : currentTitle; input.replaceWith(span); item.onclick = originalOnclick; } input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); finishRename(true); } if (e.key === 'Escape') { finishRename(false); } }); input.addEventListener('blur', () => finishRename(true)); } // Save renamed conversation async function saveRename(conversationId, name) { try { const response = await fetch(`/api/chat/${conversationId}/rename`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ name }) }); const data = await response.json(); if (data.success) { // Update local state const conv = conversations.find(c => c.id === conversationId); if (conv) { conv.custom_name = data.name; conv.title = data.name; } } } catch (error) { console.error('Error renaming conversation:', error); } } // Load a specific conversation async function loadConversation(conversationId) { currentConversationId = conversationId; // Update sidebar selection document.querySelectorAll('.conversation-item').forEach(item => { item.classList.toggle('active', parseInt(item.dataset.id) === conversationId); }); // Hide empty state const emptyState = document.getElementById('emptyState'); if (emptyState) emptyState.style.display = 'none'; // Clear messages and show loading const messagesDiv = document.getElementById('chatMessages'); messagesDiv.innerHTML = '
AI
Ładowanie historii...
'; try { const response = await fetch(`/api/chat/${conversationId}/history`); const data = await response.json(); if (data.success) { messagesDiv.innerHTML = ''; data.messages.forEach(msg => { addMessage(msg.role, msg.content, false); }); // Re-add typing indicator at the end messagesDiv.innerHTML += ` `; scrollToBottom(); } } catch (error) { console.error('Error loading conversation:', error); messagesDiv.innerHTML = '
AI
Nie udało się załadować rozmowy.
'; } // Close mobile sidebar document.getElementById('chatSidebar').classList.remove('mobile-open'); } // Start new conversation function startNewConversation() { currentConversationId = null; // Clear active state in sidebar document.querySelectorAll('.conversation-item').forEach(item => { item.classList.remove('active'); }); // Show empty state const messagesDiv = document.getElementById('chatMessages'); messagesDiv.innerHTML = `
NordaGPT

NordaGPT - Asystent AI Norda Biznes

Mogę pomóc Ci znaleźć firmy, usługi, sprawdzić kalendarz wydarzeń, rekomendacje i wiele więcej.

`; document.getElementById('messageInput').focus(); // Close mobile sidebar document.getElementById('chatSidebar').classList.remove('mobile-open'); } // Delete conversation async function deleteConversation(conversationId) { if (!confirm('Czy na pewno chcesz usunąć tę rozmowę?')) return; try { const response = await fetch(`/api/chat/${conversationId}/delete`, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken } }); const data = await response.json(); if (data.success) { // If deleted current conversation, start new if (currentConversationId === conversationId) { startNewConversation(); } // Reload conversations list loadConversations(); } } catch (error) { console.error('Error deleting conversation:', error); alert('Nie udało się usunąć rozmowy'); } } // Send message async function sendMessage() { const input = document.getElementById('messageInput'); const sendBtn = document.getElementById('sendBtn'); const message = input.value.trim(); if (!message) return; // Disable input input.value = ''; input.disabled = true; sendBtn.disabled = true; // Hide empty state const emptyState = document.getElementById('emptyState'); if (emptyState) emptyState.style.display = 'none'; // Add user message addMessage('user', message); scrollToBottom(); try { // Start new conversation if needed if (!currentConversationId) { const startResponse = await fetch('/api/chat/start', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ title: (() => { const firstSentence = message.split(/[.!?]\s/)[0]; return firstSentence.length <= 60 ? firstSentence : message.substring(0, 57) + '...'; })() }) }); const startData = await startResponse.json(); if (startData.success) { currentConversationId = startData.conversation_id; } else { throw new Error(startData.error || 'Failed to start conversation'); } } // Create assistant message bubble with thinking animation const messagesDiv = document.getElementById('chatMessages'); const typingIndicator = document.getElementById('typingIndicator'); const streamMsgDiv = document.createElement('div'); streamMsgDiv.className = 'message assistant'; const streamAvatar = document.createElement('div'); streamAvatar.className = 'message-avatar'; streamAvatar.innerHTML = AI_AVATAR_HTML; const streamContent = document.createElement('div'); streamContent.className = 'message-content'; // Thinking dots placeholder const thinkingDots = document.createElement('div'); thinkingDots.className = 'thinking-dots'; thinkingDots.innerHTML = ''; streamContent.appendChild(thinkingDots); streamMsgDiv.appendChild(streamAvatar); streamMsgDiv.appendChild(streamContent); if (typingIndicator) { messagesDiv.insertBefore(streamMsgDiv, typingIndicator); } else { messagesDiv.appendChild(streamMsgDiv); } scrollToBottom(); // Try streaming endpoint let streamingSucceeded = false; const abortController = new AbortController(); const streamTimeout = setTimeout(() => abortController.abort(), 60000); // 60s timeout try { const streamResp = await fetch(`/api/chat/${currentConversationId}/message/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ message: message, model: currentModel }), signal: abortController.signal }); if (!streamResp.ok) throw new Error(`HTTP ${streamResp.status}`); const reader = streamResp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let firstToken = true; let accumulatedText = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // keep incomplete line in buffer for (const line of lines) { if (!line.startsWith('data: ')) continue; const jsonStr = line.slice(6).trim(); if (!jsonStr) continue; let chunk; try { chunk = JSON.parse(jsonStr); } catch (e) { continue; } if (chunk.type === 'token') { // Remove thinking dots on first token if (firstToken) { thinkingDots.remove(); firstToken = false; } accumulatedText += chunk.content; streamContent.innerHTML = formatMessage(accumulatedText); scrollToBottom(); } else if (chunk.type === 'done') { // Render final post-processed text (links corrected server-side) if (chunk.full_text) { streamContent.innerHTML = formatMessage(chunk.full_text); } // Add tech info badge const techInfo = { model: currentModel, latency_ms: chunk.latency_ms || 0, cost_usd: chunk.cost_usd || 0 }; const infoBadge = document.createElement('div'); infoBadge.className = 'thinking-info-badge'; const latencySec = ((chunk.latency_ms || 0) / 1000).toFixed(1); const qualityLabels = {'minimal': 'szybka odpowiedź', 'low': 'standardowa', 'high': 'dokładna analiza'}; const qLabel = qualityLabels[chunk.thinking] || 'standardowa'; let badgeHTML = `${qLabel}`; badgeHTML += `⏱ ${latencySec}s`; if (IS_ADMIN) { const modelLabels = {'gemini-3-flash-preview': 'Flash', 'gemini-3.1-flash-lite-preview': 'Lite', 'gemini-3.1-pro-preview': 'Pro'}; const costStr = (chunk.cost_usd || 0) > 0 ? `$${(chunk.cost_usd).toFixed(4)}` : '$0.00'; badgeHTML += `${modelLabels[chunk.model || ''] || 'Flash'}`; badgeHTML += `💰 ${costStr}`; } infoBadge.innerHTML = badgeHTML; streamContent.appendChild(infoBadge); // Add follow-up suggestion chips const suggestions = document.createElement('div'); suggestions.className = 'follow-up-suggestions'; suggestions.innerHTML = ` `; streamContent.appendChild(suggestions); if (chunk.cost_usd) updateMonthlyCost(chunk.cost_usd); loadConversations(); scrollToBottom(); streamingSucceeded = true; } else if (chunk.type === 'error') { thinkingDots.remove(); streamContent.innerHTML = formatMessage('Przepraszam, wystąpił błąd: ' + chunk.content); scrollToBottom(); streamingSucceeded = true; // handled } } } clearTimeout(streamTimeout); } catch (streamError) { clearTimeout(streamTimeout); console.warn('Streaming failed, falling back to non-streaming:', streamError); // Remove the partial streaming bubble streamMsgDiv.remove(); } // Fallback: non-streaming if SSE failed if (!streamingSucceeded) { // Show typing indicator document.getElementById('typingIndicator').style.display = 'flex'; scrollToBottom(); const response = await fetch(`/api/chat/${currentConversationId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, body: JSON.stringify({ message: message, model: currentModel }) }); const data = await response.json(); document.getElementById('typingIndicator').style.display = 'none'; if (data.success) { addMessage('assistant', data.message, true, data.tech_info); loadConversations(); if (data.tech_info && data.tech_info.cost_usd) updateMonthlyCost(data.tech_info.cost_usd); } else if (data.limit_exceeded) { if (data.usage) updateUsageBars(data.usage); showLimitBanner(data.usage || {daily_percent: 100}); addMessage('assistant', data.error); } else { addMessage('assistant', 'Przepraszam, wystąpił błąd: ' + (data.error || 'Nieznany błąd')); } } } catch (error) { console.error('Error sending message:', error); document.getElementById('typingIndicator').style.display = 'none'; addMessage('assistant', 'Przepraszam, nie mogę teraz odpowiedzieć. Spróbuj ponownie później.'); } // Re-enable input input.disabled = false; sendBtn.disabled = false; input.focus(); } // Send suggestion function sendSuggestion(text) { document.getElementById('messageInput').value = text; sendMessage(); } // Add message to chat function addMessage(role, content, animate = true, techInfo = null) { const messagesDiv = document.getElementById('chatMessages'); const typingIndicator = document.getElementById('typingIndicator'); const messageDiv = document.createElement('div'); messageDiv.className = 'message ' + role; if (!animate) messageDiv.style.animation = 'none'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.innerHTML = role === 'assistant' ? AI_AVATAR_HTML : USER_AVATAR_HTML; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.innerHTML = formatMessage(content); // Add response info badge for AI responses (model, time, cost) if (role === 'assistant' && techInfo) { const infoBadge = document.createElement('div'); infoBadge.className = 'thinking-info-badge'; const modelName = techInfo.model || 'flash'; const latencyMs = parseInt(techInfo.latency_ms) || 0; const latencySec = (latencyMs / 1000).toFixed(1); const costUsd = parseFloat(techInfo.cost_usd) || 0; // Model labels const modelLabels = { 'flash': '⚡ Flash', 'pro': '🧠 Pro', 'gemini-3-flash-preview': '⚡ Flash', 'gemini-2.5-flash-lite': '⚡ Flash (fallback)', 'gemini-2.5-flash': '⚡ Flash (fallback)', 'gemini-3-pro-preview': '🧠 Pro' }; // Build badge content — user-friendly labels let badgeHTML = `⏱ ${latencySec}s`; if (IS_ADMIN) { const modelLabels = {'flash': 'Flash', 'pro': 'Pro', 'gemini-3-flash-preview': 'Flash', 'gemini-3.1-flash-lite-preview': 'Lite'}; const costStr = costUsd > 0 ? `$${costUsd.toFixed(4)}` : '$0.00'; badgeHTML += `${modelLabels[modelName] || modelName}`; badgeHTML += `💰 ${costStr}`; } infoBadge.innerHTML = badgeHTML; contentDiv.appendChild(infoBadge); // Update monthly cost if cost provided if (costUsd > 0) { updateMonthlyCost(costUsd); } } // Add follow-up chips to the LAST assistant message if (role === 'assistant') { // Remove chips from any previous assistant message document.querySelectorAll('.follow-up-suggestions').forEach(el => el.remove()); const suggestions = document.createElement('div'); suggestions.className = 'follow-up-suggestions'; suggestions.innerHTML = ` `; contentDiv.appendChild(suggestions); } messageDiv.appendChild(avatar); messageDiv.appendChild(contentDiv); // Insert before typing indicator if (typingIndicator) { messagesDiv.insertBefore(messageDiv, typingIndicator); } else { messagesDiv.appendChild(messageDiv); } scrollToBottom(); } // Format message content (links, lists, etc.) function formatMessage(text) { if (!text) return ''; // Escape HTML first text = escapeHtml(text); // Convert markdown links [text](url) to tags with appropriate class // Links: company (orange), person (green), forum (purple), news (green), b2b (yellow), external (blue) // First: Handle internal links starting with / text = text.replace(/\[([^\]]+)\]\((\/[^)]+)\)/g, function(match, linkText, url) { let linkClass = 'external-link'; if (url.startsWith('/company/')) { linkClass = 'company-link'; } else if (url.startsWith('/forum/')) { linkClass = 'forum-link'; } else if (url.startsWith('/news/') || url.startsWith('/aktualnosci/')) { linkClass = 'news-link'; } else if (url.startsWith('/b2b/') || url.startsWith('/ogloszenia/')) { linkClass = 'b2b-link'; } else if (url.startsWith('/osoba/')) { linkClass = 'person-link'; } return '' + linkText + ''; }); // Then: Handle full URLs (https://) text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, function(match, linkText, url) { let linkClass = 'external-link'; if (url.includes('nordabiznes.pl/company/')) { linkClass = 'company-link'; } else if (url.includes('nordabiznes.pl/osoba/')) { linkClass = 'person-link'; } else if (url.includes('nordabiznes.pl/forum/')) { linkClass = 'forum-link'; } else if (url.includes('nordabiznes.pl/news/') || url.includes('nordabiznes.pl/aktualnosci/')) { linkClass = 'news-link'; } else if (url.includes('nordabiznes.pl/b2b/') || url.includes('nordabiznes.pl/ogloszenia/')) { linkClass = 'b2b-link'; } return '' + linkText + ''; }); // Convert raw URLs to links (only those not already in tags) text = text.replace(/(?)(https?:\/\/[^\s<]+)(?![^<]*<\/a>)/g, function(match, url) { let linkClass = 'external-link'; if (url.includes('nordabiznes.pl/company/')) { linkClass = 'company-link'; } else if (url.includes('nordabiznes.pl/osoba/')) { linkClass = 'person-link'; } return '' + url + ''; }); // Headers: ### text ->

, ## text ->

(before bold, before \n ->
) text = text.replace(/^### (.+)$/gm, '

$1

'); text = text.replace(/^## (.+)$/gm, '

$1

'); // Bullet lists: "* text" or "- text" at start of line (before \n ->
) text = text.replace(/^\* (.+)$/gm, '
  • $1
  • '); text = text.replace(/^- (.+)$/gm, '
  • $1
  • '); // Convert **bold** to text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Convert newlines to
    text = text.replace(/\n/g, '
    '); return text; } // Escape HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Scroll to bottom function scrollToBottom() { const messagesDiv = document.getElementById('chatMessages'); // Prosty i niezawodny scroll do dołu kontenera wiadomości requestAnimationFrame(() => { messagesDiv.scrollTop = messagesDiv.scrollHeight; }); } // Auto-resize textarea function autoResizeTextarea() { const textarea = document.getElementById('messageInput'); textarea.addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 150) + 'px'; }); } // Toggle sidebar (collapse/expand on desktop, show/hide on mobile) function toggleSidebar() { const sidebar = document.getElementById('chatSidebar'); const icon = document.getElementById('sidebarToggleIcon'); if (window.innerWidth <= 768) { sidebar.classList.toggle('mobile-open'); return; } sidebar.classList.toggle('collapsed'); const isCollapsed = sidebar.classList.contains('collapsed'); // Flip arrow direction icon.innerHTML = isCollapsed ? '' : ''; // Remember state localStorage.setItem('chatSidebarCollapsed', isCollapsed ? '1' : '0'); } // Restore sidebar state on load (function restoreSidebarState() { if (localStorage.getItem('chatSidebarCollapsed') === '1' && window.innerWidth > 768) { const sidebar = document.getElementById('chatSidebar'); const icon = document.getElementById('sidebarToggleIcon'); sidebar.classList.add('collapsed'); icon.innerHTML = ''; } // Restore saved width const savedWidth = localStorage.getItem('chatSidebarWidth'); if (savedWidth && window.innerWidth > 768) { document.getElementById('chatSidebar').style.width = savedWidth + 'px'; } })(); // Sidebar resize functionality (function initSidebarResize() { const handle = document.getElementById('sidebarResizeHandle'); const sidebar = document.getElementById('chatSidebar'); let isResizing = false; handle.addEventListener('mousedown', (e) => { isResizing = true; sidebar.classList.add('resizing'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const newWidth = Math.max(200, Math.min(500, e.clientX)); sidebar.style.width = newWidth + 'px'; }); document.addEventListener('mouseup', () => { if (!isResizing) return; isResizing = false; sidebar.classList.remove('resizing'); document.body.style.cursor = ''; document.body.style.userSelect = ''; localStorage.setItem('chatSidebarWidth', parseInt(sidebar.style.width)); }); })(); {% endblock %}