openclaw-smartmeter 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/canvas-template/analysis.public.json +77 -16
- package/canvas-template/app.js +1050 -682
- package/canvas-template/index.html +334 -155
- package/canvas-template/styles.css +1428 -1227
- package/package.json +1 -1
- package/src/analyzer/aggregator.js +4 -2
- package/src/analyzer/openrouter-client.js +15 -6
- package/src/analyzer/recommender.js +129 -0
- package/src/canvas/api-server.js +87 -4
- package/src/cli/commands.js +342 -37
- package/src/cli/index.js +29 -0
package/canvas-template/app.js
CHANGED
|
@@ -1,775 +1,1143 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* SmartMeter Dashboard — app.js
|
|
3
|
+
* Fully redesigned: sidebar nav, editable recommendations, modern UI
|
|
4
|
+
*/
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
/* ─── Globals ─── */
|
|
7
|
+
const API_BASE_URL = `http://localhost:${window.__SMARTMETER_API_PORT || 3001}`;
|
|
8
|
+
let analysisData = null;
|
|
5
9
|
let modelChart = null;
|
|
6
10
|
let taskChart = null;
|
|
7
|
-
let
|
|
8
|
-
let
|
|
9
|
-
let
|
|
10
|
-
let
|
|
11
|
+
let autoRefreshTimer = null;
|
|
12
|
+
let editedRecommendations = {}; // keyed by index
|
|
13
|
+
let selectedModels = {}; // keyed by category, stores chosen model id
|
|
14
|
+
let budgetState = {}; // current budget control values
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
/* ─── Init ─── */
|
|
13
17
|
document.addEventListener('DOMContentLoaded', () => {
|
|
14
|
-
|
|
15
|
-
initializeDashboard();
|
|
16
|
-
startAutoRefresh();
|
|
17
|
-
checkOpenRouterConfig();
|
|
18
|
+
initializeDashboard();
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
// Initialize the dashboard
|
|
21
21
|
async function initializeDashboard() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
try {
|
|
23
|
+
// Try to load data from local file first (works when served statically)
|
|
24
|
+
await loadAnalysisData();
|
|
25
|
+
hideLoading();
|
|
26
|
+
checkOpenRouterConfig();
|
|
27
|
+
startAutoRefresh();
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error('Init error:', err);
|
|
30
|
+
hideLoading();
|
|
31
|
+
showToast('Failed to load dashboard data');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ─── Data Loading ─── */
|
|
36
|
+
async function loadAnalysisData() {
|
|
37
|
+
try {
|
|
38
|
+
// First try the API
|
|
39
|
+
const res = await fetch(`${API_BASE_URL}/api/status`, { signal: AbortSignal.timeout(3000) });
|
|
40
|
+
if (res.ok) {
|
|
41
|
+
const json = await res.json();
|
|
42
|
+
if (json.success && json.analysis) {
|
|
43
|
+
analysisData = normalizeApiData(json.analysis);
|
|
44
|
+
renderAll();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// API not available — try local file
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fallback: load public JSON
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch('analysis.public.json');
|
|
55
|
+
if (res.ok) {
|
|
56
|
+
analysisData = await res.json();
|
|
57
|
+
renderAll();
|
|
58
|
+
return;
|
|
29
59
|
}
|
|
60
|
+
} catch {
|
|
61
|
+
// noop
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.warn('No analysis data available');
|
|
30
65
|
}
|
|
31
66
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
67
|
+
function normalizeApiData(api) {
|
|
68
|
+
// Reshape API /status data → same shape as analysis.public.json
|
|
69
|
+
const s = api.summary || {};
|
|
70
|
+
const p = api.period || {};
|
|
71
|
+
return {
|
|
72
|
+
version: api.version || '0.1.0',
|
|
73
|
+
generated_at: new Date().toISOString(),
|
|
74
|
+
start_date: p.start || '--',
|
|
75
|
+
end_date: p.end || '--',
|
|
76
|
+
days_analyzed: p.days || 0,
|
|
77
|
+
confidence_level: s.confidence || api.confidence || 'Unknown',
|
|
78
|
+
total_tasks: s.totalTasks || 0,
|
|
79
|
+
daily_average: s.dailyAverage || 0,
|
|
80
|
+
monthly_projected_current: s.currentMonthlyCost || 0,
|
|
81
|
+
monthly_projected_optimized: s.optimizedMonthlyCost || 0,
|
|
82
|
+
cache_hit_rate: api.caching?.hitRate || 0,
|
|
83
|
+
model_breakdown: Object.entries(api.models || {}).map(([model, m]) => ({
|
|
84
|
+
model,
|
|
85
|
+
tasks: m.count || 0,
|
|
86
|
+
cost: m.cost || 0,
|
|
87
|
+
avg_cost_per_task: m.avgCostPerTask || 0,
|
|
88
|
+
percentage: 0 // calculated later
|
|
89
|
+
})),
|
|
90
|
+
task_breakdown: Object.entries(api.taskTypes || {}).map(([type, t]) => ({
|
|
91
|
+
type,
|
|
92
|
+
count: t.count || 0,
|
|
93
|
+
percentage: 0,
|
|
94
|
+
avg_cost: t.avgCost || 0
|
|
95
|
+
})),
|
|
96
|
+
recommendations: (api.recommendations || []).map(r => ({
|
|
97
|
+
type: r.type || 'general',
|
|
98
|
+
title: r.title,
|
|
99
|
+
description: r.description,
|
|
100
|
+
impact: r.impact || r.estimatedSavings || '--',
|
|
101
|
+
details: r.details || []
|
|
102
|
+
})),
|
|
103
|
+
cache_stats: api.caching || {},
|
|
104
|
+
cost_timeline: api.costTimeline || [],
|
|
105
|
+
model_alternatives: api.modelAlternatives || api.model_alternatives || [],
|
|
106
|
+
budget_defaults: api.budgetDefaults || api.budget_defaults || {},
|
|
107
|
+
warnings: api.warnings || []
|
|
108
|
+
};
|
|
41
109
|
}
|
|
42
110
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// Update all dashboard components
|
|
59
|
-
updateHeroCard(data);
|
|
60
|
-
updateStatsCards(data);
|
|
61
|
-
updateCharts(data);
|
|
62
|
-
updateRecommendations(data);
|
|
63
|
-
updateDetailsTab(data);
|
|
64
|
-
updateLastUpdated();
|
|
65
|
-
|
|
66
|
-
if (!silent) {
|
|
67
|
-
console.log('Dashboard updated successfully');
|
|
68
|
-
}
|
|
69
|
-
} catch (error) {
|
|
70
|
-
console.error('Error fetching data:', error);
|
|
71
|
-
if (!silent) {
|
|
72
|
-
showToast('Could not load analysis data. Make sure SmartMeter has run.', 'error');
|
|
73
|
-
}
|
|
74
|
-
}
|
|
111
|
+
/* ─── Render All ─── */
|
|
112
|
+
function renderAll() {
|
|
113
|
+
if (!analysisData) return;
|
|
114
|
+
const safe = fn => { try { fn(); } catch (e) { console.error(`[SmartMeter] ${fn.name} error:`, e); } };
|
|
115
|
+
safe(updateKPIs);
|
|
116
|
+
safe(updateMetrics);
|
|
117
|
+
safe(updateCharts);
|
|
118
|
+
safe(updateRecommendations);
|
|
119
|
+
safe(updateModelRecommendations);
|
|
120
|
+
safe(updateOtherRecommendations);
|
|
121
|
+
safe(updateBudgetControls);
|
|
122
|
+
safe(updateModelDetails);
|
|
123
|
+
safe(updateLastUpdated);
|
|
124
|
+
safe(checkCostDataNotice);
|
|
75
125
|
}
|
|
76
126
|
|
|
77
|
-
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} else {
|
|
90
|
-
costDataNotice.style.display = 'none';
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (hasInsufficientData) {
|
|
94
|
-
// Show professional message about needing more data
|
|
95
|
-
document.getElementById('savingsAmount').textContent = '📊';
|
|
96
|
-
document.getElementById('savingsPercentage').textContent = 'Analyzing...';
|
|
97
|
-
document.getElementById('currentCost').innerHTML = '<span class="insufficient-data">Insufficient data</span>';
|
|
98
|
-
document.getElementById('optimizedCost').innerHTML = '<span class="insufficient-data">Gathering usage...</span>';
|
|
99
|
-
|
|
100
|
-
// Show helpful message in confidence badge
|
|
101
|
-
const badge = document.getElementById('confidenceBadge');
|
|
102
|
-
badge.innerHTML = `
|
|
103
|
-
<span class="confidence-icon">💡</span>
|
|
104
|
-
<span class="confidence-text">Need more usage data for accurate cost analysis (${data.total_tasks} tasks, ${data.days_analyzed} days so far)</span>
|
|
105
|
-
`;
|
|
106
|
-
} else {
|
|
107
|
-
// Normal display with actual costs
|
|
108
|
-
document.getElementById('savingsAmount').textContent = `$${savings.toFixed(2)}/mo`;
|
|
109
|
-
document.getElementById('savingsPercentage').textContent = `${savingsPercent}% savings`;
|
|
110
|
-
document.getElementById('currentCost').textContent = `$${data.monthly_projected_current.toFixed(2)}/mo`;
|
|
111
|
-
document.getElementById('optimizedCost').textContent = `$${data.monthly_projected_optimized.toFixed(2)}/mo`;
|
|
112
|
-
|
|
113
|
-
// Update confidence badge
|
|
114
|
-
const badge = document.getElementById('confidenceBadge');
|
|
115
|
-
const confidenceText = getConfidenceText(data.confidence_level, data.days_analyzed);
|
|
116
|
-
badge.innerHTML = `
|
|
117
|
-
<span class="confidence-icon">${getConfidenceIcon(data.confidence_level)}</span>
|
|
118
|
-
<span class="confidence-text">${confidenceText}</span>
|
|
119
|
-
`;
|
|
120
|
-
}
|
|
127
|
+
/* ─── KPIs ─── */
|
|
128
|
+
function updateKPIs() {
|
|
129
|
+
const d = analysisData;
|
|
130
|
+
const current = d.monthly_projected_current || 0;
|
|
131
|
+
const optimized = d.monthly_projected_optimized || 0;
|
|
132
|
+
const savings = current - optimized;
|
|
133
|
+
const pct = current > 0 ? ((savings / current) * 100) : 0;
|
|
134
|
+
|
|
135
|
+
setText('currentCost', `$${current.toFixed(2)}<small>/mo</small>`);
|
|
136
|
+
setText('optimizedCost', `$${optimized.toFixed(2)}<small>/mo</small>`);
|
|
137
|
+
setText('savingsAmount', `$${savings.toFixed(2)}`);
|
|
138
|
+
setText('savingsPercentage', pct > 0 ? `↓ ${pct.toFixed(1)}% reduction` : '--');
|
|
121
139
|
}
|
|
122
140
|
|
|
123
|
-
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
/* ─── Metrics ─── */
|
|
142
|
+
function updateMetrics() {
|
|
143
|
+
const d = analysisData;
|
|
144
|
+
setText('analysisPeriod', `${d.days_analyzed || 0} days`);
|
|
145
|
+
setText('totalTasks', String(d.total_tasks || 0));
|
|
146
|
+
setText('cacheHitRate', `${((d.cache_hit_rate || 0) * 100).toFixed(1)}%`);
|
|
147
|
+
const daily = d.daily_average ? (d.daily_average * (d.monthly_projected_current / (d.total_tasks || 1))) : 0;
|
|
148
|
+
setText('dailySpend', `$${daily.toFixed(2)}`);
|
|
149
|
+
|
|
150
|
+
const conf = d.confidence_level || 'Unknown';
|
|
151
|
+
const badge = document.getElementById('confidenceBadge');
|
|
152
|
+
if (badge) {
|
|
153
|
+
badge.querySelector('.confidence-text').textContent = `Confidence: ${conf}`;
|
|
154
|
+
}
|
|
129
155
|
}
|
|
130
156
|
|
|
131
|
-
|
|
132
|
-
function updateCharts(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
/* ─── Charts ─── */
|
|
158
|
+
function updateCharts() {
|
|
159
|
+
updateModelChart();
|
|
160
|
+
updateTaskChart();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function updateModelChart() {
|
|
164
|
+
const ctx = document.getElementById('modelChart')?.getContext('2d');
|
|
165
|
+
if (!ctx) return;
|
|
166
|
+
|
|
167
|
+
const models = analysisData.model_breakdown || [];
|
|
168
|
+
const labels = models.map(m => m.model);
|
|
169
|
+
const costData = models.map(m => m.cost);
|
|
170
|
+
const taskData = models.map(m => m.tasks);
|
|
171
|
+
const colors = ['#6366f1', '#22c55e', '#f59e0b', '#38bdf8', '#ef4444', '#a78bfa'];
|
|
172
|
+
|
|
173
|
+
if (modelChart) modelChart.destroy();
|
|
174
|
+
modelChart = new Chart(ctx, {
|
|
175
|
+
type: 'bar',
|
|
176
|
+
data: {
|
|
177
|
+
labels,
|
|
178
|
+
datasets: [
|
|
179
|
+
{
|
|
180
|
+
label: 'Cost ($)',
|
|
181
|
+
data: costData,
|
|
182
|
+
backgroundColor: colors.slice(0, labels.length).map(c => c + '55'),
|
|
183
|
+
borderColor: colors.slice(0, labels.length),
|
|
184
|
+
borderWidth: 1.5,
|
|
185
|
+
borderRadius: 6,
|
|
186
|
+
yAxisID: 'y'
|
|
153
187
|
},
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
`Tasks: ${model.tasks}`,
|
|
165
|
-
`Avg: $${model.avg_cost_per_task.toFixed(3)}`
|
|
166
|
-
];
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
scales: {
|
|
172
|
-
y: {
|
|
173
|
-
beginAtZero: true,
|
|
174
|
-
ticks: {
|
|
175
|
-
callback: function(value) {
|
|
176
|
-
return '$' + value.toFixed(2);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
188
|
+
{
|
|
189
|
+
label: 'Tasks',
|
|
190
|
+
data: taskData,
|
|
191
|
+
type: 'line',
|
|
192
|
+
borderColor: '#38bdf8',
|
|
193
|
+
backgroundColor: 'rgba(56,189,248,.1)',
|
|
194
|
+
pointBackgroundColor: '#38bdf8',
|
|
195
|
+
pointRadius: 4,
|
|
196
|
+
tension: .3,
|
|
197
|
+
yAxisID: 'y1'
|
|
181
198
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
]
|
|
200
|
+
},
|
|
201
|
+
options: {
|
|
202
|
+
responsive: true,
|
|
203
|
+
maintainAspectRatio: false,
|
|
204
|
+
interaction: { mode: 'index', intersect: false },
|
|
205
|
+
plugins: {
|
|
206
|
+
legend: { display: true, position: 'top', labels: { color: '#8b90a5', font: { size: 11 }, boxWidth: 12, padding: 12 } }
|
|
207
|
+
},
|
|
208
|
+
scales: {
|
|
209
|
+
x: { ticks: { color: '#5d6178', font: { size: 10, family: "'JetBrains Mono', monospace" } }, grid: { color: 'rgba(42,46,63,.4)' } },
|
|
210
|
+
y: { position: 'left', ticks: { color: '#8b90a5', font: { size: 10 }, callback: v => '$' + v.toFixed(2) }, grid: { color: 'rgba(42,46,63,.3)' } },
|
|
211
|
+
y1: { position: 'right', ticks: { color: '#38bdf8', font: { size: 10 } }, grid: { drawOnChartArea: false } }
|
|
212
|
+
}
|
|
187
213
|
}
|
|
188
|
-
|
|
189
|
-
const taskCtx = document.getElementById('taskChart').getContext('2d');
|
|
190
|
-
const taskColors = ['#16a34a', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6'];
|
|
191
|
-
|
|
192
|
-
taskChart = new Chart(taskCtx, {
|
|
193
|
-
type: 'doughnut',
|
|
194
|
-
data: {
|
|
195
|
-
labels: data.task_breakdown.map(t => t.type),
|
|
196
|
-
datasets: [{
|
|
197
|
-
data: data.task_breakdown.map(t => t.count),
|
|
198
|
-
backgroundColor: taskColors,
|
|
199
|
-
borderWidth: 2,
|
|
200
|
-
borderColor: '#fff'
|
|
201
|
-
}]
|
|
202
|
-
},
|
|
203
|
-
options: {
|
|
204
|
-
responsive: true,
|
|
205
|
-
maintainAspectRatio: false,
|
|
206
|
-
plugins: {
|
|
207
|
-
legend: {
|
|
208
|
-
position: 'right',
|
|
209
|
-
labels: {
|
|
210
|
-
padding: 15,
|
|
211
|
-
font: { size: 13 }
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
tooltip: {
|
|
215
|
-
callbacks: {
|
|
216
|
-
label: function(context) {
|
|
217
|
-
const task = data.task_breakdown[context.dataIndex];
|
|
218
|
-
const percentage = ((task.count / data.total_tasks) * 100).toFixed(1);
|
|
219
|
-
return `${task.type}: ${task.count} (${percentage}%)`;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
});
|
|
214
|
+
});
|
|
226
215
|
}
|
|
227
216
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
217
|
+
function updateTaskChart() {
|
|
218
|
+
const ctx = document.getElementById('taskChart')?.getContext('2d');
|
|
219
|
+
if (!ctx) return;
|
|
220
|
+
|
|
221
|
+
const tasks = analysisData.task_breakdown || [];
|
|
222
|
+
const labels = tasks.map(t => t.type);
|
|
223
|
+
const data = tasks.map(t => t.count);
|
|
224
|
+
const colors = ['#6366f1', '#22c55e', '#f59e0b', '#38bdf8', '#ef4444'];
|
|
225
|
+
|
|
226
|
+
if (taskChart) taskChart.destroy();
|
|
227
|
+
taskChart = new Chart(ctx, {
|
|
228
|
+
type: 'doughnut',
|
|
229
|
+
data: {
|
|
230
|
+
labels,
|
|
231
|
+
datasets: [{
|
|
232
|
+
data,
|
|
233
|
+
backgroundColor: colors.slice(0, labels.length),
|
|
234
|
+
borderColor: '#1c1f2e',
|
|
235
|
+
borderWidth: 3,
|
|
236
|
+
hoverOffset: 6
|
|
237
|
+
}]
|
|
238
|
+
},
|
|
239
|
+
options: {
|
|
240
|
+
responsive: true,
|
|
241
|
+
maintainAspectRatio: false,
|
|
242
|
+
cutout: '60%',
|
|
243
|
+
plugins: {
|
|
244
|
+
legend: { position: 'bottom', labels: { color: '#8b90a5', font: { size: 11 }, padding: 14, boxWidth: 12 } }
|
|
245
|
+
}
|
|
235
246
|
}
|
|
236
|
-
|
|
237
|
-
container.innerHTML = data.recommendations.map((rec, index) => `
|
|
238
|
-
<div class="recommendation-item">
|
|
239
|
-
<div class="recommendation-header">
|
|
240
|
-
<div class="recommendation-title">
|
|
241
|
-
<span>${getRecommendationIcon(rec.type)}</span>
|
|
242
|
-
<span>${rec.title}</span>
|
|
243
|
-
</div>
|
|
244
|
-
<div class="recommendation-impact">Saves ${rec.impact}</div>
|
|
245
|
-
</div>
|
|
246
|
-
<div class="recommendation-description">${rec.description}</div>
|
|
247
|
-
${rec.details ? `
|
|
248
|
-
<div class="recommendation-details">
|
|
249
|
-
${rec.details.map(d => `<span>• ${d}</span>`).join('')}
|
|
250
|
-
</div>
|
|
251
|
-
` : ''}
|
|
252
|
-
<div class="recommendation-actions">
|
|
253
|
-
<button class="btn btn-secondary btn-sm" onclick="viewRecommendationDetails(${index})">
|
|
254
|
-
View Details
|
|
255
|
-
</button>
|
|
256
|
-
</div>
|
|
257
|
-
</div>
|
|
258
|
-
`).join('');
|
|
247
|
+
});
|
|
259
248
|
}
|
|
260
249
|
|
|
261
|
-
|
|
262
|
-
function
|
|
263
|
-
|
|
250
|
+
/* ─── Recommendations (Savings Banner) ─── */
|
|
251
|
+
function updateRecommendations() {
|
|
252
|
+
const d = analysisData;
|
|
253
|
+
if (!d) return;
|
|
254
|
+
const current = d.monthly_projected_current || 0;
|
|
255
|
+
const optimized = computeOptimizedCost();
|
|
256
|
+
const savings = current - optimized;
|
|
257
|
+
|
|
258
|
+
setText('totalSavingsEstimate', `$${savings.toFixed(2)}/mo`);
|
|
259
|
+
setText('bannerCurrentCost', `$${current.toFixed(2)}`);
|
|
260
|
+
setText('bannerOptimizedCost', `$${optimized.toFixed(2)}`);
|
|
264
261
|
}
|
|
265
262
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
263
|
+
function computeOptimizedCost() {
|
|
264
|
+
const d = analysisData;
|
|
265
|
+
if (!d) return 0;
|
|
266
|
+
const alternatives = d.model_alternatives || [];
|
|
267
|
+
const monthlyCurrent = d.monthly_projected_current || 0;
|
|
268
|
+
const totalTasks = d.total_tasks || 0;
|
|
269
|
+
|
|
270
|
+
// Estimate tier distribution
|
|
271
|
+
const tierDist = { high: 0.10, medium: 0.55, low: 0.35 };
|
|
272
|
+
const tasks = d.task_breakdown || [];
|
|
273
|
+
if (tasks.length > 0) {
|
|
274
|
+
const total = tasks.reduce((s, t) => s + t.count, 0) || 1;
|
|
275
|
+
const research = tasks.filter(t => /research/i.test(t.type)).reduce((s, t) => s + t.count, 0);
|
|
276
|
+
const code = tasks.filter(t => /code/i.test(t.type)).reduce((s, t) => s + t.count, 0);
|
|
277
|
+
const writing = tasks.filter(t => /writ/i.test(t.type)).reduce((s, t) => s + t.count, 0);
|
|
278
|
+
tierDist.high = Math.max(0.05, (research * 1.5 + code * 0.1) / total);
|
|
279
|
+
tierDist.medium = Math.max(0.20, (code * 0.6 + writing * 0.7) / total);
|
|
280
|
+
tierDist.low = Math.max(0.10, 1 - tierDist.high - tierDist.medium);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Current cost per tier
|
|
284
|
+
const models = d.model_breakdown || [];
|
|
285
|
+
const modelsSortedByCost = [...models].sort((a, b) => b.avg_cost_per_task - a.avg_cost_per_task);
|
|
286
|
+
const currentModels = {
|
|
287
|
+
high: modelsSortedByCost[0] || null,
|
|
288
|
+
medium: modelsSortedByCost[1] || modelsSortedByCost[0] || null,
|
|
289
|
+
low: modelsSortedByCost[modelsSortedByCost.length - 1] || null
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
let total = 0;
|
|
293
|
+
for (const tier of TASK_TIERS) {
|
|
294
|
+
const tierTasks = Math.round(totalTasks * tierDist[tier.id]);
|
|
295
|
+
const chosenId = selectedModels[tier.id];
|
|
296
|
+
if (chosenId) {
|
|
297
|
+
const alt = alternatives.find(a => a.id === chosenId);
|
|
298
|
+
if (alt) {
|
|
299
|
+
total += tierTasks * alt.avg_cost_per_task;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
281
302
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
content.innerHTML = generateModelDetails(currentData);
|
|
289
|
-
break;
|
|
290
|
-
case 'timeline':
|
|
291
|
-
content.innerHTML = generateTimelineDetails(currentData);
|
|
292
|
-
break;
|
|
303
|
+
// No selection — use current model cost for this tier
|
|
304
|
+
const cm = currentModels[tier.id];
|
|
305
|
+
if (cm) {
|
|
306
|
+
total += tierTasks * cm.avg_cost_per_task;
|
|
307
|
+
} else {
|
|
308
|
+
total += monthlyCurrent * tierDist[tier.id];
|
|
293
309
|
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Apply cache savings if applicable
|
|
313
|
+
const cacheBoost = (d.cache_hit_rate || 0) < 0.5 ? 0.85 : 1;
|
|
314
|
+
return total * cacheBoost;
|
|
294
315
|
}
|
|
295
316
|
|
|
296
|
-
|
|
297
|
-
|
|
317
|
+
/* ─── Task Complexity Tiers ─── */
|
|
318
|
+
const TASK_TIERS = [
|
|
319
|
+
{
|
|
320
|
+
id: 'high',
|
|
321
|
+
label: 'High-Complexity Tasks',
|
|
322
|
+
icon: '🧠',
|
|
323
|
+
description: 'Architecture design, complex reasoning, multi-step research, difficult debugging. These tasks need the highest-quality models.',
|
|
324
|
+
color: 'var(--red)',
|
|
325
|
+
colorSubtle: 'var(--red-subtle)',
|
|
326
|
+
borderColor: 'rgba(239,68,68,.25)',
|
|
327
|
+
sort: (a, b) => b.quality_score - a.quality_score // quality first
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: 'medium',
|
|
331
|
+
label: 'Medium-Complexity Tasks',
|
|
332
|
+
icon: '⚙️',
|
|
333
|
+
description: 'Standard coding, writing, code reviews, feature implementation. A good balance of quality and cost.',
|
|
334
|
+
color: 'var(--amber)',
|
|
335
|
+
colorSubtle: 'var(--amber-subtle)',
|
|
336
|
+
borderColor: 'rgba(245,158,11,.25)',
|
|
337
|
+
sort: (a, b) => (b.quality_score * 0.5 - b.avg_cost_per_task * 100) - (a.quality_score * 0.5 - a.avg_cost_per_task * 100) // value
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: 'low',
|
|
341
|
+
label: 'Low-Complexity Tasks',
|
|
342
|
+
icon: '⚡',
|
|
343
|
+
description: 'Simple queries, quick fixes, boilerplate generation, formatting. Speed and low cost matter most.',
|
|
344
|
+
color: 'var(--green)',
|
|
345
|
+
colorSubtle: 'var(--green-subtle)',
|
|
346
|
+
borderColor: 'rgba(34,197,94,.25)',
|
|
347
|
+
sort: (a, b) => a.avg_cost_per_task - b.avg_cost_per_task // cheapest first
|
|
348
|
+
}
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
/* ─── Model Recommendations by Tier ─── */
|
|
352
|
+
function updateModelRecommendations() {
|
|
353
|
+
const container = document.getElementById('modelRecommendationsList');
|
|
354
|
+
if (!container) return;
|
|
355
|
+
|
|
356
|
+
const d = analysisData;
|
|
357
|
+
const alternatives = d.model_alternatives || [];
|
|
358
|
+
const totalTasks = d.total_tasks || 0;
|
|
359
|
+
const monthlyCurrent = d.monthly_projected_current || 0;
|
|
360
|
+
|
|
361
|
+
if (alternatives.length === 0) {
|
|
362
|
+
container.innerHTML = '<div class="empty-state"><p>No model data available. Connect your OpenRouter API key to see recommendations.</p></div>';
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Estimate task distribution across tiers
|
|
367
|
+
const tierDistribution = { high: 0.10, medium: 0.55, low: 0.35 };
|
|
368
|
+
// Refine from task_breakdown if available
|
|
369
|
+
const tasks = d.task_breakdown || [];
|
|
370
|
+
if (tasks.length > 0) {
|
|
371
|
+
const total = tasks.reduce((s, t) => s + t.count, 0) || 1;
|
|
372
|
+
const research = tasks.filter(t => /research/i.test(t.type)).reduce((s, t) => s + t.count, 0);
|
|
373
|
+
const code = tasks.filter(t => /code/i.test(t.type)).reduce((s, t) => s + t.count, 0);
|
|
374
|
+
const writing = tasks.filter(t => /writ/i.test(t.type)).reduce((s, t) => s + t.count, 0);
|
|
375
|
+
tierDistribution.high = Math.max(0.05, (research * 1.5 + code * 0.1) / total);
|
|
376
|
+
tierDistribution.medium = Math.max(0.20, (code * 0.6 + writing * 0.7) / total);
|
|
377
|
+
tierDistribution.low = Math.max(0.10, 1 - tierDistribution.high - tierDistribution.medium);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Identify which model is currently used for each tier (best guess from model_breakdown)
|
|
381
|
+
const models = d.model_breakdown || [];
|
|
382
|
+
const modelsSortedByCost = [...models].sort((a, b) => b.avg_cost_per_task - a.avg_cost_per_task);
|
|
383
|
+
const currentPerTier = {
|
|
384
|
+
high: modelsSortedByCost[0]?.model || null, // most expensive = high
|
|
385
|
+
medium: modelsSortedByCost[1]?.model || modelsSortedByCost[0]?.model || null,
|
|
386
|
+
low: modelsSortedByCost[modelsSortedByCost.length - 1]?.model || null // cheapest = low
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const cards = TASK_TIERS.map((tier, tierIdx) => {
|
|
390
|
+
const chosenId = selectedModels[tier.id] || null;
|
|
391
|
+
const tierTasks = Math.round(totalTasks * tierDistribution[tier.id]);
|
|
392
|
+
const tierCostShare = monthlyCurrent * tierDistribution[tier.id];
|
|
393
|
+
const currentModel = currentPerTier[tier.id];
|
|
394
|
+
const currentAlt = alternatives.find(a => a.name === currentModel);
|
|
395
|
+
const currentCostPerTask = currentAlt?.avg_cost_per_task || (tierCostShare / (tierTasks || 1));
|
|
396
|
+
|
|
397
|
+
// Sort alternatives by tier preference
|
|
398
|
+
const sorted = [...alternatives].sort(tier.sort);
|
|
399
|
+
|
|
400
|
+
// Build options with projected costs
|
|
401
|
+
const opts = sorted.map((alt, rank) => {
|
|
402
|
+
const projCost = tierTasks * alt.avg_cost_per_task;
|
|
403
|
+
const currentTierCost = tierTasks * currentCostPerTask;
|
|
404
|
+
const savings = currentTierCost - projCost;
|
|
405
|
+
const isCurrent = alt.name === currentModel;
|
|
406
|
+
const isChosen = chosenId === alt.id;
|
|
407
|
+
const isRecommended = rank === 0 && !isCurrent;
|
|
408
|
+
return { ...alt, projCost, savings, isCurrent, isChosen, isRecommended };
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const chosen = opts.find(o => o.isChosen);
|
|
412
|
+
const chosenSavings = chosen ? chosen.savings : 0;
|
|
413
|
+
|
|
298
414
|
return `
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
<tr><td>Weekly Projected:</td><td style="text-align: right; font-weight: 600;">$${data.weekly_projected.toFixed(2)}</td></tr>
|
|
305
|
-
<tr><td>Monthly Projected:</td><td style="text-align: right; font-weight: 600;">$${data.monthly_projected_current.toFixed(2)}</td></tr>
|
|
306
|
-
<tr style="border-top: 2px solid var(--border-color); font-weight: 700; color: var(--success-color);">
|
|
307
|
-
<td style="padding-top: 8px;">Optimized Monthly:</td>
|
|
308
|
-
<td style="text-align: right; padding-top: 8px;">$${data.monthly_projected_optimized.toFixed(2)}</td>
|
|
309
|
-
</tr>
|
|
310
|
-
</table>
|
|
311
|
-
</div>
|
|
312
|
-
<div>
|
|
313
|
-
<h4 style="margin-bottom: 12px; color: var(--text-primary);">Performance Metrics</h4>
|
|
314
|
-
<table style="width: 100%; font-size: 14px;">
|
|
315
|
-
<tr><td>Cache Hit Rate:</td><td style="text-align: right; font-weight: 600;">${(data.cache_hit_rate * 100).toFixed(1)}%</td></tr>
|
|
316
|
-
<tr><td>Total Tasks:</td><td style="text-align: right; font-weight: 600;">${data.total_tasks}</td></tr>
|
|
317
|
-
<tr><td>Analysis Period:</td><td style="text-align: right; font-weight: 600;">${data.days_analyzed} days</td></tr>
|
|
318
|
-
<tr><td>Confidence Level:</td><td style="text-align: right; font-weight: 600;">${data.confidence_level}</td></tr>
|
|
319
|
-
</table>
|
|
415
|
+
<div class="model-rec-card ${chosenId ? 'has-selection' : ''}" style="border-color: ${tier.borderColor}">
|
|
416
|
+
<div class="model-rec-header">
|
|
417
|
+
<div class="model-rec-current">
|
|
418
|
+
<div class="tier-badge" style="background: ${tier.colorSubtle}; color: ${tier.color}">
|
|
419
|
+
<span>${tier.icon}</span> ${escHtml(tier.label)}
|
|
320
420
|
</div>
|
|
421
|
+
<div class="model-rec-tier-desc">${escHtml(tier.description)}</div>
|
|
422
|
+
<div class="model-rec-stats">~${tierTasks} tasks/mo · ~$${tierCostShare.toFixed(2)}/mo${currentModel ? ' · Currently: ' + escHtml(currentModel) : ''}</div>
|
|
423
|
+
</div>
|
|
424
|
+
${chosenSavings > 0 ? `
|
|
425
|
+
<div class="model-rec-savings-badge">
|
|
426
|
+
<span class="savings-arrow">↓</span> $${chosenSavings.toFixed(2)}/mo
|
|
427
|
+
</div>` : ''}
|
|
321
428
|
</div>
|
|
322
|
-
`;
|
|
323
|
-
}
|
|
324
429
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
</tbody>
|
|
351
|
-
</table>
|
|
430
|
+
<div class="model-alt-label">Select a model for this tier:</div>
|
|
431
|
+
<div class="model-alt-list">
|
|
432
|
+
${opts.map(alt => `
|
|
433
|
+
<label class="model-alt-option ${alt.isChosen ? 'selected' : ''} ${alt.isCurrent ? 'current' : ''}" onclick="selectModelAlternative('${tier.id}', '${alt.id}')">
|
|
434
|
+
<div class="model-alt-radio">
|
|
435
|
+
<input type="radio" name="tier-${tierIdx}" ${alt.isChosen || (!chosenId && alt.isCurrent) ? 'checked' : ''}>
|
|
436
|
+
</div>
|
|
437
|
+
<div class="model-alt-info">
|
|
438
|
+
<div class="model-alt-name">
|
|
439
|
+
${escHtml(alt.name)}
|
|
440
|
+
${alt.isCurrent ? '<span class="model-tag current-tag">CURRENT</span>' : ''}
|
|
441
|
+
${alt.isRecommended ? '<span class="model-tag best-tag">RECOMMENDED</span>' : ''}
|
|
442
|
+
</div>
|
|
443
|
+
<div class="model-alt-meta">
|
|
444
|
+
Quality: ${renderQualityDots(alt.quality_score)} · ${escHtml(alt.speed || '')} · Best for: ${(alt.best_for || []).join(', ')}
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
<div class="model-alt-cost">
|
|
448
|
+
<div class="model-alt-price">$${alt.projCost.toFixed(2)}<small>/mo</small></div>
|
|
449
|
+
${!alt.isCurrent ? `<div class="model-alt-delta ${alt.savings > 0 ? 'delta-positive' : alt.savings < 0 ? 'delta-negative' : ''}">
|
|
450
|
+
${alt.savings > 0 ? '↓' : alt.savings < 0 ? '↑' : '='} ${alt.savings !== 0 ? '$' + Math.abs(alt.savings).toFixed(2) : 'same'}
|
|
451
|
+
</div>` : '<div class="model-alt-delta">—</div>'}
|
|
452
|
+
</div>
|
|
453
|
+
</label>
|
|
454
|
+
`).join('')}
|
|
352
455
|
</div>
|
|
353
|
-
|
|
456
|
+
</div>`;
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
container.innerHTML = cards.join('');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function renderQualityDots(score) {
|
|
463
|
+
const filled = Math.round(score / 20);
|
|
464
|
+
let dots = '';
|
|
465
|
+
for (let i = 0; i < 5; i++) {
|
|
466
|
+
dots += `<span class="quality-dot ${i < filled ? 'filled' : ''}"></span>`;
|
|
467
|
+
}
|
|
468
|
+
return `<span class="quality-dots">${dots}</span> ${score}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function selectModelAlternative(tierId, modelId) {
|
|
472
|
+
// Toggle: if clicking already-chosen, deselect
|
|
473
|
+
if (selectedModels[tierId] === modelId) {
|
|
474
|
+
delete selectedModels[tierId];
|
|
475
|
+
} else {
|
|
476
|
+
selectedModels[tierId] = modelId;
|
|
477
|
+
}
|
|
478
|
+
updateModelRecommendations();
|
|
479
|
+
updateRecommendations(); // recalculate savings banner
|
|
354
480
|
}
|
|
355
481
|
|
|
356
|
-
|
|
357
|
-
function
|
|
482
|
+
/* ─── Other Recommendations (non-model) ─── */
|
|
483
|
+
function updateOtherRecommendations() {
|
|
484
|
+
const container = document.getElementById('otherRecommendationsList');
|
|
485
|
+
if (!container) return;
|
|
486
|
+
|
|
487
|
+
const recs = (analysisData.recommendations || []).filter(r => r.type !== 'model_switch' && r.type !== 'budget_control');
|
|
488
|
+
if (recs.length === 0) {
|
|
489
|
+
container.innerHTML = '<div class="empty-state"><p>No additional recommendations.</p></div>';
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
container.innerHTML = recs.map((rec, i) => {
|
|
494
|
+
const edited = editedRecommendations[i];
|
|
495
|
+
const desc = edited?.description ?? rec.description;
|
|
496
|
+
const impact = rec.impact || '--';
|
|
497
|
+
const impactClass = getImpactClass(impact);
|
|
498
|
+
const isSelected = edited?.selected || false;
|
|
499
|
+
const isEditing = edited?.editing || false;
|
|
500
|
+
const isExpanded = edited?.expanded || false;
|
|
501
|
+
|
|
358
502
|
return `
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
</
|
|
364
|
-
<div
|
|
365
|
-
|
|
503
|
+
<div class="rec-card ${isSelected ? 'selected' : ''} ${isEditing ? 'editing' : ''} ${isExpanded ? 'expanded' : ''}" data-index="${i}">
|
|
504
|
+
<div class="rec-header">
|
|
505
|
+
<input type="checkbox" class="rec-checkbox" ${isSelected ? 'checked' : ''} onchange="toggleRecSelection(${i})" title="Select for apply">
|
|
506
|
+
<div class="rec-title-wrap" onclick="toggleRecExpand(${i})">
|
|
507
|
+
<div class="rec-title">${escHtml(rec.title)}</div>
|
|
508
|
+
<div class="rec-meta">${escHtml(rec.type)} · Impact: ${escHtml(impact)}</div>
|
|
509
|
+
</div>
|
|
510
|
+
<span class="rec-impact-badge ${impactClass}">${escHtml(impact)}</span>
|
|
511
|
+
<div class="rec-actions">
|
|
512
|
+
<button class="btn btn-sm btn-outline" onclick="toggleRecEdit(${i})" title="Edit recommendation">✏️</button>
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
<div class="rec-body">
|
|
516
|
+
<p class="rec-description">${escHtml(desc)}</p>
|
|
517
|
+
<div class="rec-edit-area">
|
|
518
|
+
<textarea class="rec-edit-textarea" id="recEdit${i}" oninput="recDirty(${i})">${escHtml(desc)}</textarea>
|
|
519
|
+
<div class="rec-edit-actions">
|
|
520
|
+
<button class="btn btn-sm btn-success" onclick="saveRecEdit(${i})">Save</button>
|
|
521
|
+
<button class="btn btn-sm btn-ghost" onclick="cancelRecEdit(${i})">Cancel</button>
|
|
366
522
|
</div>
|
|
523
|
+
</div>
|
|
524
|
+
${renderRecDetails(rec.details || [])}
|
|
367
525
|
</div>
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Helper functions
|
|
372
|
-
function isInsufficientData(data) {
|
|
373
|
-
// Insufficient data if:
|
|
374
|
-
// 1. Total costs are zero or near-zero (< $0.01)
|
|
375
|
-
// 2. Less than 5 tasks analyzed
|
|
376
|
-
// 3. Less than 1 day of data
|
|
377
|
-
const hasNoCosts = data.monthly_projected_current < 0.01;
|
|
378
|
-
const hasMinimalTasks = data.total_tasks < 5;
|
|
379
|
-
const hasMinimalDays = data.days_analyzed < 1;
|
|
380
|
-
|
|
381
|
-
return hasNoCosts || hasMinimalTasks || hasMinimalDays;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function getConfidenceIcon(level) {
|
|
385
|
-
const icons = {
|
|
386
|
-
'high': '✅',
|
|
387
|
-
'medium': '⚠️',
|
|
388
|
-
'low': 'ℹ️',
|
|
389
|
-
'optimistic': '📊'
|
|
390
|
-
};
|
|
391
|
-
return icons[level.toLowerCase()] || 'ℹ️';
|
|
526
|
+
</div>`;
|
|
527
|
+
}).join('');
|
|
392
528
|
}
|
|
393
529
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
530
|
+
/* ─── Budget Controls ─── */
|
|
531
|
+
function updateBudgetControls() {
|
|
532
|
+
const d = analysisData;
|
|
533
|
+
if (!d) return;
|
|
534
|
+
const defaults = d.budget_defaults || {};
|
|
535
|
+
const budgetRec = (d.recommendations || []).find(r => r.type === 'budget_control');
|
|
400
536
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
537
|
+
// Initialize budget state from defaults or recommendation data
|
|
538
|
+
if (!budgetState.initialized) {
|
|
539
|
+
budgetState = {
|
|
540
|
+
initialized: true,
|
|
541
|
+
dailyCap: defaults.daily_cap || parseFloat(extractBudgetValue(budgetRec, 'Daily cap')) || 2.00,
|
|
542
|
+
weeklyAlert: defaults.weekly_alert_pct || 75,
|
|
543
|
+
monthly: defaults.monthly_budget || parseFloat(extractBudgetValue(budgetRec, 'Monthly budget')) || 40,
|
|
544
|
+
autoPause: defaults.auto_pause_pct || 95
|
|
408
545
|
};
|
|
409
|
-
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Set input values
|
|
549
|
+
setInputVal('budgetDailyCap', budgetState.dailyCap);
|
|
550
|
+
setInputVal('budgetDailyCapSlider', budgetState.dailyCap);
|
|
551
|
+
setInputVal('budgetWeeklyAlert', budgetState.weeklyAlert);
|
|
552
|
+
setInputVal('budgetWeeklyAlertSlider', budgetState.weeklyAlert);
|
|
553
|
+
setInputVal('budgetMonthly', budgetState.monthly);
|
|
554
|
+
setInputVal('budgetMonthlySlider', budgetState.monthly);
|
|
555
|
+
setInputVal('budgetAutoPause', budgetState.autoPause);
|
|
556
|
+
setInputVal('budgetAutoPauseSlider', budgetState.autoPause);
|
|
557
|
+
|
|
558
|
+
updateBudgetImpact();
|
|
410
559
|
}
|
|
411
560
|
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
});
|
|
419
|
-
document.getElementById('lastUpdated').textContent = timeString;
|
|
561
|
+
function extractBudgetValue(rec, prefix) {
|
|
562
|
+
if (!rec || !rec.details) return '';
|
|
563
|
+
const detail = rec.details.find(d => d.startsWith(prefix));
|
|
564
|
+
if (!detail) return '';
|
|
565
|
+
const match = detail.match(/\$?([\d.]+)/);
|
|
566
|
+
return match ? match[1] : '';
|
|
420
567
|
}
|
|
421
568
|
|
|
422
|
-
function
|
|
423
|
-
|
|
424
|
-
|
|
569
|
+
function setInputVal(id, val) {
|
|
570
|
+
const el = document.getElementById(id);
|
|
571
|
+
if (el) el.value = val;
|
|
425
572
|
}
|
|
426
573
|
|
|
427
|
-
function
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
setTimeout(() => {
|
|
433
|
-
toast.classList.remove('show');
|
|
434
|
-
}, 4000);
|
|
574
|
+
function syncBudgetSlider(inputId, value) {
|
|
575
|
+
const input = document.getElementById(inputId);
|
|
576
|
+
if (input) input.value = value;
|
|
577
|
+
onBudgetChange();
|
|
435
578
|
}
|
|
436
579
|
|
|
437
|
-
|
|
438
|
-
const
|
|
580
|
+
function onBudgetChange() {
|
|
581
|
+
const daily = parseFloat(document.getElementById('budgetDailyCap')?.value) || 0;
|
|
582
|
+
const weekly = parseFloat(document.getElementById('budgetWeeklyAlert')?.value) || 0;
|
|
583
|
+
const monthly = parseFloat(document.getElementById('budgetMonthly')?.value) || 0;
|
|
584
|
+
const autoPause = parseFloat(document.getElementById('budgetAutoPause')?.value) || 0;
|
|
585
|
+
|
|
586
|
+
budgetState.dailyCap = daily;
|
|
587
|
+
budgetState.weeklyAlert = weekly;
|
|
588
|
+
budgetState.monthly = monthly;
|
|
589
|
+
budgetState.autoPause = autoPause;
|
|
439
590
|
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
591
|
+
// Sync sliders
|
|
592
|
+
setInputVal('budgetDailyCapSlider', daily);
|
|
593
|
+
setInputVal('budgetWeeklyAlertSlider', weekly);
|
|
594
|
+
setInputVal('budgetMonthlySlider', monthly);
|
|
595
|
+
setInputVal('budgetAutoPauseSlider', autoPause);
|
|
596
|
+
|
|
597
|
+
updateBudgetImpact();
|
|
445
598
|
}
|
|
446
599
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
600
|
+
function updateBudgetImpact() {
|
|
601
|
+
const grid = document.getElementById('budgetImpactGrid');
|
|
602
|
+
if (!grid) return;
|
|
603
|
+
|
|
604
|
+
const d = analysisData;
|
|
605
|
+
const current = d?.monthly_projected_current || 0;
|
|
606
|
+
const daily = budgetState.dailyCap || 0;
|
|
607
|
+
const monthly = budgetState.monthly || 0;
|
|
608
|
+
const weeklyPct = budgetState.weeklyAlert || 75;
|
|
609
|
+
const autoPausePct = budgetState.autoPause || 95;
|
|
610
|
+
|
|
611
|
+
const weeklyBudget = monthly / 4.33;
|
|
612
|
+
const weeklyAlertAt = weeklyBudget * (weeklyPct / 100);
|
|
613
|
+
const autoPauseAt = monthly * (autoPausePct / 100);
|
|
614
|
+
const maxDailySpend = daily * 30;
|
|
615
|
+
const effectiveCap = Math.min(monthly, maxDailySpend);
|
|
616
|
+
const headroom = effectiveCap - current;
|
|
617
|
+
|
|
618
|
+
grid.innerHTML = `
|
|
619
|
+
<div class="impact-item">
|
|
620
|
+
<div class="impact-label">Max daily spend</div>
|
|
621
|
+
<div class="impact-value">$${daily.toFixed(2)}</div>
|
|
622
|
+
</div>
|
|
623
|
+
<div class="impact-item">
|
|
624
|
+
<div class="impact-label">Weekly alert at</div>
|
|
625
|
+
<div class="impact-value">$${weeklyAlertAt.toFixed(2)} <small>(${weeklyPct}%)</small></div>
|
|
626
|
+
</div>
|
|
627
|
+
<div class="impact-item">
|
|
628
|
+
<div class="impact-label">Auto-pause at</div>
|
|
629
|
+
<div class="impact-value">$${autoPauseAt.toFixed(2)} <small>(${autoPausePct}%)</small></div>
|
|
630
|
+
</div>
|
|
631
|
+
<div class="impact-item ${headroom >= 0 ? 'impact-safe' : 'impact-warning'}">
|
|
632
|
+
<div class="impact-label">Budget headroom</div>
|
|
633
|
+
<div class="impact-value">${headroom >= 0 ? '+' : ''}$${headroom.toFixed(2)}/mo</div>
|
|
634
|
+
</div>
|
|
635
|
+
`;
|
|
471
636
|
}
|
|
472
637
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
<p>This is the configuration that will be applied to ~/.openclaw/openclaw.json</p>
|
|
500
|
-
<pre>${JSON.stringify(data.config, null, 2)}</pre>
|
|
501
|
-
</body>
|
|
502
|
-
</html>
|
|
503
|
-
`);
|
|
504
|
-
showToast('Config preview opened in new window', 'success');
|
|
505
|
-
} else {
|
|
506
|
-
showToast(data.error || 'Failed to load preview', 'error');
|
|
638
|
+
function resetBudgetDefaults() {
|
|
639
|
+
const d = analysisData;
|
|
640
|
+
const defaults = d?.budget_defaults || {};
|
|
641
|
+
budgetState = {
|
|
642
|
+
initialized: true,
|
|
643
|
+
dailyCap: defaults.daily_cap || 2.40,
|
|
644
|
+
weeklyAlert: defaults.weekly_alert_pct || 75,
|
|
645
|
+
monthly: defaults.monthly_budget || 40,
|
|
646
|
+
autoPause: defaults.auto_pause_pct || 95
|
|
647
|
+
};
|
|
648
|
+
updateBudgetControls();
|
|
649
|
+
showToast('Budget controls reset to defaults');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function applyBudgetControls() {
|
|
653
|
+
try {
|
|
654
|
+
const res = await fetch(`${API_BASE_URL}/api/apply`, {
|
|
655
|
+
method: 'POST',
|
|
656
|
+
headers: { 'Content-Type': 'application/json' },
|
|
657
|
+
body: JSON.stringify({
|
|
658
|
+
confirm: true,
|
|
659
|
+
budget: {
|
|
660
|
+
daily: budgetState.dailyCap,
|
|
661
|
+
weekly_alert_pct: budgetState.weeklyAlert,
|
|
662
|
+
monthly: budgetState.monthly,
|
|
663
|
+
auto_pause_pct: budgetState.autoPause
|
|
507
664
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
665
|
+
})
|
|
666
|
+
});
|
|
667
|
+
const json = await res.json();
|
|
668
|
+
if (json.success) {
|
|
669
|
+
showToast('✅ Budget controls saved!');
|
|
670
|
+
} else {
|
|
671
|
+
showToast(`Error: ${json.error || 'Save failed'}`);
|
|
511
672
|
}
|
|
673
|
+
} catch {
|
|
674
|
+
// Offline mode — save to local state
|
|
675
|
+
showToast('✅ Budget controls saved locally (API offline)');
|
|
676
|
+
}
|
|
512
677
|
}
|
|
513
678
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
679
|
+
function renderRecDetails(details) {
|
|
680
|
+
if (!details.length) return '';
|
|
681
|
+
return `
|
|
682
|
+
<div class="rec-details">
|
|
683
|
+
${details.map(d => `
|
|
684
|
+
<div class="rec-detail-item">
|
|
685
|
+
<div class="rec-detail-value">${escHtml(d)}</div>
|
|
686
|
+
</div>`).join('')}
|
|
687
|
+
</div>`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function getImpactClass(impact) {
|
|
691
|
+
if (!impact) return 'impact-low';
|
|
692
|
+
const s = impact.toLowerCase();
|
|
693
|
+
if (s.includes('$') && parseInt(s.replace(/[^0-9]/g, '')) >= 20) return 'impact-high';
|
|
694
|
+
if (s.includes('$') && parseInt(s.replace(/[^0-9]/g, '')) >= 10) return 'impact-medium';
|
|
695
|
+
if (s.includes('prevent') || s.includes('control')) return 'impact-medium';
|
|
696
|
+
return 'impact-low';
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function toggleRecExpand(i) {
|
|
700
|
+
const card = document.querySelector(`.rec-card[data-index="${i}"]`);
|
|
701
|
+
if (!card) return;
|
|
702
|
+
card.classList.toggle('expanded');
|
|
703
|
+
if (!editedRecommendations[i]) editedRecommendations[i] = {};
|
|
704
|
+
editedRecommendations[i].expanded = card.classList.contains('expanded');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function toggleRecSelection(i) {
|
|
708
|
+
if (!editedRecommendations[i]) editedRecommendations[i] = {};
|
|
709
|
+
editedRecommendations[i].selected = !editedRecommendations[i].selected;
|
|
710
|
+
const card = document.querySelector(`.rec-card[data-index="${i}"]`);
|
|
711
|
+
if (card) card.classList.toggle('selected', editedRecommendations[i].selected);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function toggleRecEdit(i) {
|
|
715
|
+
const card = document.querySelector(`.rec-card[data-index="${i}"]`);
|
|
716
|
+
if (!card) return;
|
|
717
|
+
if (!editedRecommendations[i]) editedRecommendations[i] = {};
|
|
718
|
+
const isEditing = !editedRecommendations[i].editing;
|
|
719
|
+
editedRecommendations[i].editing = isEditing;
|
|
720
|
+
card.classList.toggle('editing', isEditing);
|
|
721
|
+
// Also expand if not already
|
|
722
|
+
if (isEditing && !card.classList.contains('expanded')) {
|
|
723
|
+
card.classList.add('expanded');
|
|
724
|
+
editedRecommendations[i].expanded = true;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function saveRecEdit(i) {
|
|
729
|
+
const textarea = document.getElementById(`recEdit${i}`);
|
|
730
|
+
if (!textarea) return;
|
|
731
|
+
if (!editedRecommendations[i]) editedRecommendations[i] = {};
|
|
732
|
+
editedRecommendations[i].description = textarea.value;
|
|
733
|
+
editedRecommendations[i].editing = false;
|
|
734
|
+
updateRecommendations();
|
|
735
|
+
showToast('Recommendation updated');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function cancelRecEdit(i) {
|
|
739
|
+
if (!editedRecommendations[i]) editedRecommendations[i] = {};
|
|
740
|
+
editedRecommendations[i].editing = false;
|
|
741
|
+
// Reset textarea to last saved description
|
|
742
|
+
const textarea = document.getElementById(`recEdit${i}`);
|
|
743
|
+
const orig = editedRecommendations[i].description ?? analysisData.recommendations[i]?.description ?? '';
|
|
744
|
+
if (textarea) textarea.value = orig;
|
|
745
|
+
updateRecommendations();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function recDirty(i) {
|
|
749
|
+
// Mark as dirty — visual feedback could go here
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function resetAllEdits() {
|
|
753
|
+
editedRecommendations = {};
|
|
754
|
+
updateRecommendations();
|
|
755
|
+
showToast('All edits reset');
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function applySelectedRecommendations() {
|
|
759
|
+
const selected = Object.entries(editedRecommendations)
|
|
760
|
+
.filter(([_, v]) => v.selected)
|
|
761
|
+
.map(([i]) => parseInt(i));
|
|
762
|
+
|
|
763
|
+
if (selected.length === 0) {
|
|
764
|
+
showToast('Select at least one recommendation to apply');
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const confirmed = confirm(`Apply ${selected.length} selected recommendation(s)? This will update your OpenClaw configuration.`);
|
|
769
|
+
if (!confirmed) return;
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
const res = await fetch(`${API_BASE_URL}/api/apply`, {
|
|
773
|
+
method: 'POST',
|
|
774
|
+
headers: { 'Content-Type': 'application/json' },
|
|
775
|
+
body: JSON.stringify({ confirm: true })
|
|
776
|
+
});
|
|
777
|
+
const json = await res.json();
|
|
778
|
+
if (json.success) {
|
|
779
|
+
showToast(`✅ ${selected.length} optimization(s) applied!`);
|
|
780
|
+
// Uncheck applied items
|
|
781
|
+
selected.forEach(i => {
|
|
782
|
+
if (editedRecommendations[i]) editedRecommendations[i].selected = false;
|
|
783
|
+
});
|
|
784
|
+
updateRecommendations();
|
|
785
|
+
} else {
|
|
786
|
+
showToast(`Error: ${json.error || 'Apply failed'}`);
|
|
546
787
|
}
|
|
788
|
+
} catch (err) {
|
|
789
|
+
showToast(`Network error: ${err.message}`);
|
|
790
|
+
}
|
|
547
791
|
}
|
|
548
792
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
793
|
+
/* ─── Model Details ─── */
|
|
794
|
+
function updateModelDetails() {
|
|
795
|
+
const container = document.getElementById('modelDetailsCard');
|
|
796
|
+
if (!container) return;
|
|
552
797
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
798
|
+
const models = analysisData.model_breakdown || [];
|
|
799
|
+
if (models.length === 0) {
|
|
800
|
+
container.innerHTML = '<div class="empty-state"><p>No model data available yet.</p></div>';
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const totalCost = models.reduce((s, m) => s + m.cost, 0);
|
|
805
|
+
container.innerHTML = `
|
|
806
|
+
<table class="model-table">
|
|
807
|
+
<thead>
|
|
808
|
+
<tr>
|
|
809
|
+
<th>Model</th>
|
|
810
|
+
<th>Tasks</th>
|
|
811
|
+
<th>Cost</th>
|
|
812
|
+
<th>Avg/Task</th>
|
|
813
|
+
<th>Share</th>
|
|
814
|
+
</tr>
|
|
815
|
+
</thead>
|
|
816
|
+
<tbody>
|
|
817
|
+
${models.map(m => {
|
|
818
|
+
const share = totalCost > 0 ? ((m.cost / totalCost) * 100).toFixed(1) : '0.0';
|
|
819
|
+
return `<tr>
|
|
820
|
+
<td><span class="model-name">${escHtml(m.model)}</span></td>
|
|
821
|
+
<td>${m.tasks}</td>
|
|
822
|
+
<td>$${m.cost.toFixed(2)}</td>
|
|
823
|
+
<td>$${m.avg_cost_per_task.toFixed(3)}</td>
|
|
824
|
+
<td>${share}%</td>
|
|
825
|
+
</tr>`;
|
|
826
|
+
}).join('')}
|
|
827
|
+
</tbody>
|
|
828
|
+
</table>`;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/* ─── OpenRouter Integration ─── */
|
|
556
832
|
async function checkOpenRouterConfig() {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const data = await response.json();
|
|
564
|
-
openRouterConfigured = data.configured;
|
|
565
|
-
|
|
566
|
-
if (openRouterConfigured) {
|
|
567
|
-
await fetchOpenRouterUsage();
|
|
568
|
-
} else {
|
|
569
|
-
// Show configure prompt
|
|
570
|
-
updateOpenRouterDisplay({ configured: false });
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
} catch (error) {
|
|
574
|
-
console.log('OpenRouter config check failed (API server may not be running):', error.message);
|
|
575
|
-
// Still show section with configuration prompt
|
|
576
|
-
updateOpenRouterDisplay({ configured: false });
|
|
833
|
+
try {
|
|
834
|
+
const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`);
|
|
835
|
+
if (!res.ok) return;
|
|
836
|
+
const json = await res.json();
|
|
837
|
+
if (json.configured) {
|
|
838
|
+
fetchOpenRouterUsage();
|
|
577
839
|
}
|
|
840
|
+
} catch {
|
|
841
|
+
// API not available — continue in static mode
|
|
842
|
+
}
|
|
578
843
|
}
|
|
579
844
|
|
|
580
|
-
/**
|
|
581
|
-
* Fetch actual OpenRouter usage from API
|
|
582
|
-
*/
|
|
583
845
|
async function fetchOpenRouterUsage() {
|
|
846
|
+
const container = document.getElementById('openRouterContent');
|
|
847
|
+
try {
|
|
848
|
+
const res = await fetch(`${API_BASE_URL}/api/openrouter-usage`);
|
|
849
|
+
if (!res.ok) return;
|
|
850
|
+
const json = await res.json();
|
|
851
|
+
if (json.success && json.configured) {
|
|
852
|
+
const usage = json.data || json;
|
|
853
|
+
container.innerHTML = `
|
|
854
|
+
<div class="or-stats-grid">
|
|
855
|
+
<div class="or-stat-card">
|
|
856
|
+
<div class="or-stat-label">Usage (USD)</div>
|
|
857
|
+
<div class="or-stat-value">$${(usage.usage || 0).toFixed(2)}</div>
|
|
858
|
+
</div>
|
|
859
|
+
<div class="or-stat-card">
|
|
860
|
+
<div class="or-stat-label">Limit</div>
|
|
861
|
+
<div class="or-stat-value">$${(usage.limit || 0).toFixed(2)}</div>
|
|
862
|
+
</div>
|
|
863
|
+
<div class="or-stat-card">
|
|
864
|
+
<div class="or-stat-label">Remaining</div>
|
|
865
|
+
<div class="or-stat-value">$${((usage.limit || 0) - (usage.usage || 0)).toFixed(2)}</div>
|
|
866
|
+
</div>
|
|
867
|
+
<div class="or-stat-card">
|
|
868
|
+
<div class="or-stat-label">Rate Limit</div>
|
|
869
|
+
<div class="or-stat-value">${usage.rate_limit?.requests || '--'}/s</div>
|
|
870
|
+
</div>
|
|
871
|
+
</div>`;
|
|
872
|
+
}
|
|
873
|
+
} catch {
|
|
874
|
+
// noop
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/* ─── Config Modal ─── */
|
|
879
|
+
function openConfigModal() {
|
|
880
|
+
document.getElementById('configModal').style.display = 'flex';
|
|
881
|
+
const input = document.getElementById('apiKeyInput');
|
|
882
|
+
const stored = localStorage.getItem('smartmeter_openrouter_key');
|
|
883
|
+
if (stored && !input.value) input.value = stored;
|
|
884
|
+
input.focus();
|
|
885
|
+
const status = document.getElementById('configStatus');
|
|
886
|
+
status.className = 'config-status';
|
|
887
|
+
status.style.removeProperty('display');
|
|
888
|
+
}
|
|
889
|
+
function closeConfigModal() {
|
|
890
|
+
document.getElementById('configModal').style.display = 'none';
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async function saveApiKey() {
|
|
894
|
+
const key = document.getElementById('apiKeyInput').value.trim();
|
|
895
|
+
const status = document.getElementById('configStatus');
|
|
896
|
+
|
|
897
|
+
function showStatus(msg, type) {
|
|
898
|
+
status.textContent = msg;
|
|
899
|
+
status.className = 'config-status ' + type;
|
|
900
|
+
status.style.removeProperty('display');
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (!key) {
|
|
904
|
+
showStatus('Please enter an API key.', 'error');
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (!key.startsWith('sk-or-')) {
|
|
909
|
+
showStatus('Invalid format — key should start with sk-or-', 'error');
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
showStatus('Validating…', 'validating');
|
|
914
|
+
|
|
915
|
+
let validated = false;
|
|
916
|
+
let errorMsg = '';
|
|
917
|
+
|
|
918
|
+
// Try API server first
|
|
919
|
+
try {
|
|
920
|
+
const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`, {
|
|
921
|
+
method: 'POST',
|
|
922
|
+
headers: { 'Content-Type': 'application/json' },
|
|
923
|
+
body: JSON.stringify({ apiKey: key })
|
|
924
|
+
});
|
|
925
|
+
const json = await res.json();
|
|
926
|
+
if (json.success) {
|
|
927
|
+
validated = true;
|
|
928
|
+
} else {
|
|
929
|
+
errorMsg = json.error || 'Validation failed';
|
|
930
|
+
}
|
|
931
|
+
} catch (_) {
|
|
932
|
+
// API server not available — validate directly against OpenRouter
|
|
584
933
|
try {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
934
|
+
const res = await fetch('https://openrouter.ai/api/v1/auth/key', {
|
|
935
|
+
headers: { 'Authorization': `Bearer ${key}` }
|
|
936
|
+
});
|
|
937
|
+
if (res.ok) {
|
|
938
|
+
const data = await res.json();
|
|
939
|
+
validated = !!(data && data.data);
|
|
940
|
+
if (!validated) errorMsg = 'Key not recognized by OpenRouter';
|
|
941
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
942
|
+
errorMsg = 'Invalid API key — authentication failed';
|
|
943
|
+
} else {
|
|
944
|
+
errorMsg = `OpenRouter returned status ${res.status}`;
|
|
945
|
+
}
|
|
946
|
+
} catch (e2) {
|
|
947
|
+
errorMsg = 'Could not reach OpenRouter to validate — check your connection';
|
|
597
948
|
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (validated) {
|
|
952
|
+
localStorage.setItem('smartmeter_openrouter_key', key);
|
|
953
|
+
showStatus('✅ API key saved and validated!', 'success');
|
|
954
|
+
setTimeout(() => {
|
|
955
|
+
closeConfigModal();
|
|
956
|
+
fetchOpenRouterUsage();
|
|
957
|
+
navigateTo('openrouter');
|
|
958
|
+
}, 1200);
|
|
959
|
+
} else {
|
|
960
|
+
showStatus(`❌ ${errorMsg}`, 'error');
|
|
961
|
+
}
|
|
598
962
|
}
|
|
599
963
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
<button class="btn btn-primary" onclick="openConfigModal()">Configure API Key</button>
|
|
611
|
-
</div>
|
|
612
|
-
`;
|
|
613
|
-
return;
|
|
964
|
+
/* ─── Preview Modal ─── */
|
|
965
|
+
async function viewConfig() {
|
|
966
|
+
try {
|
|
967
|
+
const res = await fetch(`${API_BASE_URL}/api/preview`);
|
|
968
|
+
const json = await res.json();
|
|
969
|
+
if (json.success) {
|
|
970
|
+
document.getElementById('previewConfigCode').textContent = JSON.stringify(json.config, null, 2);
|
|
971
|
+
document.getElementById('previewModal').style.display = 'flex';
|
|
972
|
+
} else {
|
|
973
|
+
showToast(`Error: ${json.error || 'No config available'}`);
|
|
614
974
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
975
|
+
} catch (err) {
|
|
976
|
+
showToast(`Preview failed: ${err.message}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function closePreviewModal() {
|
|
980
|
+
document.getElementById('previewModal').style.display = 'none';
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function applyOptimizations() {
|
|
984
|
+
const confirmed = confirm('Apply all optimizations? A backup will be created.');
|
|
985
|
+
if (!confirmed) return;
|
|
986
|
+
try {
|
|
987
|
+
const res = await fetch(`${API_BASE_URL}/api/apply`, {
|
|
988
|
+
method: 'POST',
|
|
989
|
+
headers: { 'Content-Type': 'application/json' },
|
|
990
|
+
body: JSON.stringify({ confirm: true })
|
|
991
|
+
});
|
|
992
|
+
const json = await res.json();
|
|
993
|
+
if (json.success) {
|
|
994
|
+
showToast('✅ Optimizations applied! Backup created.');
|
|
995
|
+
closePreviewModal();
|
|
996
|
+
} else {
|
|
997
|
+
showToast(`Error: ${json.error || 'Apply failed'}`);
|
|
624
998
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const accountInfo = data.account || {};
|
|
629
|
-
|
|
630
|
-
content.innerHTML = `
|
|
631
|
-
<div class="openrouter-data">
|
|
632
|
-
<div class="usage-grid">
|
|
633
|
-
<div class="usage-item">
|
|
634
|
-
<div class="usage-label">Account</div>
|
|
635
|
-
<div class="usage-value">${accountInfo.label || 'Unknown'}</div>
|
|
636
|
-
</div>
|
|
637
|
-
<div class="usage-item">
|
|
638
|
-
<div class="usage-label">Total Spent</div>
|
|
639
|
-
<div class="usage-value highlight">${totalSpent}</div>
|
|
640
|
-
</div>
|
|
641
|
-
<div class="usage-item">
|
|
642
|
-
<div class="usage-label">Usage Balance</div>
|
|
643
|
-
<div class="usage-value">${accountInfo.usageBalance !== null ? `$${(accountInfo.usageBalance / 100).toFixed(2)}` : 'N/A'}</div>
|
|
644
|
-
</div>
|
|
645
|
-
<div class="usage-item">
|
|
646
|
-
<div class="usage-label">Account Type</div>
|
|
647
|
-
<div class="usage-value">${accountInfo.isFreeTier ? 'Free Tier' : 'Paid'}</div>
|
|
648
|
-
</div>
|
|
649
|
-
</div>
|
|
650
|
-
${getComparisonHtml(data)}
|
|
651
|
-
<div class="openrouter-footer">
|
|
652
|
-
<small>Last updated: ${new Date(data.timestamp).toLocaleString()}</small>
|
|
653
|
-
<button class="btn-link" onclick="fetchOpenRouterUsage()">🔄 Refresh</button>
|
|
654
|
-
</div>
|
|
655
|
-
</div>
|
|
656
|
-
`;
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
showToast(`Apply failed: ${err.message}`);
|
|
1001
|
+
}
|
|
657
1002
|
}
|
|
658
1003
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (!
|
|
664
|
-
|
|
1004
|
+
/* ─── Export ─── */
|
|
1005
|
+
async function exportReport() {
|
|
1006
|
+
try {
|
|
1007
|
+
const res = await fetch(`${API_BASE_URL}/api/export`);
|
|
1008
|
+
if (!res.ok) throw new Error('Export failed');
|
|
1009
|
+
const blob = await res.blob();
|
|
1010
|
+
const url = URL.createObjectURL(blob);
|
|
1011
|
+
const a = document.createElement('a');
|
|
1012
|
+
a.href = url;
|
|
1013
|
+
a.download = 'smartmeter-report.md';
|
|
1014
|
+
a.click();
|
|
1015
|
+
URL.revokeObjectURL(url);
|
|
1016
|
+
showToast('📄 Report downloaded');
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
// Fallback: generate from local data
|
|
1019
|
+
if (analysisData) {
|
|
1020
|
+
const md = generateLocalReport(analysisData);
|
|
1021
|
+
const blob = new Blob([md], { type: 'text/markdown' });
|
|
1022
|
+
const url = URL.createObjectURL(blob);
|
|
1023
|
+
const a = document.createElement('a');
|
|
1024
|
+
a.href = url;
|
|
1025
|
+
a.download = 'smartmeter-report.md';
|
|
1026
|
+
a.click();
|
|
1027
|
+
URL.revokeObjectURL(url);
|
|
1028
|
+
showToast('📄 Report exported from local data');
|
|
1029
|
+
} else {
|
|
1030
|
+
showToast('Export failed: no data');
|
|
665
1031
|
}
|
|
666
|
-
|
|
667
|
-
const analyzed = currentData.monthly_projected_current;
|
|
668
|
-
const actual = openRouterData.totalSpent;
|
|
669
|
-
const difference = Math.abs(analyzed - actual);
|
|
670
|
-
const percentDiff = analyzed > 0 ? ((difference / analyzed) * 100).toFixed(1) : 0;
|
|
671
|
-
|
|
672
|
-
return `
|
|
673
|
-
<div class="comparison-section">
|
|
674
|
-
<h4>📊 Comparison</h4>
|
|
675
|
-
<div class="comparison-grid">
|
|
676
|
-
<div class="comparison-item">
|
|
677
|
-
<span class="comparison-label">SmartMeter Analyzed:</span>
|
|
678
|
-
<span class="comparison-value">$${analyzed.toFixed(4)}</span>
|
|
679
|
-
</div>
|
|
680
|
-
<div class="comparison-item">
|
|
681
|
-
<span class="comparison-label">OpenRouter Actual:</span>
|
|
682
|
-
<span class="comparison-value">$${actual.toFixed(4)}</span>
|
|
683
|
-
</div>
|
|
684
|
-
<div class="comparison-item">
|
|
685
|
-
<span class="comparison-label">Difference:</span>
|
|
686
|
-
<span class="comparison-value ${analyzed > actual ? 'positive' : 'negative'}">
|
|
687
|
-
${analyzed > actual ? '-' : '+'}$${difference.toFixed(4)} (${percentDiff}%)
|
|
688
|
-
</span>
|
|
689
|
-
</div>
|
|
690
|
-
</div>
|
|
691
|
-
${analyzed === 0 && actual > 0 ? `
|
|
692
|
-
<div class="comparison-note">
|
|
693
|
-
ℹ️ SmartMeter shows $0 because OpenRouter isn't including cost data in API responses.
|
|
694
|
-
Your actual usage is ${totalSpent} as shown above.
|
|
695
|
-
</div>
|
|
696
|
-
` : ''}
|
|
697
|
-
</div>
|
|
698
|
-
`;
|
|
1032
|
+
}
|
|
699
1033
|
}
|
|
700
1034
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1035
|
+
function generateLocalReport(d) {
|
|
1036
|
+
return `# SmartMeter Cost Analysis Report
|
|
1037
|
+
|
|
1038
|
+
**Generated:** ${new Date().toISOString()}
|
|
1039
|
+
**Period:** ${d.start_date} to ${d.end_date} (${d.days_analyzed} days)
|
|
1040
|
+
|
|
1041
|
+
## Summary
|
|
1042
|
+
- Current Monthly Cost: $${(d.monthly_projected_current || 0).toFixed(2)}
|
|
1043
|
+
- Optimized Monthly Cost: $${(d.monthly_projected_optimized || 0).toFixed(2)}
|
|
1044
|
+
- Potential Savings: $${((d.monthly_projected_current || 0) - (d.monthly_projected_optimized || 0)).toFixed(2)}/month
|
|
1045
|
+
- Total Tasks: ${d.total_tasks}
|
|
1046
|
+
- Cache Hit Rate: ${((d.cache_hit_rate || 0) * 100).toFixed(1)}%
|
|
1047
|
+
|
|
1048
|
+
## Recommendations
|
|
1049
|
+
${(d.recommendations || []).map((r, i) => `${i + 1}. **${r.title}** — ${r.impact}\n ${r.description}`).join('\n\n')}
|
|
1050
|
+
|
|
1051
|
+
---
|
|
1052
|
+
*Generated by SmartMeter*
|
|
1053
|
+
`;
|
|
707
1054
|
}
|
|
708
1055
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1056
|
+
/* ─── Navigation ─── */
|
|
1057
|
+
function navigateTo(section) {
|
|
1058
|
+
// Update nav active state
|
|
1059
|
+
document.querySelectorAll('.nav-item').forEach(el => {
|
|
1060
|
+
el.classList.toggle('active', el.dataset.section === section);
|
|
1061
|
+
});
|
|
1062
|
+
// Toggle sections
|
|
1063
|
+
document.querySelectorAll('.page-section').forEach(el => {
|
|
1064
|
+
el.classList.toggle('active', el.id === `section-${section}`);
|
|
1065
|
+
});
|
|
1066
|
+
// Update page title
|
|
1067
|
+
const titles = { overview: 'Overview', recommendations: 'Recommendations', models: 'Models', openrouter: 'OpenRouter' };
|
|
1068
|
+
setText('pageTitle', titles[section] || section);
|
|
716
1069
|
}
|
|
717
1070
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (!apiKey) {
|
|
726
|
-
statusDiv.innerHTML = '<div class="status-error">⚠️ Please enter an API key</div>';
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
if (!apiKey.startsWith('sk-or-')) {
|
|
731
|
-
statusDiv.innerHTML = '<div class="status-error">⚠️ Invalid API key format (should start with "sk-or-")</div>';
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
statusDiv.innerHTML = '<div class="status-loading">⏳ Validating API key...</div>';
|
|
736
|
-
|
|
1071
|
+
function toggleSidebar() {
|
|
1072
|
+
document.getElementById('sidebar').classList.toggle('open');
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/* ─── Auto-Refresh ─── */
|
|
1076
|
+
function startAutoRefresh() {
|
|
1077
|
+
autoRefreshTimer = setInterval(async () => {
|
|
737
1078
|
try {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
const data = await response.json();
|
|
747
|
-
|
|
748
|
-
if (data.success) {
|
|
749
|
-
statusDiv.innerHTML = '<div class="status-success">✅ API key saved and validated!</div>';
|
|
750
|
-
setTimeout(() => {
|
|
751
|
-
closeConfigModal();
|
|
752
|
-
window.location.reload(); // Reload to show OpenRouter section
|
|
753
|
-
}, 1500);
|
|
754
|
-
} else {
|
|
755
|
-
statusDiv.innerHTML = `<div class="status-error">❌ ${data.error || 'Validation failed'}</div>`;
|
|
1079
|
+
const res = await fetch('analysis.public.json', { cache: 'no-store' });
|
|
1080
|
+
if (res.ok) {
|
|
1081
|
+
const newData = await res.json();
|
|
1082
|
+
if (JSON.stringify(newData) !== JSON.stringify(analysisData)) {
|
|
1083
|
+
analysisData = newData;
|
|
1084
|
+
renderAll();
|
|
756
1085
|
}
|
|
757
|
-
|
|
758
|
-
|
|
1086
|
+
}
|
|
1087
|
+
} catch {
|
|
1088
|
+
// silent
|
|
1089
|
+
}
|
|
1090
|
+
}, 5000);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function refreshDashboard() {
|
|
1094
|
+
await loadAnalysisData();
|
|
1095
|
+
showToast('Dashboard refreshed');
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/* ─── Cost Data Notice ─── */
|
|
1099
|
+
function checkCostDataNotice() {
|
|
1100
|
+
const d = analysisData;
|
|
1101
|
+
if (!d) return;
|
|
1102
|
+
const hasCostData = (d.monthly_projected_current || 0) > 0;
|
|
1103
|
+
const notice = document.getElementById('costDataNotice');
|
|
1104
|
+
if (notice) {
|
|
1105
|
+
// Only show when cost data is truly missing — keep hidden otherwise
|
|
1106
|
+
if (!hasCostData) {
|
|
1107
|
+
notice.style.display = 'flex';
|
|
759
1108
|
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/* ─── Helpers ─── */
|
|
1113
|
+
function setText(id, html) {
|
|
1114
|
+
const el = document.getElementById(id);
|
|
1115
|
+
if (el) el.innerHTML = html;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function escHtml(str) {
|
|
1119
|
+
if (!str) return '';
|
|
1120
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
760
1121
|
}
|
|
761
1122
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1123
|
+
function updateLastUpdated() {
|
|
1124
|
+
const el = document.getElementById('lastUpdated');
|
|
1125
|
+
if (el) el.textContent = new Date().toLocaleTimeString();
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function hideLoading() {
|
|
1129
|
+
const ov = document.getElementById('loadingOverlay');
|
|
1130
|
+
if (ov) {
|
|
1131
|
+
ov.classList.add('hidden');
|
|
1132
|
+
setTimeout(() => ov.remove(), 400);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function showToast(msg, duration = 3000) {
|
|
1137
|
+
const el = document.getElementById('toast');
|
|
1138
|
+
if (!el) return;
|
|
1139
|
+
el.textContent = msg;
|
|
1140
|
+
el.classList.add('show');
|
|
1141
|
+
clearTimeout(el._timer);
|
|
1142
|
+
el._timer = setTimeout(() => el.classList.remove('show'), duration);
|
|
1143
|
+
}
|