feat: Gemini token usage counter (#82) + smarter qty suggestions 90-day EWMA (#70)

Backend (api/index.php):
- callGemini() now extracts usageMetadata (tokens_in/tokens_out) from response
- _recordAiUsage() persists monthly token data to data/ai_usage.json
- callGeminiWithFallback() accepts $usageAction param; all 15 call sites labeled
- gemini_usage endpoint: returns token stats, cost estimate, log info, DB size
- smartShopping(): rolling 90-day EWMA (70% last-30d / 30% days-31-90)
  with fallback to all-time rate when <14 days of history

Frontend (index.html + app.js):
- New Info tab (ℹ️) in Settings with Gemini usage and System cards
- _loadInfoTab() / _renderInfoTab(): loads on click, auto-refreshes every 30s
- switchSettingsTab() stops auto-refresh when leaving Info tab

Translations (it/en/de): settings.info.* keys
This commit is contained in:
dadaloop82
2026-05-18 06:23:42 +00:00
parent dc3cefefd0
commit 9f554c6e22
6 changed files with 339 additions and 35 deletions
+151 -24
View File
@@ -18,6 +18,14 @@ define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', __DIR__ . '/../data/category_ai_cache.json');
define('AI_USAGE_PATH', __DIR__ . '/../data/ai_usage.json');
// Gemini pricing (USD per 1M tokens) — overridable via .env
// gemini-2.5-flash: $0.15 input / $0.60 output
// gemini-2.0-flash: $0.10 input / $0.40 output
define('GEMINI_COST_25F_IN', 0.15);
define('GEMINI_COST_25F_OUT', 0.60);
define('GEMINI_COST_20F_IN', 0.10);
define('GEMINI_COST_20F_OUT', 0.40);
/** Decode the XOR-obfuscated GitHub token at runtime. */
function _ghToken(): string {
@@ -135,6 +143,56 @@ if (($_GET['action'] ?? '') === 'get_logs') {
exit;
}
// ── Gemini token usage + cost estimate ────────────────────────────────────────
if (($_GET['action'] ?? '') === 'gemini_usage') {
header('Content-Type: application/json; charset=utf-8');
$data = file_exists(AI_USAGE_PATH) ? (json_decode(file_get_contents(AI_USAGE_PATH), true) ?: []) : [];
$month = date('Y-m');
$cur = $data[$month] ?? ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_action' => [], 'by_model' => []];
// Per-model cost calculation
$totalCost = 0.0;
foreach (($cur['by_model'] ?? []) as $mdl => $mu) {
$inRate = str_contains($mdl, '2.5') ? (float)(env('GEMINI_COST_INPUT_PER_1M') ?: GEMINI_COST_25F_IN) : (float)(env('GEMINI_COST_INPUT_PER_1M') ?: GEMINI_COST_20F_IN);
$outRate = str_contains($mdl, '2.5') ? (float)(env('GEMINI_COST_OUTPUT_PER_1M') ?: GEMINI_COST_25F_OUT) : (float)(env('GEMINI_COST_OUTPUT_PER_1M') ?: GEMINI_COST_20F_OUT);
$totalCost += ($mu['in'] / 1_000_000) * $inRate + ($mu['out'] / 1_000_000) * $outRate;
}
// Fallback if by_model not populated (old data)
if ($totalCost === 0.0 && ($cur['input_tokens'] > 0 || $cur['output_tokens'] > 0)) {
$inRate = (float)(env('GEMINI_COST_INPUT_PER_1M') ?: GEMINI_COST_25F_IN);
$outRate = (float)(env('GEMINI_COST_OUTPUT_PER_1M') ?: GEMINI_COST_25F_OUT);
$totalCost = ($cur['input_tokens'] / 1_000_000) * $inRate + ($cur['output_tokens'] / 1_000_000) * $outRate;
}
// Log sizes — EverLog::listFiles() returns [{file, size_kb, mtime}, ...]
$logFilesInfo = EverLog::listFiles();
$logBytes = 0;
foreach ($logFilesInfo as $lf) {
$logBytes += (int)(($lf['size_kb'] ?? 0) * 1024);
}
echo json_encode([
'month' => $month,
'input_tokens' => (int)$cur['input_tokens'],
'output_tokens' => (int)$cur['output_tokens'],
'calls' => (int)$cur['calls'],
'by_action' => $cur['by_action'] ?? [],
'by_model' => $cur['by_model'] ?? [],
'cost_usd' => round($totalCost, 6),
'log_bytes' => $logBytes,
'log_level' => EverLog::levelName(),
'log_files' => count($logFilesInfo),
'db_bytes' => file_exists(DB_PATH) ? filesize(DB_PATH) : 0,
'history' => array_map(fn($k, $v) => [
'month' => $k,
'input_tokens' => (int)($v['input_tokens'] ?? 0),
'output_tokens' => (int)($v['output_tokens'] ?? 0),
'calls' => (int)($v['calls'] ?? 0),
], array_keys($data), array_values($data)),
], JSON_UNESCAPED_UNICODE);
exit;
}
// ── Health check — startup diagnostic (no rate-limit, no auth required) ──────
if (($_GET['action'] ?? '') === 'health_check') {
$checks = [];
@@ -2904,27 +2962,72 @@ function callGemini(string $url, array $payload, int $timeout = 60): array {
EverLog::aiResponse('gemini', strlen($lastBody), $elapsed, false, "HTTP {$lastCode}: " . substr($lastBody, 0, 300));
}
$data = $lastBody ? json_decode($lastBody, true) : null;
// Extract token counts from Gemini usageMetadata
$usage = $data['usageMetadata'] ?? [];
$tokIn = (int)($usage['promptTokenCount'] ?? 0);
$tokOut = (int)($usage['candidatesTokenCount'] ?? 0);
return [
'http_code' => $lastCode,
'body' => $lastBody,
'data' => $lastBody ? json_decode($lastBody, true) : null,
'data' => $data,
'tokens_in' => $tokIn,
'tokens_out' => $tokOut,
];
}
/**
* Record Gemini token usage to the monthly ai_usage.json file.
* Called by callGeminiWithFallback after each successful call.
*/
function _recordAiUsage(string $model, int $tokIn, int $tokOut, string $action = ''): void {
if ($tokIn === 0 && $tokOut === 0) return;
$month = date('Y-m');
$data = [];
if (file_exists(AI_USAGE_PATH)) {
$data = json_decode(file_get_contents(AI_USAGE_PATH), true) ?: [];
}
if (!isset($data[$month])) {
$data[$month] = ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_action' => [], 'by_model' => []];
}
$m = &$data[$month];
$m['input_tokens'] += $tokIn;
$m['output_tokens'] += $tokOut;
$m['calls']++;
if ($action) {
$m['by_action'][$action] = ($m['by_action'][$action] ?? 0) + 1;
}
if ($model) {
if (!isset($m['by_model'][$model])) $m['by_model'][$model] = ['in' => 0, 'out' => 0, 'calls' => 0];
$m['by_model'][$model]['in'] += $tokIn;
$m['by_model'][$model]['out'] += $tokOut;
$m['by_model'][$model]['calls'] += 1;
}
// Keep only last 13 months
krsort($data);
$data = array_slice($data, 0, 13, true);
@file_put_contents(AI_USAGE_PATH, json_encode($data, JSON_PRETTY_PRINT));
EverLog::debug('ai_usage recorded', ['model' => $model, 'in' => $tokIn, 'out' => $tokOut, 'action' => $action]);
}
/**
* Like callGemini() but tries gemini-2.5-flash first, falls back to gemini-2.0-flash
* on quota/rate-limit errors (429/503). Builds the URL from model name + API key.
*/
function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 30): array {
function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 30, string $usageAction = ''): array {
$models = ['gemini-2.5-flash', 'gemini-2.0-flash'];
$last = ['http_code' => 0, 'body' => '', 'data' => null];
$last = ['http_code' => 0, 'body' => '', 'data' => null, 'tokens_in' => 0, 'tokens_out' => 0];
$promptLen = strlen(json_encode($payload));
foreach ($models as $idx => $model) {
$isFallback = $idx > 0;
EverLog::aiCall($model, $promptLen, $isFallback);
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$last = callGemini($url, $payload, $timeout);
if ($last['http_code'] === 200) return $last;
if ($last['http_code'] === 200) {
_recordAiUsage($model, $last['tokens_in'], $last['tokens_out'], $usageAction);
return $last;
}
if ($last['http_code'] !== 429 && $last['http_code'] !== 503) return $last; // non-retryable
EverLog::warn('AI model exhausted, trying fallback', ['model' => $model, 'code' => $last['http_code']]);
}
@@ -3014,7 +3117,7 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location
'contents' => [['parts' => [['text' => $prompt]]]],
'generationConfig' => ['maxOutputTokens' => 8, 'temperature' => 0],
];
$result = callGeminiWithFallback($apiKey, $payload, 12);
$result = callGeminiWithFallback($apiKey, $payload, 12, 'shelf_life');
if ($result['http_code'] === 200) {
$text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '');
$parsed = (int)preg_replace('/\D/', '', $text);
@@ -3280,7 +3383,7 @@ function geminiReadExpiry(): void {
]
];
$result = callGeminiWithFallback($apiKey, $payload, 30);
$result = callGeminiWithFallback($apiKey, $payload, 30, 'expiry_ocr');
$httpCode = $result['http_code'];
if ($httpCode !== 200) {
@@ -3434,7 +3537,7 @@ PROMPT;
]
];
$result = callGeminiWithFallback($apiKey, $payload, 90);
$result = callGeminiWithFallback($apiKey, $payload, 90, 'chat');
$httpCode = $result['http_code'];
if ($httpCode !== 200) {
@@ -3939,7 +4042,7 @@ PROMPT;
]
];
$result = callGeminiWithFallback($apiKey, $payload, 60);
$result = callGeminiWithFallback($apiKey, $payload, 60, 'recipe');
$httpCode = $result['http_code'];
if ($httpCode !== 200) {
@@ -4223,7 +4326,7 @@ PROMPT;
'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 8192]
];
$result = callGeminiWithFallback($apiKey, $payload, 45);
$result = callGeminiWithFallback($apiKey, $payload, 45, 'chat_recipe');
if ($result['http_code'] !== 200) {
echo json_encode(['success' => false, 'error' => $result['data']['error']['message'] ?? 'gemini_error']);
@@ -4339,7 +4442,7 @@ PROMPT;
'generationConfig' => ['temperature' => 0.7, 'maxOutputTokens' => 8192],
];
$result = callGeminiWithFallback($apiKey, $payload, 45);
$result = callGeminiWithFallback($apiKey, $payload, 45, 'recipe_ingredient');
if ($result['http_code'] !== 200) {
echo json_encode(['success' => false, 'error' => $result['data']['error']['message'] ?? 'gemini_error']);
@@ -5102,7 +5205,7 @@ PROMPT;
]
];
$result = callGeminiWithFallback($apiKey, $payload, 30);
$result = callGeminiWithFallback($apiKey, $payload, 30, 'identify_product');
$httpCode = $result['http_code'];
if ($httpCode !== 200) {
@@ -5515,7 +5618,7 @@ PROMPT;
'generationConfig' => ['temperature' => 0.1, 'maxOutputTokens' => 16],
];
$result = callGeminiWithFallback($apiKey, $payload, 15);
$result = callGeminiWithFallback($apiKey, $payload, 15, 'classify_category');
if ($result['http_code'] !== 200 || !isset($result['data']['candidates'][0])) return null;
$text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '');
@@ -6495,6 +6598,7 @@ function smartShopping(PDO $db): void {
}
// 3. Get transaction stats per product (exclude undone=1 corrections)
// Also compute rolling 90-day consumption for smarter quantity suggestions (#70)
$txStmt = $db->query("
SELECT product_id,
COUNT(CASE WHEN type IN ('out','waste') AND undone=0 THEN 1 END) as use_count,
@@ -6503,7 +6607,9 @@ function smartShopping(PDO $db): void {
SUM(CASE WHEN type = 'in' AND undone=0 THEN quantity ELSE 0 END) as total_bought,
MIN(CASE WHEN type = 'in' AND undone=0 THEN created_at END) as first_in,
MAX(CASE WHEN type = 'in' AND undone=0 THEN created_at END) as last_in,
MAX(CASE WHEN type IN ('out','waste') AND undone=0 THEN created_at END) as last_out
MAX(CASE WHEN type IN ('out','waste') AND undone=0 THEN created_at END) as last_out,
SUM(CASE WHEN type IN ('out','waste') AND undone=0 AND created_at >= datetime('now','-90 days') THEN quantity ELSE 0 END) as used_90d,
SUM(CASE WHEN type IN ('out','waste') AND undone=0 AND created_at >= datetime('now','-30 days') THEN quantity ELSE 0 END) as used_30d
FROM transactions
GROUP BY product_id
");
@@ -6581,10 +6687,30 @@ function smartShopping(PDO $db): void {
$lastOut = $tx && $tx['last_out'] ? strtotime($tx['last_out']) : null;
$daysSinceFirst = $firstIn ? max(1, ($now - $firstIn) / 86400) : 999;
// Average daily consumption rate.
// Use the "effective tracking period" (first purchase → last activity) rather than
// first purchase → now, so idle periods after last use don't deflate the rate.
// Example: Aglio bought 60 days ago but last used 34 days ago → use 34-day window.
// Average daily consumption rate — rolling 90-day window with EWMA weighting (#70).
// Priority: if we have ≥3 use events in last 90 days, use weighted blend
// 70% weight on last 30 days, 30% on days 31-90 → reacts to habit changes.
// Fallback: all-time effective-period rate (original logic).
$used90d = (float)($tx['used_90d'] ?? 0);
$used30d = (float)($tx['used_30d'] ?? 0);
$used60_90d = max(0, $used90d - $used30d); // consumption in days 31-90
$dailyRate30 = $used30d > 0 ? $used30d / 30.0 : 0;
$dailyRate60 = $used60_90d > 0 ? $used60_90d / 60.0 : 0;
// Use EWMA only when we have enough recent data
$useEwma = ($used90d > 0 && $daysSinceFirst >= 14);
if ($useEwma) {
if ($dailyRate30 > 0 && $dailyRate60 > 0) {
// Both windows have data → blend 70/30
$dailyRate = 0.70 * $dailyRate30 + 0.30 * $dailyRate60;
} elseif ($dailyRate30 > 0) {
$dailyRate = $dailyRate30; // only recent data
} else {
$dailyRate = $dailyRate60; // only older data
}
} else {
// Fallback: all-time effective-period rate (original logic)
$lastActivity = max($lastIn ?? 0, $lastOut ?? 0);
$activitySpan = ($firstIn && $lastActivity > $firstIn) ? ($lastActivity - $firstIn) : 0;
// Guard: if all activity fits within 24h (e.g. bought & consumed same day / seconds apart),
@@ -6594,6 +6720,7 @@ function smartShopping(PDO $db): void {
? max(1, $activitySpan / 86400)
: $daysSinceFirst;
$dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0;
}
// Days of stock remaining
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
@@ -7151,7 +7278,7 @@ function bringSuggestItems(PDO $db): void {
. "Name and reason must be in Italian. Reason max 8 words.";
$payload = ['contents' => [['parts' => [['text' => $prompt]]]]];
$gemResult = callGeminiWithFallback($apiKey, $payload, 20);
$gemResult = callGeminiWithFallback($apiKey, $payload, 20, 'bring_suggest');
$aiResult = null;
if ($gemResult['http_code'] === 200) {
@@ -7822,7 +7949,7 @@ function geminiProductHint(): void {
. "Output ONLY the JSON, no markdown, no extra text.";
$payload = ['contents' => [['parts' => [['text' => $prompt]]]]];
$result = callGeminiWithFallback($apiKey, $payload, 15);
$result = callGeminiWithFallback($apiKey, $payload, 15, 'product_hint');
if ($result['http_code'] !== 200) {
echo json_encode(['success' => false, 'error' => 'gemini_error', 'http_code' => $result['http_code']]);
@@ -7919,7 +8046,7 @@ function geminiShoppingEnrich(PDO $db): void {
. "Keep the same order and count as the input. Output ONLY the JSON array, no markdown.";
$payload = ['contents' => [['parts' => [['text' => $prompt]]]]];
$result = callGeminiWithFallback($apiKey, $payload, 20);
$result = callGeminiWithFallback($apiKey, $payload, 20, 'shopping_enrich');
if ($result['http_code'] !== 200) {
echo json_encode(['success' => false, 'error' => 'gemini_error']);
@@ -7983,7 +8110,7 @@ function geminiNumberOCR(): void {
'generationConfig' => ['temperature' => 0, 'maxOutputTokens' => 20, 'thinkingConfig' => ['thinkingBudget' => 0]]
];
$result = callGeminiWithFallback($apiKey, $payload, 10);
$result = callGeminiWithFallback($apiKey, $payload, 10, 'number_ocr');
$text = trim($result['text'] ?? '');
$digits = preg_replace('/\D/', '', $text);
@@ -8045,7 +8172,7 @@ function geminiAnomalyExplain(): void {
. "Be conversational and practical.";
$payload = ['contents' => [['parts' => [['text' => $prompt]]]]];
$result = callGeminiWithFallback($apiKey, $payload, 15);
$result = callGeminiWithFallback($apiKey, $payload, 15, 'anomaly_explain');
if ($result['http_code'] !== 200) {
echo json_encode(['success' => false, 'error' => 'gemini_error']);
@@ -8131,7 +8258,7 @@ PROMPT;
$payload = ['contents' => [['parts' => [['text' => $prompt]]]]];
// 55s timeout — generous for large batches (set_time_limit(120) in getAllShoppingPrices)
$result = callGeminiWithFallback($apiKey, $payload, 55);
$result = callGeminiWithFallback($apiKey, $payload, 55, 'price_batch');
if ($result['http_code'] !== 200) return [];
@@ -8189,7 +8316,7 @@ function guessCategoryFromAI(): void {
],
];
$result = callGeminiWithFallback($apiKey, $payload, 10);
$result = callGeminiWithFallback($apiKey, $payload, 10, 'guess_category');
$raw = strtolower(trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''));
$raw = preg_replace('/[^a-z_ ]/', '', $raw);
$raw = trim($raw);
+103
View File
@@ -2203,6 +2203,104 @@ function _applySyncedSettings(serverSettings) {
}
}
let _infoTabTimer = null;
/**
* Load the Info tab: Gemini token usage + cost, log size, DB size, log level.
* Called on tab click; auto-refreshes every 30s while the tab is open.
*/
async function _loadInfoTab() {
// Cancel any previous auto-refresh
if (_infoTabTimer) { clearInterval(_infoTabTimer); _infoTabTimer = null; }
await _renderInfoTab();
// Auto-refresh every 30s while Info tab is visible
_infoTabTimer = setInterval(_renderInfoTab, 30_000);
}
async function _renderInfoTab() {
const aiEl = document.getElementById('info-ai-content');
const sysEl = document.getElementById('info-system-content');
if (!aiEl && !sysEl) return;
try {
const d = await api('gemini_usage');
const s = getSettings();
const sym = s.price_currency === 'USD' ? '$' : (s.price_currency === 'GBP' ? '£' : '€');
// ── AI Usage card ────────────────────────────────────────────────────
if (aiEl) {
const totalTok = (d.input_tokens || 0) + (d.output_tokens || 0);
const costUsd = d.cost_usd || 0;
// Convert cost to display currency (rough fixed rates)
let costDisplay = '$' + costUsd.toFixed(4);
if (s.price_currency === 'EUR') costDisplay = '€' + (costUsd * 0.92).toFixed(4);
if (s.price_currency === 'GBP') costDisplay = '£' + (costUsd * 0.79).toFixed(4);
// By-action breakdown
const actions = d.by_action || {};
const actionRows = Object.entries(actions)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `<tr><td style="padding:2px 8px 2px 0;color:var(--text-secondary);font-size:0.82rem">${k}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem">${v} calls</td></tr>`)
.join('');
// By-model breakdown
const models = d.by_model || {};
const modelRows = Object.entries(models)
.map(([m, mv]) => `<tr><td style="padding:2px 8px 2px 0;color:var(--text-secondary);font-size:0.82rem">${m}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem">${((mv.in||0)+(mv.out||0)).toLocaleString()} tok</td></tr>`)
.join('');
aiEl.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px">
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;text-align:center">
<div style="font-size:1.4rem;font-weight:700;color:var(--accent)">${totalTok.toLocaleString()}</div>
<div style="font-size:0.75rem;color:var(--text-secondary);margin-top:2px">${t('settings.info.total_tokens')}</div>
</div>
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;text-align:center">
<div style="font-size:1.4rem;font-weight:700;color:#15803d">${costDisplay}</div>
<div style="font-size:0.75rem;color:var(--text-secondary);margin-top:2px">${t('settings.info.est_cost')} (${d.month})</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:10px;font-size:0.82rem;color:var(--text-secondary)">
<span> ${t('settings.info.input_tok')}: <strong>${(d.input_tokens||0).toLocaleString()}</strong></span>
<span> ${t('settings.info.output_tok')}: <strong>${(d.output_tokens||0).toLocaleString()}</strong></span>
<span>${t('settings.info.ai_calls')}: <strong>${d.calls||0}</strong></span>
</div>
${actionRows ? `<details style="margin-top:6px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_action')}</summary><table style="margin-top:6px;border-collapse:collapse">${actionRows}</table></details>` : ''}
${modelRows ? `<details style="margin-top:4px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_model')}</summary><table style="margin-top:6px;border-collapse:collapse">${modelRows}</table></details>` : ''}
<p class="settings-hint" style="margin-top:8px">${t('settings.info.pricing_note')}</p>
`;
}
// ── System card ──────────────────────────────────────────────────────
if (sysEl) {
const logMb = ((d.log_bytes || 0) / 1048576).toFixed(2);
const dbMb = ((d.db_bytes || 0) / 1048576).toFixed(2);
sysEl.innerHTML = `
<table style="border-collapse:collapse;width:100%;font-size:0.88rem">
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 0;color:var(--text-secondary)">${t('settings.info.db_size')}</td>
<td style="padding:7px 0;font-weight:600;text-align:right">${dbMb} MB</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 0;color:var(--text-secondary)">${t('settings.info.log_size')}</td>
<td style="padding:7px 0;font-weight:600;text-align:right">${logMb} MB (${d.log_files||0} files)</td>
</tr>
<tr>
<td style="padding:7px 0;color:var(--text-secondary)">${t('settings.info.log_level')}</td>
<td style="padding:7px 0;font-weight:600;text-align:right">
<span style="background:${d.log_level==='DEBUG'?'#dbeafe':d.log_level==='INFO'?'#dcfce7':d.log_level==='WARN'?'#fef9c3':'#fee2e2'};color:${d.log_level==='DEBUG'?'#1e40af':d.log_level==='INFO'?'#15803d':d.log_level==='WARN'?'#854d0e':'#991b1b'};padding:2px 8px;border-radius:6px;font-size:0.8rem">${d.log_level||'INFO'}</span>
</td>
</tr>
</table>
`;
}
} catch(e) {
if (aiEl) aiEl.innerHTML = `<p class="settings-hint">${t('error.generic')}</p>`;
if (sysEl) sysEl.innerHTML = `<p class="settings-hint">${t('error.generic')}</p>`;
}
}
/**
* Populate the About section with the current app version from the server.
*/
@@ -3002,6 +3100,11 @@ async function saveSettings() {
}
function switchSettingsTab(btn, tabId) {
// Stop info-tab auto-refresh when leaving that tab
if (tabId !== 'tab-info' && _infoTabTimer) {
clearInterval(_infoTabTimer);
_infoTabTimer = null;
}
document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
+20
View File
@@ -841,6 +841,7 @@
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-language')" data-tab="tab-language" title="Lingua" data-i18n-title="settings.tab_language">🌐</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info"></button>
</div>
<div class="settings-panels">
<!-- API Keys Tab -->
@@ -1335,6 +1336,25 @@
</div>
</div>
</div>
<!-- Info Tab -->
<div class="settings-panel" id="tab-info">
<!-- Gemini AI Usage card -->
<div class="settings-card">
<h4 data-i18n="settings.info.ai_title">Gemini AI — Token Usage</h4>
<p class="settings-hint" data-i18n="settings.info.ai_hint">Monthly consumption and estimated cost for the current API key.</p>
<div id="info-ai-content" style="margin-top:10px">
<p class="settings-hint" data-i18n="settings.info.loading">Loading…</p>
</div>
</div>
<!-- System Info card -->
<div class="settings-card">
<h4 data-i18n="settings.info.system_title">System</h4>
<div id="info-system-content" style="margin-top:10px">
<p class="settings-hint" data-i18n="settings.info.loading">Loading…</p>
</div>
</div>
</div>
</div>
<!-- Kiosk app download banner (hidden inside kiosk WebView) -->
+18
View File
@@ -761,6 +761,24 @@
"card_title": "♻️ Zero-Waste-Tipps",
"card_hint": "Zeige während des Kochens Tipps zur Wiederverwendung von Abfällen (Schalen, Kochwasser usw.). Standardmäßig deaktiviert.",
"label": "Tipps beim Kochen anzeigen"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Token-Nutzung",
"ai_hint": "Monatlicher Verbrauch und geschätzte Kosten für den aktuellen API-Schlüssel.",
"loading": "Laden…",
"total_tokens": "Token gesamt",
"est_cost": "Gesch. Kosten",
"input_tok": "Eingabe-Token",
"output_tok": "Ausgabe-Token",
"ai_calls": "KI-Aufrufe",
"by_action": "Aufschlüsselung nach Funktion",
"by_model": "Aufschlüsselung nach Modell",
"pricing_note": "Referenzpreise: gemini-2.5-flash $0.15/1M Input, $0.60/1M Output.",
"system_title": "System",
"db_size": "Datenbankgröße",
"log_size": "Log-Größe",
"log_level": "Log-Level"
}
},
"expiry": {
+18
View File
@@ -761,6 +761,24 @@
"card_title": "♻️ Zero-waste tips",
"card_hint": "During cooking, show tips on how to reuse scraps generated in each step (peels, cooking water, etc.). Disabled by default.",
"label": "Show tips during cooking"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Token Usage",
"ai_hint": "Monthly consumption and estimated cost for the current API key.",
"loading": "Loading…",
"total_tokens": "Total tokens",
"est_cost": "Est. cost",
"input_tok": "Input tokens",
"output_tok": "Output tokens",
"ai_calls": "AI calls",
"by_action": "Breakdown by function",
"by_model": "Breakdown by model",
"pricing_note": "Reference pricing: gemini-2.5-flash $0.15/1M input, $0.60/1M output.",
"system_title": "System",
"db_size": "Database size",
"log_size": "Log size",
"log_level": "Log level"
}
},
"expiry": {
+18
View File
@@ -761,6 +761,24 @@
"card_title": "♻️ Suggerimenti zero-waste",
"card_hint": "Durante la cottura, mostra consigli su come riutilizzare gli scarti prodotti in ogni passo (bucce, acqua di cottura, ecc.). Disattivo per impostazione predefinita.",
"label": "Mostra suggerimenti durante la cottura"
},
"info": {
"tab": "Info",
"ai_title": "Gemini AI — Utilizzo Token",
"ai_hint": "Consumo mensile e costo stimato per la chiave API corrente.",
"loading": "Caricamento…",
"total_tokens": "Token totali",
"est_cost": "Costo stimato",
"input_tok": "Token input",
"output_tok": "Token output",
"ai_calls": "Chiamate AI",
"by_action": "Dettaglio per funzione",
"by_model": "Dettaglio per modello",
"pricing_note": "Prezzi di riferimento: gemini-2.5-flash $0.15/1M input, $0.60/1M output.",
"system_title": "Sistema",
"db_size": "Dimensione database",
"log_size": "Dimensione log",
"log_level": "Livello di log"
}
},
"expiry": {