feat(chat): Wybór modelu Flash/Pro zamiast Thinking Mode + koszt miesięczny

UI:
- Dropdown wyboru modelu: Flash (szybki, $0.05) vs Pro (analiza, $0.20)
- Wyświetlanie kosztu miesięcznego w headerze
- Badge odpowiedzi pokazuje: model, czas, koszt

Backend:
- Endpoint /api/chat/settings obsługuje model i monthly_cost
- NordaBizChatEngine przyjmuje parametr model
- Koszt zapisywany w tech_info odpowiedzi

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-29 11:04:29 +01:00
parent fa696b331f
commit 26db9a7cc9
3 changed files with 207 additions and 118 deletions

80
app.py
View File

@ -4517,33 +4517,51 @@ def chat():
@csrf.exempt @csrf.exempt
@login_required @login_required
def chat_settings(): def chat_settings():
"""Get or update chat settings (thinking level)""" """Get or update chat settings (model selection, monthly cost)"""
if request.method == 'GET': if request.method == 'GET':
# Get current thinking level from session or default # Get current model from session or default to flash
thinking_level = session.get('thinking_level', 'high') model = session.get('chat_model', 'flash')
# Calculate monthly cost for current user
monthly_cost = 0.0
try:
from database import AIApiCost
db = SessionLocal()
# Get first day of current month
first_day = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
costs = db.query(AIApiCost).filter(
AIApiCost.user_id == current_user.id,
AIApiCost.timestamp >= first_day
).all()
monthly_cost = sum(c.total_cost_usd or 0 for c in costs)
db.close()
except Exception as e:
logger.warning(f"Error calculating monthly cost: {e}")
return jsonify({ return jsonify({
'success': True, 'success': True,
'thinking_level': thinking_level 'model': model,
'monthly_cost': round(monthly_cost, 4)
}) })
# POST - update settings # POST - update settings
try: try:
data = request.get_json() data = request.get_json()
thinking_level = data.get('thinking_level', 'high') model = data.get('model', 'flash')
# Validate thinking level # Validate model
valid_levels = ['minimal', 'low', 'medium', 'high'] valid_models = ['flash', 'pro']
if thinking_level not in valid_levels: if model not in valid_models:
thinking_level = 'high' model = 'flash'
# Store in session # Store in session
session['thinking_level'] = thinking_level session['chat_model'] = model
logger.info(f"User {current_user.id} set thinking_level to: {thinking_level}") logger.info(f"User {current_user.id} set chat_model to: {model}")
return jsonify({ return jsonify({
'success': True, 'success': True,
'thinking_level': thinking_level 'model': model
}) })
except Exception as e: except Exception as e:
@ -4602,24 +4620,28 @@ def chat_send_message(conversation_id):
finally: finally:
db.close() db.close()
# Get thinking level from request or session # Get model from request or session (flash = default, pro = premium)
thinking_level = data.get('thinking_level') or session.get('thinking_level', 'high') model_choice = data.get('model') or session.get('chat_model', 'flash')
chat_engine = NordaBizChatEngine() # Map model choice to actual model name
model_map = {
'flash': '3-flash',
'pro': '3-pro'
}
model_key = model_map.get(model_choice, '3-flash')
chat_engine = NordaBizChatEngine(model=model_key)
response = chat_engine.send_message( response = chat_engine.send_message(
conversation_id=conversation_id, conversation_id=conversation_id,
user_message=message, user_message=message,
user_id=current_user.id, user_id=current_user.id,
thinking_level=thinking_level thinking_level='minimal' if model_choice == 'flash' else 'high'
) )
# Get free tier usage stats for today # Get actual cost from response
free_tier_stats = get_free_tier_usage()
# Calculate theoretical cost (Gemini 2.0 Flash pricing)
tokens_in = response.tokens_input or 0 tokens_in = response.tokens_input or 0
tokens_out = response.tokens_output or 0 tokens_out = response.tokens_output or 0
theoretical_cost = (tokens_in / 1_000_000) * 0.075 + (tokens_out / 1_000_000) * 0.30 actual_cost = response.cost_usd or 0.0
return jsonify({ return jsonify({
'success': True, 'success': True,
@ -4628,24 +4650,12 @@ def chat_send_message(conversation_id):
'created_at': response.created_at.isoformat(), 'created_at': response.created_at.isoformat(),
# Technical metadata # Technical metadata
'tech_info': { 'tech_info': {
'model': gemini_service.get_gemini_service().model_name if gemini_service.get_gemini_service() else 'gemini-3-flash-preview', 'model': model_choice,
'thinking_level': thinking_level,
'thinking_enabled': gemini_service.get_gemini_service().thinking_enabled if gemini_service.get_gemini_service() else True,
'data_source': 'PostgreSQL (150 firm Norda Biznes)',
'architecture': 'Full DB Context + Thinking Mode',
'tokens_input': tokens_in, 'tokens_input': tokens_in,
'tokens_output': tokens_out, 'tokens_output': tokens_out,
'tokens_total': tokens_in + tokens_out, 'tokens_total': tokens_in + tokens_out,
'latency_ms': response.latency_ms or 0, 'latency_ms': response.latency_ms or 0,
'theoretical_cost_usd': round(theoretical_cost, 6), 'cost_usd': round(actual_cost, 6)
'actual_cost_usd': 0.0, # Paid tier but tracked
'free_tier': {
'is_free': False,
'daily_limit': 10000, # Gemini paid tier
'requests_today': free_tier_stats['requests_today'],
'tokens_today': free_tier_stats['tokens_today'],
'remaining': max(0, 10000 - free_tier_stats['requests_today'])
}
} }
}) })

