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
@login_required
def chat_settings():
"""Get or update chat settings (thinking level)"""
"""Get or update chat settings (model selection, monthly cost)"""
if request.method == 'GET':
# Get current thinking level from session or default
thinking_level = session.get('thinking_level', 'high')
# Get current model from session or default to flash
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({
'success': True,
'thinking_level': thinking_level
'model': model,
'monthly_cost': round(monthly_cost, 4)
})
# POST - update settings
try:
data = request.get_json()
thinking_level = data.get('thinking_level', 'high')
model = data.get('model', 'flash')
# Validate thinking level
valid_levels = ['minimal', 'low', 'medium', 'high']
if thinking_level not in valid_levels:
thinking_level = 'high'
# Validate model
valid_models = ['flash', 'pro']
if model not in valid_models:
model = 'flash'
# 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({
'success': True,
'thinking_level': thinking_level
'model': model
})
except Exception as e:
@ -4602,24 +4620,28 @@ def chat_send_message(conversation_id):
finally:
db.close()
# Get thinking level from request or session
thinking_level = data.get('thinking_level') or session.get('thinking_level', 'high')
# Get model from request or session (flash = default, pro = premium)
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(
conversation_id=conversation_id,
user_message=message,
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
free_tier_stats = get_free_tier_usage()
# Calculate theoretical cost (Gemini 2.0 Flash pricing)
# Get actual cost from response
tokens_in = response.tokens_input 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({
'success': True,
@ -4628,24 +4650,12 @@ def chat_send_message(conversation_id):
'created_at': response.created_at.isoformat(),
# Technical metadata
'tech_info': {
'model': gemini_service.get_gemini_service().model_name if gemini_service.get_gemini_service() else 'gemini-3-flash-preview',
'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',
'model': model_choice,
'tokens_input': tokens_in,
'tokens_output': tokens_out,
'tokens_total': tokens_in + tokens_out,
'latency_ms': response.latency_ms or 0,
'theoretical_cost_usd': round(theoretical_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'])
}
'cost_usd': round(actual_cost, 6)
}
})

View File

@ -86,17 +86,25 @@ class NordaBizChatEngine:
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
Args:
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)
model: Model key ('3-flash', '3-pro') - if provided, creates new service with this model
"""
self.use_global_service = use_global_service
self.requested_model = model
if use_global_service:
if model:
# Create new service with requested model
from gemini_service import GeminiService
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)

View File

@ -311,6 +311,11 @@
font-style: italic;
}
.thinking-badge-cost {
color: #f59e0b;
font-weight: 500;
}
/* Klikalne linki jako kolorowe badge'y */
.message-content a {
display: inline-block;
@ -724,6 +729,49 @@
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 {
color: var(--text-secondary);
font-size: var(--font-size-xs);
@ -1181,45 +1229,46 @@
</button>
<span style="font-size: 1.5rem;">🤖</span>
<h1>NordaGPT</h1>
<span class="chat-header-badge">Gemini 3</span>
<!-- Thinking Mode Toggle -->
<div class="thinking-toggle" id="thinkingToggle">
<button class="thinking-btn" onclick="toggleThinkingDropdown()" title="Tryb rozumowania AI">
<span class="thinking-icon">🧠</span>
<span class="thinking-label" id="thinkingLabel">Wysoki</span>
<!-- 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="thinkingDropdown">
<div class="thinking-dropdown" id="modelDropdown">
<div class="thinking-dropdown-header">
<strong>Tryb rozumowania AI</strong>
<p>Określa głębokość analizy przed odpowiedzią</p>
<strong>Model AI</strong>
<p>Wybierz model dopasowany do pytania</p>
</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">
<span class="thinking-option-icon"></span>
<span class="thinking-option-name">Błyskawiczny</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-name">Gemini Flash</span>
<span class="thinking-option-badge">Domyślny</span>
</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>
<!-- 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"/>
@ -1431,57 +1480,67 @@
// NordaGPT Chat - State
let currentConversationId = null;
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 = {
'minimal': 'Błyskawiczny',
'low': 'Szybki',
'high': 'Głęboki'
const MODEL_CONFIG = {
'flash': { label: 'Flash', icon: '⚡', desc: 'Szybki' },
'pro': { label: 'Pro', icon: '🧠', desc: 'Analiza' }
};
function toggleThinkingDropdown() {
const toggle = document.getElementById('thinkingToggle');
function toggleModelDropdown() {
const toggle = document.getElementById('modelToggle');
toggle.classList.toggle('open');
}
function setThinkingLevel(level) {
currentThinkingLevel = level;
function setModel(model) {
currentModel = model;
const config = MODEL_CONFIG[model];
// 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
document.querySelectorAll('.thinking-option').forEach(opt => {
opt.classList.toggle('active', opt.dataset.level === level);
opt.classList.toggle('active', opt.dataset.model === model);
});
// Close dropdown
document.getElementById('thinkingToggle').classList.remove('open');
document.getElementById('modelToggle').classList.remove('open');
// 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 {
await fetch('/api/chat/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ thinking_level: level })
body: JSON.stringify({ model: model })
});
} 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) {
const toggle = document.getElementById('thinkingToggle');
const toggle = document.getElementById('modelToggle');
if (toggle && !toggle.contains(e.target)) {
toggle.classList.remove('open');
}
@ -1552,23 +1611,33 @@ document.addEventListener('click', function(e) {
document.addEventListener('DOMContentLoaded', function() {
loadConversations();
autoResizeTextarea();
loadThinkingSettings();
loadModelSettings();
});
// Load thinking settings from server
async function loadThinkingSettings() {
// 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 && data.thinking_level) {
currentThinkingLevel = data.thinking_level;
document.getElementById('thinkingLabel').textContent = THINKING_LABELS[data.thinking_level] || data.thinking_level;
if (data.success) {
// Load model preference
if (data.model) {
currentModel = data.model;
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.level === data.thinking_level);
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) {
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: message,
thinking_level: currentThinkingLevel
model: currentModel
})
});
const data = await response.json();
@ -1826,33 +1895,35 @@ function addMessage(role, content, animate = true, techInfo = null) {
contentDiv.className = 'message-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) {
const thinkingBadge = document.createElement('div');
thinkingBadge.className = 'thinking-info-badge';
const infoBadge = document.createElement('div');
infoBadge.className = 'thinking-info-badge';
const thinkingLevel = techInfo.thinking_level || 'high';
const modelName = techInfo.model || 'flash';
const latencyMs = techInfo.latency_ms || 0;
const latencySec = (latencyMs / 1000).toFixed(1);
const costUsd = techInfo.cost_usd || 0;
// Labels with quality descriptions to show value of deeper thinking
const levelLabels = {
'minimal': '⚡ Błyskawiczny',
'low': '🚀 Szybki',
'medium': '⚖️ Zbalansowany',
'high': '🧠 Głęboka analiza'
// Model labels
const modelLabels = {
'flash': '⚡ Flash',
'pro': '🧠 Pro',
'gemini-3-flash-preview': '⚡ Flash',
'gemini-3-pro-preview': '🧠 Pro'
};
const levelDescriptions = {
'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] || '';
const modelLabel = modelLabels[modelName] || modelName;
thinkingBadge.innerHTML = `<span class="thinking-badge-level">${levelLabel}</span> · <span class="thinking-badge-desc">${levelDesc}</span> · <span class="thinking-badge-time">${latencySec}s</span>`;
contentDiv.appendChild(thinkingBadge);
// Format cost
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);