nordabiz/templates/chat.html
Maciej Pienczyn df6ef48f5f
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
fix(chat): red pin icon for pinned, gray for unpin action
Pinned conversations show red pushpin. On hover, pin button fades
to gray to indicate "unpin" action. Unpinned items show red on
hover to indicate "pin" action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:34:43 +01:00

2474 lines
79 KiB
HTML
Executable File

{% extends "base.html" %}
{% block title %}NordaGPT - Norda Biznes Partner{% endblock %}
{% block container_class %}chat-container-override{% endblock %}
{% block extra_css %}
<style>
/* Reset dla pełnoekranowego chatu jak ChatGPT/Claude */
:root {
/* Wysokość nagłówka: 73px navbar + 36px admin bar (jeśli admin) */
--header-height: {% if current_user.is_authenticated and current_user.can_access_admin_panel() %}109px{% else %}73px{% endif %};
}
html, body {
overflow: hidden !important; /* Blokada scrollowania strony */
height: 100% !important;
}
/* Ukrycie footera na stronie chatu */
footer {
display: none !important;
}
main {
padding: 0 !important;
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height)) !important; /* Wysokość minus navbar (+ admin bar) */
max-height: calc(100vh - var(--header-height)) !important;
overflow: hidden !important;
}
main > .container.chat-container-override {
max-width: 100% !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
}
.chat-layout {
display: flex;
flex: 1;
width: 100%;
background: var(--background);
overflow: hidden;
}
/* ============================================
SIDEBAR - Historia konwersacji (jasna wersja)
============================================ */
.chat-sidebar {
width: 280px;
min-width: 200px;
max-width: 500px;
background: #f5f7fa;
color: #374151;
display: flex;
flex-direction: column;
border-right: 1px solid #e5e7eb;
flex-shrink: 0;
position: relative;
transition: width 0.2s ease, min-width 0.2s ease;
}
.chat-sidebar.collapsed {
width: 48px !important;
min-width: 48px !important;
overflow: hidden;
}
.chat-sidebar.collapsed .sidebar-header,
.chat-sidebar.collapsed .sidebar-title,
.chat-sidebar.collapsed .conversations-list {
display: none;
}
.chat-sidebar.resizing {
transition: none;
user-select: none;
}
/* Sidebar toggle button */
.sidebar-toggle-btn {
position: absolute;
top: 12px;
right: -14px;
width: 28px;
height: 28px;
background: #f5f7fa;
border: 1px solid #e5e7eb;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
color: #6b7280;
transition: var(--transition);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.sidebar-toggle-btn:hover {
background: #ffffff;
color: #374151;
}
.chat-sidebar.collapsed .sidebar-toggle-btn {
right: -14px;
}
/* Resize handle */
.sidebar-resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 5;
}
.sidebar-resize-handle:hover,
.sidebar-resize-handle.active {
background: linear-gradient(90deg, transparent, rgba(30, 48, 80, 0.15), transparent);
}
.chat-sidebar.collapsed .sidebar-resize-handle {
display: none;
}
.sidebar-header {
padding: var(--spacing-md);
border-bottom: 1px solid #e5e7eb;
}
.new-chat-btn {
width: 100%;
padding: var(--spacing-md);
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
border: none;
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
transition: var(--transition);
}
.new-chat-btn:hover {
background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
transform: translateY(-1px);
}
.new-chat-btn svg {
width: 18px;
height: 18px;
}
.sidebar-title {
padding: var(--spacing-md);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.1em;
color: #6b7280;
font-weight: 600;
}
.conversations-list {
flex: 1;
overflow-y: auto;
padding: 0 var(--spacing-sm);
}
.conversation-item {
display: flex;
align-items: flex-start;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
cursor: pointer;
margin-bottom: 2px;
transition: var(--transition);
gap: var(--spacing-sm);
}
.conversation-item:hover {
background: #ffffff;
}
.conversation-item.active {
background: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.conversation-item svg {
width: 16px;
height: 16px;
color: #9ca3af;
flex-shrink: 0;
}
.conversation-item svg.conversation-pin-icon {
color: #dc2626;
}
.conversation-title {
flex: 1;
font-size: var(--font-size-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
min-width: 0;
word-break: break-word;
}
.conversation-item svg:first-child {
margin-top: 2px;
}
.conversation-actions {
display: flex;
gap: 2px;
opacity: 0;
flex-shrink: 0;
transition: var(--transition);
}
.conversation-item:hover .conversation-actions {
opacity: 1;
}
.conversation-action-btn {
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
transition: var(--transition);
display: flex;
align-items: center;
}
.conversation-action-btn:hover {
color: #6b7280;
background: rgba(0, 0, 0, 0.05);
}
.conversation-action-btn.pin-btn:hover {
color: #dc2626;
background: rgba(220, 38, 38, 0.1);
}
.conversation-action-btn.delete-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.conversation-pin-icon {
color: #dc2626 !important;
flex-shrink: 0;
width: 14px;
height: 14px;
margin-right: 2px;
margin-top: 2px;
}
.conversations-section-title {
font-size: 11px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--spacing-sm) var(--spacing-md) 4px;
}
/* Rename inline input */
.conversation-rename-input {
flex: 1;
font-size: var(--font-size-sm);
border: 1px solid #d1d5db;
border-radius: var(--radius-sm);
padding: 2px 6px;
outline: none;
background: white;
min-width: 0;
}
.conversation-rename-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
/* Keep actions visible for pinned items */
.conversation-item.pinned .conversation-actions .pin-btn {
opacity: 1;
color: #dc2626;
}
/* Unpin button - when hovering pinned item, pin btn shows "unpin" style */
.conversation-item.pinned:hover .conversation-actions .pin-btn {
color: #9ca3af;
}
.conversation-item.pinned:hover .conversation-actions .pin-btn:hover {
color: #6b7280;
background: rgba(0, 0, 0, 0.05);
}
.conversation-item.pinned .conversation-actions {
opacity: 1;
}
.conversation-item.pinned .conversation-actions .delete-btn,
.conversation-item.pinned .conversation-actions .rename-btn {
opacity: 0;
}
.conversation-item.pinned:hover .conversation-actions .delete-btn,
.conversation-item.pinned:hover .conversation-actions .rename-btn {
opacity: 1;
}
.sidebar-empty {
padding: var(--spacing-lg);
text-align: center;
color: #6b7280;
font-size: var(--font-size-sm);
}
.sidebar-loading {
padding: var(--spacing-lg);
text-align: center;
color: #6b7280;
}
/* ============================================
MAIN CHAT AREA - Styl NordaGPT
============================================ */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%; /* Wymuszenie pełnej wysokości */
max-height: 100%; /* Nie może przekroczyć rodzica */
overflow: hidden;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
position: relative;
z-index: 50;
flex-shrink: 0; /* Header nie może się kurczyć */
}
.chat-header-left {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.chat-header h1 {
font-size: var(--font-size-lg);
font-weight: 600;
margin: 0;
}
.chat-header-badge {
background: rgba(255,255,255,0.2);
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
}
.chat-messages {
flex: 1;
min-height: 0; /* Kluczowe dla poprawnego działania overflow w flexbox */
overflow-y: auto;
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
background: white;
}
.message {
display: flex;
gap: var(--spacing-md);
max-width: 85%;
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: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.message.assistant .message-avatar {
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
}
.message.user .message-avatar {
background: var(--primary);
color: white;
}
.message-content {
padding: var(--spacing-md);
border-radius: var(--radius-lg);
line-height: 1.6;
}
.message.assistant .message-content {
background: #f3f4f6;
color: var(--text-primary);
border-bottom-left-radius: 4px;
}
.message.user .message-content {
background: var(--primary);
color: white;
border-bottom-right-radius: 4px;
}
/* Thinking info badge - pokazuje tryb i czas odpowiedzi */
.thinking-info-badge {
display: flex;
align-items: center;
gap: 4px;
margin-top: var(--spacing-sm);
padding-top: var(--spacing-sm);
border-top: 1px solid #e5e7eb;
font-size: 11px;
color: #9ca3af;
}
.thinking-badge-level {
color: #2E4872;
font-weight: 500;
}
.thinking-badge-time {
color: #6b7280;
}
.thinking-badge-desc {
color: #9ca3af;
font-style: italic;
}
.thinking-badge-cost {
color: #f59e0b;
font-weight: 500;
}
.pro-upgrade-hint {
color: #9ca3af;
text-decoration: none;
font-size: 11px;
transition: color 0.2s;
}
.pro-upgrade-hint:hover {
color: #2E4872;
}
.pro-upgrade-hint strong {
color: #6366f1;
}
/* Klikalne linki jako kolorowe badge'y */
.message-content a {
display: inline-block;
padding: 2px 10px;
margin: 1px 2px;
border-radius: 12px;
text-decoration: none;
font-weight: 600;
font-size: 0.95em;
transition: var(--transition);
}
/* 🏢 Linki do FIRM (nordabiznes.pl/company/) - pomarańczowy */
.message-content a.company-link {
background: #fff7ed;
color: #c2410c;
}
.message-content a.company-link:hover {
background: #ffedd5;
color: #9a3412;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(194, 65, 12, 0.2);
}
/* 👤 Linki do OSÓB (nordabiznes.pl/osoba/) - zielony */
.message-content a.person-link {
background: #ecfdf5;
color: #047857;
}
.message-content a.person-link:hover {
background: #d1fae5;
color: #065f46;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(4, 120, 87, 0.2);
}
/* 💬 Linki do FORUM - fioletowy */
.message-content a.forum-link {
background: #f5f3ff;
color: #2E4872;
}
.message-content a.forum-link:hover {
background: #ede9fe;
color: #6d28d9;
transform: translateY(-1px);
}
/* 📰 Linki do AKTUALNOŚCI - zielony */
.message-content a.news-link {
background: #ecfdf5;
color: #059669;
}
.message-content a.news-link:hover {
background: #d1fae5;
color: #047857;
transform: translateY(-1px);
}
/* 💼 Linki do B2B - żółty */
.message-content a.b2b-link {
background: #fefce8;
color: #ca8a04;
}
.message-content a.b2b-link:hover {
background: #fef9c3;
color: #a16207;
transform: translateY(-1px);
}
/* 🔗 Linki ZEWNĘTRZNE (www, social media, maps) - niebieski */
.message-content a.external-link {
background: #eff6ff;
color: #1d4ed8;
}
.message-content a.external-link:hover {
background: #dbeafe;
color: #1e40af;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(29, 78, 216, 0.2);
}
/* Linki w wiadomościach użytkownika - białe tło */
.message.user .message-content a {
background: rgba(255,255,255,0.25);
color: white;
}
.message.user .message-content a:hover {
background: rgba(255,255,255,0.4);
}
.message-content ul, .message-content ol {
margin: var(--spacing-sm) 0;
padding-left: var(--spacing-lg);
}
.message-content li {
margin-bottom: var(--spacing-xs);
}
.message-content strong {
font-weight: 600;
}
/* Typing indicator */
.typing-indicator {
display: none;
align-items: center;
gap: 4px;
padding: var(--spacing-md);
background: #f3f4f6;
border-radius: var(--radius-lg);
width: fit-content;
}
.typing-indicator.active {
display: flex;
}
.typing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #2E4872;
animation: typingBounce 1.4s infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
30% { transform: translateY(-6px); opacity: 1; }
}
/* Empty state */
.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-icon {
font-size: 4rem;
margin-bottom: var(--spacing-lg);
opacity: 0.5;
}
.empty-state h2 {
color: var(--text-primary);
margin-bottom: var(--spacing-md);
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
justify-content: center;
margin-top: var(--spacing-lg);
max-width: 600px;
}
.suggestion-chip {
padding: var(--spacing-sm) var(--spacing-md);
background: white;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
cursor: pointer;
font-size: var(--font-size-sm);
transition: var(--transition);
}
.suggestion-chip:hover {
border-color: #2E4872;
color: #2E4872;
background: #f5f3ff;
}
/* Input area */
.chat-input-area {
padding: var(--spacing-lg);
padding-bottom: calc(var(--spacing-lg) + env(safe-area-inset-bottom, 8px));
background: white;
border-top: 1px solid var(--border);
flex-shrink: 0; /* Input area nie może się kurczyć - zawsze widoczna na dole */
}
.chat-input-wrapper {
display: flex;
gap: var(--spacing-md);
max-width: 900px;
margin: 0 auto;
}
.chat-input {
flex: 1;
padding: var(--spacing-md);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
font-size: var(--font-size-base);
font-family: inherit;
resize: none;
min-height: 50px;
max-height: 150px;
transition: var(--transition);
}
.chat-input:focus {
outline: none;
border-color: #2E4872;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.send-btn {
padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: var(--spacing-sm);
transition: var(--transition);
}
.send-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
transform: translateY(-1px);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-btn svg {
width: 18px;
height: 18px;
}
/* Mobile responsive */
@media (max-width: 768px) {
.chat-sidebar {
display: none;
position: fixed;
top: var(--header-height);
left: 0;
bottom: 0;
z-index: 100;
width: 280px;
box-shadow: 4px 0 20px rgba(0,0,0,0.1);
}
.chat-sidebar.mobile-open {
display: flex;
}
.mobile-menu-btn {
display: flex !important;
}
.message {
max-width: 95%;
}
}
.mobile-menu-btn {
display: none;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 36px;
height: 36px;
border-radius: var(--radius);
cursor: pointer;
align-items: center;
justify-content: center;
margin-right: var(--spacing-sm);
}
/* ============================================
Thinking Mode Toggle
============================================ */
.thinking-toggle {
position: relative;
margin-left: var(--spacing-sm);
}
.thinking-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
border-radius: var(--radius);
color: white;
font-size: var(--font-size-xs);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.thinking-btn:hover {
background: rgba(255,255,255,0.3);
}
.thinking-icon {
font-size: 14px;
}
.thinking-arrow {
transition: transform 0.2s ease;
}
.thinking-toggle.open .thinking-arrow {
transform: rotate(180deg);
}
.thinking-dropdown {
display: none;
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 280px;
background: white;
border-radius: var(--radius-lg);
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
z-index: 100;
overflow: hidden;
animation: dropdownSlide 0.2s ease;
}
@keyframes dropdownSlide {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.thinking-toggle.open .thinking-dropdown {
display: block;
}
.thinking-dropdown-header {
padding: var(--spacing-md);
background: #f5f3ff;
border-bottom: 1px solid #e5e7eb;
}
.thinking-dropdown-header strong {
display: block;
color: #1e3050;
font-size: var(--font-size-sm);
margin-bottom: 4px;
}
.thinking-dropdown-header p {
color: var(--text-secondary);
font-size: var(--font-size-xs);
margin: 0;
}
.thinking-option {
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
transition: var(--transition);
border-bottom: 1px solid #f3f4f6;
}
.thinking-option:last-child {
border-bottom: none;
}
.thinking-option:hover {
background: #f9fafb;
}
.thinking-option.active {
background: #f5f3ff;
border-left: 3px solid #2E4872;
}
.thinking-option-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: 4px;
}
.thinking-option-icon {
font-size: 16px;
}
.thinking-option-name {
font-weight: 600;
color: var(--text-primary);
font-size: var(--font-size-sm);
}
.thinking-option-badge {
font-size: 10px;
padding: 2px 6px;
background: #2E4872;
color: white;
border-radius: var(--radius-sm);
font-weight: 500;
}
.thinking-option-badge.premium {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.thinking-option-badge.free {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.thinking-option-price.free {
color: #10b981;
font-weight: 500;
font-style: normal;
}
.thinking-option-price {
display: block;
font-size: 11px;
color: #6b7280;
margin-top: 4px;
font-style: italic;
}
/* Monthly Cost Badge */
.monthly-cost-badge {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255,255,255,0.15);
padding: 6px 12px;
border-radius: var(--radius-md);
font-size: 12px;
margin-left: var(--spacing-sm);
}
.monthly-cost-badge .cost-icon {
font-size: 14px;
}
.monthly-cost-badge .cost-label {
opacity: 0.8;
}
.monthly-cost-badge .cost-value {
font-weight: 600;
color: #fef08a;
}
@media (max-width: 768px) {
.monthly-cost-badge .cost-label {
display: none;
}
}
.thinking-option-desc {
color: var(--text-secondary);
font-size: var(--font-size-xs);
margin: 0;
line-height: 1.4;
}
@media (max-width: 768px) {
.thinking-label {
display: none;
}
.thinking-dropdown {
width: 260px;
right: -60px;
}
}
/* ============================================
Model Info Button & Modal
============================================ */
.model-info-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: var(--spacing-xs);
transition: var(--transition);
}
.model-info-btn:hover {
background: rgba(255,255,255,0.4);
transform: scale(1.1);
}
.model-info-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
padding: var(--spacing-lg);
overflow-y: auto;
animation: fadeIn 0.2s ease;
}
.model-info-modal.active {
display: flex;
align-items: flex-start;
justify-content: center;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.model-info-content {
background: white;
border-radius: var(--radius-lg);
max-width: 600px;
width: 100%;
padding: var(--spacing-xl);
margin-top: 60px;
position: relative;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.model-info-close {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.model-info-close:hover {
background: #f3f4f6;
color: var(--text-primary);
}
.model-info-content h2 {
font-size: var(--font-size-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
padding-right: var(--spacing-xl);
}
.model-info-content h3 {
font-size: var(--font-size-md);
color: var(--text-primary);
margin: var(--spacing-lg) 0 var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.model-current {
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
border: 1px solid #c4b5fd;
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.model-current h3 {
margin-top: 0;
}
.model-badge-large {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.model-name {
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
font-weight: 600;
font-size: var(--font-size-md);
}
.model-provider {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.model-description {
color: var(--text-secondary);
font-size: var(--font-size-sm);
line-height: 1.6;
margin: 0;
}
.specs-table {
width: 100%;
border-collapse: collapse;
}
.specs-table td {
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border);
}
.specs-table td:first-child {
color: var(--text-secondary);
width: 40%;
}
.specs-table td:last-child {
text-align: right;
}
.spec-change {
display: inline-block;
font-size: var(--font-size-xs);
color: #16a34a;
background: #dcfce7;
padding: 1px 6px;
border-radius: var(--radius);
margin-left: var(--spacing-xs);
font-weight: 500;
}
.timeline {
position: relative;
padding-left: var(--spacing-lg);
}
.timeline::before {
content: '';
position: absolute;
left: 6px;
top: 8px;
bottom: 8px;
width: 2px;
background: #e5e7eb;
}
.timeline-item {
position: relative;
padding-bottom: var(--spacing-lg);
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item::before {
content: '';
position: absolute;
left: calc(-1 * var(--spacing-lg) + 2px);
top: 6px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #d1d5db;
border: 2px solid white;
}
.timeline-item.current::before {
background: #2E4872;
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.2);
}
.timeline-date {
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
font-weight: 600;
}
.timeline-content strong {
color: var(--text-primary);
display: block;
margin-bottom: var(--spacing-xs);
}
.timeline-content p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin: 0 0 var(--spacing-sm);
line-height: 1.5;
}
.timeline-badge {
display: inline-block;
font-size: var(--font-size-xs);
padding: 2px var(--spacing-sm);
border-radius: var(--radius);
background: #f3f4f6;
color: var(--text-secondary);
}
.timeline-badge.upgrade {
background: linear-gradient(135deg, #1e3050 0%, #2E4872 100%);
color: white;
}
.timeline-badge.feature {
background: #dbeafe;
color: #1d4ed8;
}
.timeline-badge.launch {
background: #dcfce7;
color: #16a34a;
}
.model-benefits ul {
margin: 0;
padding-left: var(--spacing-lg);
}
.model-benefits li {
padding: var(--spacing-xs) 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.model-benefits li strong {
color: var(--text-primary);
}
/* ============================================
Video Help Button & Modal
============================================ */
.video-help-btn {
background: rgba(255,255,255,0.3) !important;
}
.video-help-btn:hover {
background: rgba(255,255,255,0.5) !important;
}
.video-help-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 1001;
padding: var(--spacing-lg);
overflow-y: auto;
animation: fadeIn 0.2s ease;
}
.video-help-modal.active {
display: flex;
align-items: flex-start;
justify-content: center;
}
.video-help-content {
background: white;
border-radius: var(--radius-lg);
max-width: 800px;
width: 100%;
padding: var(--spacing-xl);
margin-top: 40px;
position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
animation: slideUp 0.3s ease;
}
.video-help-close {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.video-help-close:hover {
background: #f3f4f6;
color: var(--text-primary);
}
.video-help-content h2 {
font-size: var(--font-size-xl);
color: var(--text-primary);
margin: 0 0 var(--spacing-xs);
padding-right: var(--spacing-xl);
}
.video-help-subtitle {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin: 0 0 var(--spacing-lg);
}
.video-container {
position: relative;
background: #000;
border-radius: var(--radius);
overflow: hidden;
margin-bottom: var(--spacing-lg);
}
.video-container video {
width: 100%;
display: block;
max-height: 450px;
}
.video-help-tips {
background: #f5f3ff;
border: 1px solid #c4b5fd;
border-radius: var(--radius);
padding: var(--spacing-lg);
}
.video-help-tips h3 {
font-size: var(--font-size-md);
color: var(--text-primary);
margin: 0 0 var(--spacing-md);
}
.video-help-tips ul {
margin: 0;
padding-left: var(--spacing-lg);
}
.video-help-tips li {
padding: var(--spacing-xs) 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.video-help-tips li strong {
color: #1e3050;
}
@media (max-width: 768px) {
.video-help-content {
margin-top: 20px;
padding: var(--spacing-md);
}
.video-container video {
max-height: 280px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="chat-layout">
<!-- Sidebar z historią konwersacji -->
<aside class="chat-sidebar" id="chatSidebar">
<button class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Zwiń/rozwiń panel" id="sidebarToggleBtn">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14" id="sidebarToggleIcon">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/>
</svg>
</button>
<div class="sidebar-resize-handle" id="sidebarResizeHandle"></div>
<div class="sidebar-header">
<button class="new-chat-btn" onclick="startNewConversation()">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Nowa rozmowa
</button>
</div>
<div class="sidebar-title">Historia rozmów</div>
<div class="conversations-list" id="conversationsList">
<div class="sidebar-loading">Ładowanie...</div>
</div>
</aside>
<!-- Główny obszar chatu -->
<main class="chat-main">
<header class="chat-header">
<div class="chat-header-left">
<button class="mobile-menu-btn" onclick="toggleSidebar()">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="20" height="20">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<img src="{{ url_for('static', filename='img/nordagpt-icon.svg') }}" alt="NordaGPT" style="width: 32px; height: 32px;">
<h1>NordaGPT</h1>
<!-- Model Selection Toggle -->
<div class="thinking-toggle" id="modelToggle">
<button class="thinking-btn" onclick="toggleModelDropdown()" title="Wybierz model AI">
<span class="thinking-icon" id="modelIcon"></span>
<span class="thinking-label" id="modelLabel">Flash</span>
<svg class="thinking-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24" width="12" height="12">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div class="thinking-dropdown" id="modelDropdown">
<div class="thinking-dropdown-header">
<strong>Model AI</strong>
<p>Wybierz model dopasowany do pytania</p>
</div>
<div class="thinking-option active" data-model="flash" onclick="setModel('flash')">
<div class="thinking-option-header">
<span class="thinking-option-icon"></span>
<span class="thinking-option-name">Flash</span>
<span class="thinking-option-badge">Domyślny</span>
</div>
<p class="thinking-option-desc">Thinking mode — szybki i inteligentny. Dla pytań o firmy, kontakty, strategie.</p>
<span class="thinking-option-price">~$0.04/pytanie · 10 000 zapytań/dzień</span>
</div>
<div class="thinking-option" data-model="pro" onclick="setModel('pro')">
<div class="thinking-option-header">
<span class="thinking-option-icon">🧠</span>
<span class="thinking-option-name">Pro</span>
<span class="thinking-option-badge premium">Premium</span>
</div>
<p class="thinking-option-desc">Najlepsza analiza i rozumowanie. Dla złożonych raportów i rekomendacji.</p>
<span class="thinking-option-price">~$0.20/pytanie · limit: $2/dzień</span>
</div>
</div>
</div>
<!-- Monthly cost display -->
<div class="monthly-cost-badge" title="Twoje koszty AI w tym miesiącu">
<span class="cost-icon">💰</span>
<span class="cost-label">Ten miesiąc:</span>
<span class="cost-value" id="monthlyCostDisplay">$0.00</span>
</div>
<button class="model-info-btn" onclick="openModelInfoModal()" title="Informacje o modelu AI">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<button class="model-info-btn video-help-btn" onclick="openVideoHelpModal()" title="Jak korzystać z NordaGPT? (Wideo)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
</div>
</header>
<!-- Modal z informacjami o modelu AI i historią rozwoju -->
<div class="model-info-modal" id="modelInfoModal">
<div class="model-info-content">
<button class="model-info-close" onclick="closeModelInfoModal()">&times;</button>
<h2>🤖 NordaGPT - Informacje techniczne</h2>
<div class="model-current">
<h3>Dostępne modele AI</h3>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<div class="model-badge-large" style="flex: 1; min-width: 150px; background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); border: 2px solid #3b82f6;">
<span class="model-name">⚡ Flash</span>
<span class="model-provider" style="color: #3b82f6;">DOMYŚLNY</span>
</div>
<div class="model-badge-large" style="flex: 1; min-width: 150px; background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%); border: 2px solid #a855f7;">
<span class="model-name">🧠 Pro</span>
<span class="model-provider" style="color: #a855f7;">PREMIUM</span>
</div>
</div>
<p class="model-description">
<strong>Flash</strong> — Gemini 3 Flash z thinking mode, 10 000 zapytań/dzień.
<strong>Pro</strong> — Gemini 3 Pro, najlepsza analiza dla złożonych pytań.
</p>
</div>
<div class="model-specs">
<h3>📊 Specyfikacja techniczna</h3>
<table class="specs-table">
<tr>
<td>Kontekst:</td>
<td><strong>1 000 000 tokenów</strong></td>
</tr>
<tr>
<td>Max. odpowiedź:</td>
<td><strong>65 536 tokenów</strong></td>
</tr>
<tr>
<td>Reasoning:</td>
<td><strong>7x lepszy</strong> <span class="spec-change">↑ vs 2.5</span></td>
</tr>
<tr>
<td>Thinking mode:</td>
<td><strong>Flash: wysoki</strong> / <strong>Pro: zaawansowany</strong></td>
</tr>
<tr>
<td>Kodowanie (SWE-bench):</td>
<td><strong>78%</strong> <span class="spec-change">↑ najlepszy w klasie</span></td>
</tr>
<tr>
<td>Nauka (GPQA Diamond):</td>
<td><strong>90.4%</strong></td>
</tr>
</table>
</div>
<div class="model-specs">
<h3>💰 Koszty i limity</h3>
<table class="specs-table">
<tr>
<td>⚡ Flash:</td>
<td><strong>~$0.02-0.05</strong> za odpowiedź (Paid Tier 1)</td>
</tr>
<tr>
<td>🧠 Pro:</td>
<td><strong>~$0.10-0.30</strong> za odpowiedź (Paid Tier 1)</td>
</tr>
<tr>
<td>Limit dzienny Pro:</td>
<td><strong>$2.00</strong> / dzień</td>
</tr>
<tr>
<td>Limit miesięczny Pro:</td>
<td><strong>$20.00</strong> / miesiąc</td>
</tr>
<tr>
<td>Limit Flash:</td>
<td><strong>10 000</strong> zapytań/dzień</td>
</tr>
</table>
<p style="font-size: 0.85em; color: #666; margin-top: 0.5rem;">
Koszt każdej odpowiedzi widoczny w badge pod wiadomością. Miesięczne zużycie w nagłówku chatu.
</p>
</div>
<div class="model-history">
<h3>📜 Historia rozwoju NordaGPT</h3>
<div class="timeline">
<div class="timeline-item current">
<div class="timeline-date">07.02.2026</div>
<div class="timeline-content">
<strong>Paid Tier 1 + Gemini 3 Pro</strong>
<p>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.</p>
<span class="timeline-badge upgrade">Aktualna wersja</span>
</div>
</div>
<div class="timeline-item">
<div class="timeline-date">28.01.2026</div>
<div class="timeline-content">
<strong>Gemini 3 Flash (Preview)</strong>
<p>Najnowsza generacja AI od Google. 7x lepsze rozumowanie, thinking mode, 78% na benchmarku kodowania.</p>
<span class="timeline-badge">Upgrade modelu</span>
</div>
</div>
<div class="timeline-item">
<div class="timeline-date">14.01.2026</div>
<div class="timeline-content">
<strong>Gemini 2.5 Flash-Lite</strong>
<p>8x dłuższe odpowiedzi, pełny thinking mode, 4x większy limit dzienny.</p>
<span class="timeline-badge">Poprzednia wersja</span>
</div>
</div>
<div class="timeline-item">
<div class="timeline-date">13.01.2026</div>
<div class="timeline-content">
<strong>Historia konwersacji</strong>
<p>Sidebar z historią rozmów - możliwość powrotu do wcześniejszych konwersacji.</p>
<span class="timeline-badge feature">Nowa funkcja</span>
</div>
</div>
<div class="timeline-item">
<div class="timeline-date">Grudzień 2025</div>
<div class="timeline-content">
<strong>Gemini 2.0 Flash</strong>
<p>Pierwszy model AI w NordaGPT. Kontekst 1M tokenów.</p>
<span class="timeline-badge">Poprzednia wersja</span>
</div>
</div>
<div class="timeline-item">
<div class="timeline-date">Listopad 2025</div>
<div class="timeline-content">
<strong>Uruchomienie NordaGPT</strong>
<p>Premiera asystenta AI dla członków Norda Biznes. Integracja z bazą {{ COMPANY_COUNT }} firm.</p>
<span class="timeline-badge launch">Premiera</span>
</div>
</div>
</div>
</div>
<div class="model-benefits">
<h3>✨ Co działa w NordaGPT?</h3>
<ul>
<li><strong>Wybór modelu</strong> — Flash (thinking mode, domyślny) lub Pro (premium, najlepsza analiza)</li>
<li><strong>Baza {{ COMPANY_COUNT }} firm</strong> — pełna wiedza o członkach Izby</li>
<li><strong>Forum i wydarzenia</strong> — dostęp do dyskusji i kalendarza</li>
<li><strong>Linki w odpowiedziach</strong> — bezpośrednie odnośniki do profili firm i osób</li>
<li><strong>Transparentne koszty</strong> — widoczny koszt każdej odpowiedzi i miesięczne zużycie</li>
<li><strong>Historia rozmów</strong> — pełna historia konwersacji w sidebarze</li>
</ul>
</div>
</div>
</div>
<!-- Modal z wideo pomocowym -->
<div class="video-help-modal" id="videoHelpModal">
<div class="video-help-content">
<button class="video-help-close" onclick="closeVideoHelpModal()">&times;</button>
<h2>🎬 Jak korzystać z NordaGPT?</h2>
<p class="video-help-subtitle">Krótki przewodnik (40 sekund)</p>
<div class="video-container">
<video id="helpVideo" controls poster="{{ url_for('static', filename='videos/nordagpt-demo-poster.jpg') }}">
<source src="{{ url_for('static', filename='videos/nordagpt-demo.mp4') }}" type="video/mp4">
Twoja przeglądarka nie obsługuje wideo HTML5.
</video>
</div>
<div class="video-help-tips">
<h3>💡 Szybkie wskazówki</h3>
<ul>
<li><strong>Znajdź firmę:</strong> "Kto oferuje usługi IT?"</li>
<li><strong>Sprawdź prezesa:</strong> "Kto jest prezesem PIXLAB?"</li>
<li><strong>Wydarzenia:</strong> "Kiedy następne spotkanie Norda?"</li>
<li><strong>Rekomendacje:</strong> "Poleć drukarnie z dobrymi opiniami"</li>
</ul>
</div>
</div>
</div>
<div class="chat-messages" id="chatMessages">
<!-- Empty state - pokazywany gdy brak wiadomości -->
<div class="empty-state" id="emptyState">
<img src="{{ url_for('static', filename='img/nordagpt-icon.svg') }}" alt="NordaGPT" style="width: 80px; height: 80px; margin-bottom: 1rem;">
<h2>NordaGPT - Asystent AI Norda Biznes</h2>
<p>Mogę pomóc Ci znaleźć firmy, usługi, sprawdzić kalendarz wydarzeń, rekomendacje i wiele więcej.</p>
<div class="suggestions">
<button class="suggestion-chip" onclick="sendSuggestion('Szukam partnera do projektu budowlanego')">
Szukam partnera do projektu
</button>
<button class="suggestion-chip" onclick="sendSuggestion('Kto w Izbie zajmuje się marketingiem?')">
Kto zajmuje się marketingiem?
</button>
<button class="suggestion-chip" onclick="sendSuggestion('Poleć firmę z dobrymi opiniami Google')">
Poleć firmę z opiniami
</button>
<button class="suggestion-chip" onclick="sendSuggestion('Co nowego na forum?')">
Co nowego na forum?
</button>
</div>
</div>
<!-- Typing indicator -->
<div class="message assistant" id="typingIndicator" style="display: none;">
<div class="message-avatar">AI</div>
<div class="typing-indicator active">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
</div>
<div class="chat-input-area">
<div class="chat-input-wrapper">
<textarea
id="messageInput"
class="chat-input"
placeholder="Wpisz swoją wiadomość..."
rows="1"
onkeypress="if(event.key==='Enter' && !event.shiftKey) { event.preventDefault(); sendMessage(); }"
></textarea>
<button class="send-btn" id="sendBtn" onclick="sendMessage()">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Wyślij
</button>
</div>
</div>
</main>
</div>
{% endblock %}
{% block extra_js %}
// NordaGPT Chat - State
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
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
// ============================================
// 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;
const costDisplay = document.getElementById('monthlyCostDisplay');
if (costDisplay) {
costDisplay.textContent = '$' + monthlyUsageCost.toFixed(2);
}
}
// 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 monthly cost 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 (Gemini 3 Flash, thinking mode) - 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 monthly cost
if (data.monthly_cost !== undefined) {
monthlyUsageCost = data.monthly_cost;
document.getElementById('monthlyCostDisplay').textContent = '$' + monthlyUsageCost.toFixed(2);
}
}
} 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 =
'<div class="sidebar-empty">Nie udało się załadować historii</div>';
}
}
// Render conversations in sidebar
function renderConversationsList() {
const list = document.getElementById('conversationsList');
if (conversations.length === 0) {
list.innerHTML = '<div class="sidebar-empty">Brak poprzednich rozmów.<br>Rozpocznij nową!</div>';
return;
}
const pinned = conversations.filter(c => c.is_pinned);
const unpinned = conversations.filter(c => !c.is_pinned);
let html = '';
if (pinned.length > 0) {
html += '<div class="conversations-section-title">Przypięte</div>';
html += pinned.map(conv => renderConversationItem(conv, true)).join('');
}
if (pinned.length > 0 && unpinned.length > 0) {
html += '<div class="conversations-section-title">Historia</div>';
}
html += unpinned.map(conv => renderConversationItem(conv, false)).join('');
list.innerHTML = html;
}
function renderConversationItem(conv, isPinned) {
return `
<div class="conversation-item ${conv.id === currentConversationId ? 'active' : ''} ${isPinned ? 'pinned' : ''}"
onclick="loadConversation(${conv.id})"
data-id="${conv.id}">
${isPinned ? '<svg class="conversation-pin-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>' : '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>'}
<span class="conversation-title">${escapeHtml(conv.title)}</span>
<div class="conversation-actions">
<button class="conversation-action-btn pin-btn" onclick="event.stopPropagation(); togglePin(${conv.id})" title="${isPinned ? 'Odepnij' : 'Przypnij'}">
<svg viewBox="0 0 24 24" width="14" height="14" fill="${isPinned ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="${isPinned ? '0' : '2'}">
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/>
</svg>
</button>
<button class="conversation-action-btn rename-btn" onclick="event.stopPropagation(); startRename(${conv.id})" title="Zmień nazwę">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
</svg>
</button>
<button class="conversation-action-btn delete-btn" onclick="event.stopPropagation(); deleteConversation(${conv.id})" title="Usuń">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="14" height="14">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
`;
}
// 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 new Date(b.updated_at) - new Date(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 = '<div class="message assistant"><div class="message-avatar">AI</div><div class="message-content">Ładowanie historii...</div></div>';
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 += `
<div class="message assistant" id="typingIndicator" style="display: none;">
<div class="message-avatar">AI</div>
<div class="typing-indicator active">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
`;
scrollToBottom();
}
} catch (error) {
console.error('Error loading conversation:', error);
messagesDiv.innerHTML = '<div class="message assistant"><div class="message-avatar">AI</div><div class="message-content">Nie udało się załadować rozmowy.</div></div>';
}
// 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 = `
<div class="empty-state" id="emptyState">
<img src="{{ url_for('static', filename='img/nordagpt-icon.svg') }}" alt="NordaGPT" style="width: 80px; height: 80px; margin-bottom: 1rem;">
<h2>NordaGPT - Asystent AI Norda Biznes</h2>
<p>Mogę pomóc Ci znaleźć firmy, usługi, sprawdzić kalendarz wydarzeń, rekomendacje i wiele więcej.</p>
<div class="suggestions">
<button class="suggestion-chip" onclick="sendSuggestion('Szukam partnera do projektu budowlanego')">Szukam partnera do projektu</button>
<button class="suggestion-chip" onclick="sendSuggestion('Kto w Izbie zajmuje się marketingiem?')">Kto zajmuje się marketingiem?</button>
<button class="suggestion-chip" onclick="sendSuggestion('Poleć firmę z dobrymi opiniami Google')">Poleć firmę z opiniami</button>
<button class="suggestion-chip" onclick="sendSuggestion('Co nowego na forum?')">Co nowego na forum?</button>
</div>
</div>
<div class="message assistant" id="typingIndicator" style="display: none;">
<div class="message-avatar">AI</div>
<div class="typing-indicator active">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
`;
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);
// Show typing indicator
document.getElementById('typingIndicator').style.display = 'flex';
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: message.substring(0, 50) + (message.length > 50 ? '...' : '')
})
});
const startData = await startResponse.json();
if (startData.success) {
currentConversationId = startData.conversation_id;
} else {
throw new Error(startData.error || 'Failed to start conversation');
}
}
// Send message with model selection
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();
// Hide typing indicator
document.getElementById('typingIndicator').style.display = 'none';
if (data.success) {
addMessage('assistant', data.message, true, data.tech_info);
// Reload conversations to update list
loadConversations();
} 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.textContent = role === 'assistant' ? 'AI' : '{{ current_user.name[:1].upper() if current_user else "U" }}';
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'
};
const modelLabel = modelLabels[modelName] || modelName;
// Format cost
const costStr = costUsd > 0 ? `$${costUsd.toFixed(4)}` : '$0.00';
// Build badge content
let badgeHTML = `<span class="thinking-badge-level">${modelLabel}</span> · <span class="thinking-badge-time">${latencySec}s</span> · <span class="thinking-badge-cost">${costStr}</span>`;
// Add "Try Pro" hint when using Flash model
if (currentModel === 'flash' && (modelName === 'flash' || modelName === 'gemini-3-flash-preview')) {
badgeHTML += ` · <a href="#" class="pro-upgrade-hint" onclick="event.preventDefault(); setModel('pro');" title="Przełącz na Gemini 3 Pro dla lepszych odpowiedzi">Lepsze odpowiedzi? <strong>Spróbuj Pro</strong> 🧠</a>`;
}
infoBadge.innerHTML = badgeHTML;
contentDiv.appendChild(infoBadge);
// Update monthly cost if cost provided
if (costUsd > 0) {
updateMonthlyCost(costUsd);
}
}
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 <a> 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 '<a href="' + url + '" class="' + linkClass + '">' + linkText + '</a>';
});
// 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 '<a href="' + url + '" target="_blank" rel="noopener" class="' + linkClass + '">' + linkText + '</a>';
});
// Convert raw URLs to links (only those not already in <a> tags)
text = text.replace(/(?<!href="|">)(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 '<a href="' + url + '" target="_blank" rel="noopener" class="' + linkClass + '">' + url + '</a>';
});
// Convert **bold** to <strong>
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Convert newlines to <br>
text = text.replace(/\n/g, '<br>');
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
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"/>'
: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/>';
// 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 = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7"/>';
}
// 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 %}