View File

@ -86,21 +86,29 @@ class NordaBizChatEngine:
Helps users find companies, services, and business partners. Helps users find companies, services, and business partners.
""" """
def __init__(self, gemini_api_key: Optional[str] = None, use_global_service: bool = True): def __init__(self, gemini_api_key: Optional[str] = None, use_global_service: bool = True, model: str = None):
""" """
Initialize Norda Biznes Chat Engine Initialize Norda Biznes Chat Engine
Args: Args:
gemini_api_key: Google Gemini API key (uses env var if not provided) gemini_api_key: Google Gemini API key (uses env var if not provided)
use_global_service: Use global gemini_service for automatic cost tracking (default: True) use_global_service: Use global gemini_service for automatic cost tracking (default: True)
model: Model key ('3-flash', '3-pro') - if provided, creates new service with this model
""" """
self.use_global_service = use_global_service self.use_global_service = use_global_service
self.requested_model = model
if use_global_service: if use_global_service:
# Use global gemini_service for automatic cost tracking to ai_api_costs table if model:
self.gemini_service = gemini_service.get_gemini_service() # Create new service with requested model
# Get model name from global service (currently Gemini 3 Flash Preview) from gemini_service import GeminiService
self.model_name = self.gemini_service.model_name if self.gemini_service else "gemini-3-flash-preview" self.gemini_service = GeminiService(model=model)
self.model_name = self.gemini_service.model_name
else:
# Use global gemini_service for automatic cost tracking to ai_api_costs table
self.gemini_service = gemini_service.get_gemini_service()
# Get model name from global service (currently Gemini 3 Flash Preview)
self.model_name = self.gemini_service.model_name if self.gemini_service else "gemini-3-flash-preview"
self.model = None self.model = None
# Initialize tokenizer for cost calculation (still needed for per-message tracking) # Initialize tokenizer for cost calculation (still needed for per-message tracking)

View File

@ -311,6 +311,11 @@
font-style: italic; font-style: italic;
} }
.thinking-badge-cost {
color: #f59e0b;
font-weight: 500;
}
/* Klikalne linki jako kolorowe badge'y */ /* Klikalne linki jako kolorowe badge'y */
.message-content a { .message-content a {
display: inline-block; display: inline-block;
@ -724,6 +729,49 @@
font-weight: 500; font-weight: 500;
} }
.thinking-option-badge.premium {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.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 { .thinking-option-desc {
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
@ -1181,45 +1229,46 @@
</button> </button>
<span style="font-size: 1.5rem;">🤖</span> <span style="font-size: 1.5rem;">🤖</span>
<h1>NordaGPT</h1> <h1>NordaGPT</h1>
<span class="chat-header-badge">Gemini 3</span> <!-- Model Selection Toggle -->
<!-- Thinking Mode Toggle --> <div class="thinking-toggle" id="modelToggle">
<div class="thinking-toggle" id="thinkingToggle"> <button class="thinking-btn" onclick="toggleModelDropdown()" title="Wybierz model AI">
<button class="thinking-btn" onclick="toggleThinkingDropdown()" title="Tryb rozumowania AI"> <span class="thinking-icon" id="modelIcon"></span>
<span class="thinking-icon">🧠</span> <span class="thinking-label" id="modelLabel">Flash</span>
<span class="thinking-label" id="thinkingLabel">Wysoki</span>
<svg class="thinking-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24" width="12" height="12"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</button> </button>
<div class="thinking-dropdown" id="thinkingDropdown"> <div class="thinking-dropdown" id="modelDropdown">
<div class="thinking-dropdown-header"> <div class="thinking-dropdown-header">
<strong>Tryb rozumowania AI</strong> <strong>Model AI</strong>
<p>Określa głębokość analizy przed odpowiedzią</p> <p>Wybierz model dopasowany do pytania</p>
</div> </div>
<div class="thinking-option" data-level="minimal" onclick="setThinkingLevel('minimal')"> <div class="thinking-option active" data-model="flash" onclick="setModel('flash')">
<div class="thinking-option-header"> <div class="thinking-option-header">
<span class="thinking-option-icon"></span> <span class="thinking-option-icon"></span>
<span class="thinking-option-name">Błyskawiczny</span> <span class="thinking-option-name">Gemini Flash</span>
</div>
<p class="thinking-option-desc">Najszybsze odpowiedzi. Dla prostych pytań typu "kto?", "gdzie?".</p>
</div>
<div class="thinking-option" data-level="low" onclick="setThinkingLevel('low')">
<div class="thinking-option-header">
<span class="thinking-option-icon">🚀</span>
<span class="thinking-option-name">Szybki</span>
</div>
<p class="thinking-option-desc">Zrównoważony. Dobre dla większości pytań o firmy i usługi.</p>
</div>
<div class="thinking-option active" data-level="high" onclick="setThinkingLevel('high')">
<div class="thinking-option-header">
<span class="thinking-option-icon">🧠</span>
<span class="thinking-option-name">Głęboki</span>
<span class="thinking-option-badge">Domyślny</span> <span class="thinking-option-badge">Domyślny</span>
</div> </div>
<p class="thinking-option-desc">Maksymalna analiza. Dla złożonych pytań, rekomendacji, strategii.</p> <p class="thinking-option-desc">Szybki i ekonomiczny. Dla prostych pytań o firmy, kontakty, wydarzenia.</p>
<span class="thinking-option-price">~$0.05/pytanie</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">Gemini Pro</span>
<span class="thinking-option-badge premium">Premium</span>
</div>
<p class="thinking-option-desc">Głęboka analiza. Dla złożonych pytań, strategii, rekomendacji.</p>
<span class="thinking-option-price">~$0.20/pytanie</span>
</div> </div>
</div> </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"> <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"> <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"/> <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"/>
@ -1431,57 +1480,67 @@
// NordaGPT Chat - State // NordaGPT Chat - State
let currentConversationId = null; let currentConversationId = null;
let conversations = []; let conversations = [];
let currentThinkingLevel = 'high'; // Default thinking level let currentModel = 'flash'; // Default model (flash = ekonomiczny)
let monthlyUsageCost = 0; // Koszt miesięczny użytkownika
// ============================================ // ============================================
// Thinking Mode Toggle Functions // Model Selection Toggle Functions
// ============================================ // ============================================
const THINKING_LABELS = { const MODEL_CONFIG = {
'minimal': 'Błyskawiczny', 'flash': { label: 'Flash', icon: '⚡', desc: 'Szybki' },
'low': 'Szybki', 'pro': { label: 'Pro', icon: '🧠', desc: 'Analiza' }
'high': 'Głęboki'
}; };
function toggleThinkingDropdown() { function toggleModelDropdown() {
const toggle = document.getElementById('thinkingToggle'); const toggle = document.getElementById('modelToggle');
toggle.classList.toggle('open'); toggle.classList.toggle('open');
} }
function setThinkingLevel(level) { function setModel(model) {
currentThinkingLevel = level; currentModel = model;
const config = MODEL_CONFIG[model];
// Update UI // Update UI
document.getElementById('thinkingLabel').textContent = THINKING_LABELS[level] || level; document.getElementById('modelLabel').textContent = config.label;
document.getElementById('modelIcon').textContent = config.icon;
// Update active state // Update active state
document.querySelectorAll('.thinking-option').forEach(opt => { document.querySelectorAll('.thinking-option').forEach(opt => {
opt.classList.toggle('active', opt.dataset.level === level); opt.classList.toggle('active', opt.dataset.model === model);
}); });
// Close dropdown // Close dropdown
document.getElementById('thinkingToggle').classList.remove('open'); document.getElementById('modelToggle').classList.remove('open');
// Save preference to server // Save preference to server
saveThinkingPreference(level); saveModelPreference(model);
console.log('Thinking level set to:', level); console.log('Model set to:', model);
} }
async function saveThinkingPreference(level) { async function saveModelPreference(model) {
try { try {
await fetch('/api/chat/settings', { await fetch('/api/chat/settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thinking_level: level }) body: JSON.stringify({ model: model })
}); });
} catch (error) { } catch (error) {
console.error('Failed to save thinking preference:', error); console.error('Failed to save model preference:', error);
} }
} }
// Close thinking dropdown when clicking outside 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) { document.addEventListener('click', function(e) {
const toggle = document.getElementById('thinkingToggle'); const toggle = document.getElementById('modelToggle');
if (toggle && !toggle.contains(e.target)) { if (toggle && !toggle.contains(e.target)) {
toggle.classList.remove('open'); toggle.classList.remove('open');
} }
@ -1552,23 +1611,33 @@ document.addEventListener('click', function(e) {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadConversations(); loadConversations();
autoResizeTextarea(); autoResizeTextarea();
loadThinkingSettings(); loadModelSettings();
}); });
// Load thinking settings from server // Load model settings and monthly cost from server
async function loadThinkingSettings() { async function loadModelSettings() {
try { try {
const response = await fetch('/api/chat/settings'); const response = await fetch('/api/chat/settings');
const data = await response.json(); const data = await response.json();
if (data.success && data.thinking_level) { if (data.success) {
currentThinkingLevel = data.thinking_level; // Load model preference
document.getElementById('thinkingLabel').textContent = THINKING_LABELS[data.thinking_level] || data.thinking_level; if (data.model) {
document.querySelectorAll('.thinking-option').forEach(opt => { currentModel = data.model;
opt.classList.toggle('active', opt.dataset.level === data.thinking_level); const config = MODEL_CONFIG[data.model] || 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 === data.model);
});
}
// Load monthly cost
if (data.monthly_cost !== undefined) {
monthlyUsageCost = data.monthly_cost;
document.getElementById('monthlyCostDisplay').textContent = '$' + monthlyUsageCost.toFixed(2);
}
} }
} catch (error) { } catch (error) {
console.log('Using default thinking level:', currentThinkingLevel); console.log('Using default model:', currentModel);
} }
} }
@ -1769,13 +1838,13 @@ async function sendMessage() {
} }
} }
// Send message with thinking level // Send message with model selection
const response = await fetch(`/api/chat/${currentConversationId}/message`, { const response = await fetch(`/api/chat/${currentConversationId}/message`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
message: message, message: message,
thinking_level: currentThinkingLevel model: currentModel
}) })
}); });
const data = await response.json(); const data = await response.json();
@ -1826,33 +1895,35 @@ function addMessage(role, content, animate = true, techInfo = null) {
contentDiv.className = 'message-content'; contentDiv.className = 'message-content';
contentDiv.innerHTML = formatMessage(content); contentDiv.innerHTML = formatMessage(content);
// Add thinking info badge for AI responses // Add response info badge for AI responses (model, time, cost)
if (role === 'assistant' && techInfo) { if (role === 'assistant' && techInfo) {
const thinkingBadge = document.createElement('div'); const infoBadge = document.createElement('div');
thinkingBadge.className = 'thinking-info-badge'; infoBadge.className = 'thinking-info-badge';
const thinkingLevel = techInfo.thinking_level || 'high'; const modelName = techInfo.model || 'flash';
const latencyMs = techInfo.latency_ms || 0; const latencyMs = techInfo.latency_ms || 0;
const latencySec = (latencyMs / 1000).toFixed(1); const latencySec = (latencyMs / 1000).toFixed(1);
const costUsd = techInfo.cost_usd || 0;
// Labels with quality descriptions to show value of deeper thinking // Model labels
const levelLabels = { const modelLabels = {
'minimal': '⚡ Błyskawiczny', 'flash': '⚡ Flash',
'low': '🚀 Szybki', 'pro': '🧠 Pro',
'medium': '⚖️ Zbalansowany', 'gemini-3-flash-preview': '⚡ Flash',
'high': '🧠 Głęboka analiza' 'gemini-3-pro-preview': '🧠 Pro'
}; };
const levelDescriptions = { const modelLabel = modelLabels[modelName] || modelName;
'minimal': 'szybka odpowiedź',
'low': 'zwięzła analiza',
'medium': 'przemyślana odpowiedź',
'high': 'dogłębna analiza z weryfikacją'
};
const levelLabel = levelLabels[thinkingLevel] || thinkingLevel;
const levelDesc = levelDescriptions[thinkingLevel] || '';
thinkingBadge.innerHTML = `<span class="thinking-badge-level">${levelLabel}</span> · <span class="thinking-badge-desc">${levelDesc}</span> · <span class="thinking-badge-time">${latencySec}s</span>`; // Format cost
contentDiv.appendChild(thinkingBadge); const costStr = costUsd > 0 ? `$${costUsd.toFixed(4)}` : '$0.00';
infoBadge.innerHTML = `<span class="thinking-badge-level">${modelLabel}</span> · <span class="thinking-badge-time">${latencySec}s</span> · <span class="thinking-badge-cost">${costStr}</span>`;
contentDiv.appendChild(infoBadge);
// Update monthly cost if cost provided
if (costUsd > 0) {
updateMonthlyCost(costUsd);
}
} }
messageDiv.appendChild(avatar); messageDiv.appendChild(avatar);