openclaw-smartmeter 0.5.1 → 0.5.3

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.
@@ -5,6 +5,7 @@
5
5
 
6
6
  /* ─── Globals ─── */
7
7
  const API_BASE_URL = `http://localhost:${window.__SMARTMETER_API_PORT || 3001}`;
8
+ const STORAGE_PREFIX = window.__SMARTMETER_USER ? `smartmeter_${window.__SMARTMETER_USER}_` : 'smartmeter_';
8
9
  let analysisData = null;
9
10
  let modelChart = null;
10
11
  let taskChart = null;
@@ -123,6 +124,7 @@ function renderAll() {
123
124
  safe(updateModelDetails);
124
125
  safe(updateLastUpdated);
125
126
  safe(checkCostDataNotice);
127
+ safe(() => populateStepPanel(activeStep));
126
128
  }
127
129
 
128
130
  /* ─── KPIs ─── */
@@ -900,7 +902,7 @@ async function validateInlineApiKey() {
900
902
  btn.textContent = 'Save & Validate';
901
903
 
902
904
  if (validated) {
903
- localStorage.setItem('smartmeter_openrouter_key', key);
905
+ localStorage.setItem(STORAGE_PREFIX + 'openrouter_key', key);
904
906
  showStatus('✅ API key saved and validated!', 'success');
905
907
 
906
908
  // Show balance section, hide key input
@@ -958,7 +960,7 @@ function showApiKeyInput() {
958
960
 
959
961
  /** On init, check if key is already stored and auto-show balance */
960
962
  async function initGetStartedCard() {
961
- const stored = localStorage.getItem('smartmeter_openrouter_key');
963
+ const stored = localStorage.getItem(STORAGE_PREFIX + 'openrouter_key');
962
964
  if (!stored) return;
963
965
 
964
966
  // Pre-fill the input
@@ -991,6 +993,228 @@ async function initGetStartedCard() {
991
993
  }
992
994
  }
993
995
 
996
+ /* ─── Step Panels (Analyze / Evaluate / Guide) ─── */
997
+ let activeStep = 1;
998
+
999
+ function activateStep(n) {
1000
+ activeStep = n;
1001
+ // Toggle active class on step elements
1002
+ for (let i = 1; i <= 3; i++) {
1003
+ const step = document.getElementById('gsStep' + i);
1004
+ if (step) step.classList.toggle('gs-step-active', i === n);
1005
+ }
1006
+ // Show/hide panels
1007
+ for (let i = 1; i <= 3; i++) {
1008
+ const panel = document.getElementById('gsPanel' + i);
1009
+ if (panel) panel.style.display = (i === n) ? '' : 'none';
1010
+ }
1011
+ // Populate panel content
1012
+ populateStepPanel(n);
1013
+ // Update contextual info banner
1014
+ updateContextualBanner(n);
1015
+ }
1016
+
1017
+ function populateStepPanel(n) {
1018
+ const d = analysisData;
1019
+ if (n === 1) {
1020
+ const panel = document.getElementById('gsPanel1');
1021
+ if (!panel) return;
1022
+ if (!d) {
1023
+ panel.innerHTML = '<p class="panel-empty">Run an analysis to see your usage summary here.</p>';
1024
+ return;
1025
+ }
1026
+ const days = d.days_analyzed || 0;
1027
+ const tasks = d.total_tasks || 0;
1028
+ const current = d.monthly_projected_current || 0;
1029
+ const optimized = d.monthly_projected_optimized || 0;
1030
+ const models = (d.model_breakdown || []).length;
1031
+ panel.innerHTML = `
1032
+ <h4>Analysis Summary</h4>
1033
+ <p>${days} day${days !== 1 ? 's' : ''} analyzed &middot; ${tasks} task${tasks !== 1 ? 's' : ''} &middot; ${models} model${models !== 1 ? 's' : ''} detected</p>
1034
+ <table>
1035
+ <tr><td>Projected Monthly Cost</td><td style="text-align:right;font-weight:600">$${current.toFixed(2)}</td></tr>
1036
+ <tr><td>Optimized Projection</td><td style="text-align:right;font-weight:600;color:var(--green)">$${optimized.toFixed(2)}</td></tr>
1037
+ <tr><td>Potential Savings</td><td style="text-align:right;font-weight:600;color:var(--accent)">$${(current - optimized).toFixed(2)}</td></tr>
1038
+ </table>`;
1039
+ } else if (n === 2) {
1040
+ const panel = document.getElementById('gsPanel2');
1041
+ if (!panel) return;
1042
+ const models = d ? (d.model_breakdown || []) : [];
1043
+ if (models.length === 0) {
1044
+ panel.innerHTML = '<p class="panel-empty">No model data available yet. Run an analysis first.</p>';
1045
+ return;
1046
+ }
1047
+ const totalCost = models.reduce((s, m) => s + m.cost, 0);
1048
+ panel.innerHTML = `
1049
+ <h4>Model Cost Breakdown</h4>
1050
+ <table>
1051
+ <thead><tr>
1052
+ <th>Model</th><th>Tasks</th><th>Cost</th><th>Avg/Task</th><th>Share</th>
1053
+ </tr></thead>
1054
+ <tbody>
1055
+ ${models.map(m => {
1056
+ const share = totalCost > 0 ? ((m.cost / totalCost) * 100).toFixed(1) : '0.0';
1057
+ return `<tr>
1058
+ <td>${escHtml(m.model)}</td>
1059
+ <td>${m.tasks}</td>
1060
+ <td>$${m.cost.toFixed(2)}</td>
1061
+ <td>$${m.avg_cost_per_task.toFixed(3)}</td>
1062
+ <td>${share}%</td>
1063
+ </tr>`;
1064
+ }).join('')}
1065
+ </tbody>
1066
+ </table>`;
1067
+ } else if (n === 3) {
1068
+ const panel = document.getElementById('gsPanel3');
1069
+ if (!panel) return;
1070
+ const recs = d ? (d.recommendations || []) : [];
1071
+ if (recs.length === 0) {
1072
+ panel.innerHTML = '<p class="panel-empty">No recommendations available yet. Run an analysis first.</p>';
1073
+ return;
1074
+ }
1075
+ panel.innerHTML = `
1076
+ <h4>Recommendations</h4>
1077
+ <p>Toggle on the optimizations you want to activate, then hit Apply.</p>
1078
+ <div class="gs-rec-list">
1079
+ ${recs.map((r, i) => {
1080
+ const impactClass = (r.impact || '').toLowerCase().includes('high') ? 'high'
1081
+ : (r.impact || '').toLowerCase().includes('medium') ? 'medium' : 'low';
1082
+ const checked = editedRecommendations[i] && editedRecommendations[i].selected ? 'checked' : '';
1083
+ return `<label class="gs-rec-row" data-index="${i}">
1084
+ <input type="checkbox" class="gs-rec-toggle" ${checked} onchange="toggleGuideRec(${i}, this.checked)">
1085
+ <div class="gs-rec-body">
1086
+ <div class="gs-rec-header">
1087
+ <span class="rec-title">${escHtml(r.title)}</span>
1088
+ ${r.impact ? `<span class="rec-impact ${impactClass}">${escHtml(r.impact)}</span>` : ''}
1089
+ </div>
1090
+ <div class="rec-desc">${escHtml(r.description)}</div>
1091
+ </div>
1092
+ </label>`;
1093
+ }).join('')}
1094
+ </div>
1095
+ <div class="gs-rec-actions">
1096
+ <button class="btn btn-primary" onclick="applyGuideRecommendations()">Apply Selected</button>
1097
+ <span class="gs-rec-count" id="gsRecCount">${Object.values(editedRecommendations).filter(v => v.selected).length} selected</span>
1098
+ </div>`;
1099
+ }
1100
+ }
1101
+
1102
+ function updateContextualBanner(n) {
1103
+ const notice = document.getElementById('costDataNotice');
1104
+ const content = document.getElementById('costDataNoticeContent');
1105
+ if (!notice || !content) return;
1106
+
1107
+ const messages = {
1108
+ 1: {
1109
+ title: 'About Cost Tracking',
1110
+ text: 'Your OpenRouter API responses may not include cost data. SmartMeter still optimizes based on token usage.'
1111
+ },
1112
+ 2: {
1113
+ title: 'Understanding Model Costs',
1114
+ text: 'Costs are based on token usage reported by OpenRouter. Compare models to find cheaper alternatives for similar tasks.'
1115
+ },
1116
+ 3: {
1117
+ title: 'Applying Recommendations',
1118
+ text: 'Select recommendations below to apply them. Changes update your OpenClaw configuration for optimized model routing.'
1119
+ }
1120
+ };
1121
+
1122
+ const msg = messages[n] || messages[1];
1123
+ const d = analysisData;
1124
+ const hasCostData = d && (d.monthly_projected_current || 0) > 0;
1125
+
1126
+ // Show banner contextually: always for step 2/3 if data exists, or step 1 if no cost data
1127
+ if (n === 1 && hasCostData) {
1128
+ notice.style.display = 'none';
1129
+ } else {
1130
+ content.innerHTML = `<strong>${msg.title}</strong><p>${msg.text}</p>`;
1131
+ notice.style.display = 'flex';
1132
+ }
1133
+ }
1134
+
1135
+ /* ─── Guide Panel Actions ─── */
1136
+ function toggleGuideRec(i, on) {
1137
+ if (!editedRecommendations[i]) editedRecommendations[i] = {};
1138
+ editedRecommendations[i].selected = on;
1139
+ // Update count
1140
+ const count = Object.values(editedRecommendations).filter(v => v.selected).length;
1141
+ const el = document.getElementById('gsRecCount');
1142
+ if (el) el.textContent = count + ' selected';
1143
+ // Sync main recommendation cards if they exist
1144
+ const card = document.querySelector(`.rec-card[data-index="${i}"]`);
1145
+ if (card) card.classList.toggle('selected', on);
1146
+ }
1147
+
1148
+ function applyGuideRecommendations() {
1149
+ const selected = Object.entries(editedRecommendations)
1150
+ .filter(([_, v]) => v.selected)
1151
+ .map(([i]) => parseInt(i));
1152
+
1153
+ if (selected.length === 0) {
1154
+ showToast('Toggle at least one recommendation to apply');
1155
+ return;
1156
+ }
1157
+
1158
+ // Build summary list for the modal
1159
+ const recs = analysisData ? (analysisData.recommendations || []) : [];
1160
+ const listEl = document.getElementById('applyModalList');
1161
+ if (listEl) {
1162
+ listEl.innerHTML = selected.map(i => {
1163
+ const r = recs[i];
1164
+ if (!r) return '';
1165
+ const impactClass = (r.impact || '').toLowerCase().includes('high') ? 'high'
1166
+ : (r.impact || '').toLowerCase().includes('medium') ? 'medium' : 'low';
1167
+ return `<div class="apply-modal-item">
1168
+ <span class="apply-modal-dot ${impactClass}"></span>
1169
+ <span>${escHtml(r.title)}</span>
1170
+ ${r.impact ? `<span class="rec-impact ${impactClass}">${escHtml(r.impact)}</span>` : ''}
1171
+ </div>`;
1172
+ }).join('');
1173
+ }
1174
+
1175
+ // Show modal
1176
+ const modal = document.getElementById('applyModal');
1177
+ if (modal) modal.style.display = 'flex';
1178
+ }
1179
+
1180
+ function closeApplyModal() {
1181
+ const modal = document.getElementById('applyModal');
1182
+ if (modal) modal.style.display = 'none';
1183
+ }
1184
+
1185
+ async function confirmApplyRecommendations() {
1186
+ const selected = Object.entries(editedRecommendations)
1187
+ .filter(([_, v]) => v.selected)
1188
+ .map(([i]) => parseInt(i));
1189
+
1190
+ const btn = document.getElementById('applyModalConfirmBtn');
1191
+ if (btn) { btn.disabled = true; btn.textContent = 'Applying…'; }
1192
+
1193
+ try {
1194
+ const res = await fetch(`${API_BASE_URL}/api/apply`, {
1195
+ method: 'POST',
1196
+ headers: { 'Content-Type': 'application/json' },
1197
+ body: JSON.stringify({ confirm: true })
1198
+ });
1199
+ const json = await res.json();
1200
+ if (json.success) {
1201
+ closeApplyModal();
1202
+ showToast(`✅ ${selected.length} optimization(s) applied!`);
1203
+ selected.forEach(i => {
1204
+ if (editedRecommendations[i]) editedRecommendations[i].selected = false;
1205
+ });
1206
+ populateStepPanel(3);
1207
+ updateRecommendations();
1208
+ } else {
1209
+ showToast(`Error: ${json.error || 'Apply failed'}`);
1210
+ }
1211
+ } catch (err) {
1212
+ showToast(`Network error: ${err.message}`);
1213
+ } finally {
1214
+ if (btn) { btn.disabled = false; btn.textContent = 'Apply Now'; }
1215
+ }
1216
+ }
1217
+
994
1218
  /* ─── OpenRouter Integration ─── */
995
1219
  async function checkOpenRouterConfig() {
996
1220
  try {
@@ -1051,7 +1275,7 @@ async function fetchOpenRouterUsage() {
1051
1275
  function openConfigModal() {
1052
1276
  document.getElementById('configModal').style.display = 'flex';
1053
1277
  const input = document.getElementById('apiKeyInput');
1054
- const stored = localStorage.getItem('smartmeter_openrouter_key');
1278
+ const stored = localStorage.getItem(STORAGE_PREFIX + 'openrouter_key');
1055
1279
  if (stored && !input.value) input.value = stored;
1056
1280
  input.focus();
1057
1281
  const status = document.getElementById('configStatus');
@@ -1121,7 +1345,7 @@ async function saveApiKey() {
1121
1345
  }
1122
1346
 
1123
1347
  if (validated) {
1124
- localStorage.setItem('smartmeter_openrouter_key', key);
1348
+ localStorage.setItem(STORAGE_PREFIX + 'openrouter_key', key);
1125
1349
  showStatus('✅ API key saved and validated!', 'success');
1126
1350
  setTimeout(() => {
1127
1351
  closeConfigModal();
@@ -1270,16 +1494,8 @@ async function refreshDashboard() {
1270
1494
 
1271
1495
  /* ─── Cost Data Notice ─── */
1272
1496
  function checkCostDataNotice() {
1273
- const d = analysisData;
1274
- if (!d) return;
1275
- const hasCostData = (d.monthly_projected_current || 0) > 0;
1276
- const notice = document.getElementById('costDataNotice');
1277
- if (notice) {
1278
- // Only show when cost data is truly missing — keep hidden otherwise
1279
- if (!hasCostData) {
1280
- notice.style.display = 'flex';
1281
- }
1282
- }
1497
+ // Delegate to contextual banner system
1498
+ updateContextualBanner(activeStep);
1283
1499
  }
1284
1500
 
1285
1501
  /* ─── Helpers ─── */
@@ -119,21 +119,21 @@
119
119
 
120
120
  <!-- 3-Step Flow -->
121
121
  <div class="gs-steps">
122
- <div class="gs-step" id="gsStep1">
122
+ <div class="gs-step gs-step-active" id="gsStep1" onclick="activateStep(1)">
123
123
  <div class="gs-step-num">1</div>
124
124
  <div>
125
125
  <div class="gs-step-title">Analyze</div>
126
126
  <div class="gs-step-desc">Parse session logs to classify tasks, track costs, and break down model usage.</div>
127
127
  </div>
128
128
  </div>
129
- <div class="gs-step" id="gsStep2">
129
+ <div class="gs-step" id="gsStep2" onclick="activateStep(2)">
130
130
  <div class="gs-step-num">2</div>
131
131
  <div>
132
132
  <div class="gs-step-title">Evaluate</div>
133
133
  <div class="gs-step-desc">Compare models side-by-side — see which cost the most and where cheaper alternatives save money.</div>
134
134
  </div>
135
135
  </div>
136
- <div class="gs-step" id="gsStep3">
136
+ <div class="gs-step" id="gsStep3" onclick="activateStep(3)">
137
137
  <div class="gs-step-num">3</div>
138
138
  <div>
139
139
  <div class="gs-step-title">Guide</div>
@@ -141,12 +141,23 @@
141
141
  </div>
142
142
  </div>
143
143
  </div>
144
+
145
+ <!-- Step Detail Panels -->
146
+ <div class="gs-step-panel" id="gsPanel1">
147
+ <!-- Analyze: shown by default with analysis summary -->
148
+ </div>
149
+ <div class="gs-step-panel" id="gsPanel2" style="display:none">
150
+ <!-- Evaluate: model cost breakdown table -->
151
+ </div>
152
+ <div class="gs-step-panel" id="gsPanel3" style="display:none">
153
+ <!-- Guide: top recommendations -->
154
+ </div>
144
155
  </div>
145
156
 
146
- <!-- Info Banner -->
157
+ <!-- Contextual Info Banner -->
147
158
  <div class="alert alert-info" id="costDataNotice" style="display:none;">
148
159
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
149
- <div>
160
+ <div id="costDataNoticeContent">
150
161
  <strong>About Cost Tracking</strong>
151
162
  <p>Your OpenRouter API responses may not include cost data. SmartMeter still optimizes based on token usage.</p>
152
163
  </div>
@@ -160,7 +171,7 @@
160
171
  <div class="kpi-sub" id="savingsPercentage">--</div>
161
172
  </div>
162
173
  <div class="kpi-card">
163
- <div class="kpi-label">Current Cost</div>
174
+ <div class="kpi-label">Projected Monthly Cost</div>
164
175
  <div class="kpi-value" id="currentCost">$0.00<small>/mo</small></div>
165
176
  </div>
166
177
  <div class="kpi-card kpi-success">
@@ -402,6 +413,31 @@
402
413
  </div>
403
414
  </div>
404
415
 
416
+ <!-- Apply Recommendations Modal -->
417
+ <div id="applyModal" class="modal-overlay" style="display:none">
418
+ <div class="modal">
419
+ <div class="modal-header">
420
+ <h3>Apply Optimizations</h3>
421
+ <button class="btn-icon modal-close" onclick="closeApplyModal()">✕</button>
422
+ </div>
423
+ <div class="modal-body">
424
+ <p class="modal-desc">The following recommendations will be applied to your OpenClaw configuration at <code>~/.openclaw/openclaw.json</code>.</p>
425
+ <div id="applyModalList" class="apply-modal-list"></div>
426
+ <div class="apply-revert-info">
427
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
428
+ <div>
429
+ <strong>Easy to undo</strong>
430
+ <p>To revert, run <code>smartmeter reset</code> in your terminal, or edit <code>~/.openclaw/openclaw.json</code> directly. You can also toggle individual recommendations off here and re-apply.</p>
431
+ </div>
432
+ </div>
433
+ </div>
434
+ <div class="modal-footer">
435
+ <button class="btn btn-ghost" onclick="closeApplyModal()">Cancel</button>
436
+ <button class="btn btn-primary" id="applyModalConfirmBtn" onclick="confirmApplyRecommendations()">Apply Now</button>
437
+ </div>
438
+ </div>
439
+ </div>
440
+
405
441
  <!-- Preview Modal -->
406
442
  <div id="previewModal" class="modal-overlay" style="display:none">
407
443
  <div class="modal modal-lg">
@@ -399,7 +399,12 @@ a:hover { color: var(--accent-hover); }
399
399
  background: var(--bg-surface);
400
400
  border-radius: var(--radius);
401
401
  border: 1px solid var(--border);
402
- transition: border-color .2s;
402
+ transition: border-color .2s, background .2s;
403
+ cursor: pointer;
404
+ }
405
+ .gs-step:hover {
406
+ border-color: var(--border-light);
407
+ background: var(--bg-card-hover);
403
408
  }
404
409
  .gs-step.gs-step-active {
405
410
  border-color: var(--accent);
@@ -437,6 +442,167 @@ a:hover { color: var(--accent-hover); }
437
442
  line-height: 1.5;
438
443
  }
439
444
 
445
+ /* Step detail panels */
446
+ .gs-step-panel {
447
+ margin-top: 16px;
448
+ background: var(--bg-surface);
449
+ border: 1px solid var(--border);
450
+ border-radius: var(--radius);
451
+ padding: 18px;
452
+ animation: fadeIn .25s ease;
453
+ }
454
+ .gs-step-panel h4 {
455
+ font-size: 14px;
456
+ font-weight: 700;
457
+ color: var(--text-primary);
458
+ margin: 0 0 12px 0;
459
+ }
460
+ .gs-step-panel p {
461
+ font-size: 13px;
462
+ color: var(--text-secondary);
463
+ margin: 0 0 12px 0;
464
+ line-height: 1.5;
465
+ }
466
+ .gs-step-panel table {
467
+ width: 100%;
468
+ border-collapse: collapse;
469
+ font-size: 13px;
470
+ }
471
+ .gs-step-panel th {
472
+ text-align: left;
473
+ font-weight: 600;
474
+ color: var(--text-muted);
475
+ padding: 6px 10px;
476
+ border-bottom: 1px solid var(--border);
477
+ font-size: 11px;
478
+ text-transform: uppercase;
479
+ letter-spacing: .5px;
480
+ }
481
+ .gs-step-panel td {
482
+ padding: 8px 10px;
483
+ color: var(--text-primary);
484
+ border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.04));
485
+ }
486
+ .gs-step-panel tr:last-child td {
487
+ border-bottom: none;
488
+ }
489
+ .gs-step-panel .panel-empty {
490
+ color: var(--text-muted);
491
+ font-style: italic;
492
+ font-size: 13px;
493
+ }
494
+ .gs-step-panel .rec-item {
495
+ padding: 10px 0;
496
+ border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.04));
497
+ }
498
+ .gs-step-panel .rec-item:last-child {
499
+ border-bottom: none;
500
+ }
501
+ .gs-step-panel .rec-title {
502
+ font-weight: 600;
503
+ color: var(--text-primary);
504
+ font-size: 13px;
505
+ margin-bottom: 4px;
506
+ }
507
+ .gs-step-panel .rec-desc {
508
+ font-size: 12px;
509
+ color: var(--text-muted);
510
+ line-height: 1.5;
511
+ }
512
+ .gs-step-panel .rec-impact {
513
+ display: inline-block;
514
+ font-size: 11px;
515
+ font-weight: 600;
516
+ padding: 2px 8px;
517
+ border-radius: 10px;
518
+ margin-top: 4px;
519
+ }
520
+ .gs-step-panel .rec-impact.high {
521
+ background: var(--green-subtle);
522
+ color: var(--green);
523
+ }
524
+ .gs-step-panel .rec-impact.medium {
525
+ background: var(--accent-subtle);
526
+ color: var(--accent);
527
+ }
528
+ .gs-step-panel .rec-impact.low {
529
+ background: rgba(255,255,255,0.06);
530
+ color: var(--text-muted);
531
+ }
532
+
533
+ /* Guide panel – recommendation list */
534
+ .gs-rec-list {
535
+ display: flex;
536
+ flex-direction: column;
537
+ gap: 2px;
538
+ }
539
+ .gs-rec-row {
540
+ display: flex;
541
+ align-items: flex-start;
542
+ gap: 12px;
543
+ padding: 12px 14px;
544
+ border-radius: 8px;
545
+ cursor: pointer;
546
+ transition: background .15s;
547
+ }
548
+ .gs-rec-row:hover {
549
+ background: rgba(255,255,255,0.03);
550
+ }
551
+ .gs-rec-toggle {
552
+ appearance: none;
553
+ -webkit-appearance: none;
554
+ width: 38px;
555
+ min-width: 38px;
556
+ height: 22px;
557
+ border-radius: 11px;
558
+ background: var(--border);
559
+ position: relative;
560
+ cursor: pointer;
561
+ transition: background .2s;
562
+ margin-top: 2px;
563
+ }
564
+ .gs-rec-toggle::after {
565
+ content: '';
566
+ position: absolute;
567
+ top: 3px;
568
+ left: 3px;
569
+ width: 16px;
570
+ height: 16px;
571
+ border-radius: 50%;
572
+ background: var(--text-muted);
573
+ transition: transform .2s, background .2s;
574
+ }
575
+ .gs-rec-toggle:checked {
576
+ background: var(--accent);
577
+ }
578
+ .gs-rec-toggle:checked::after {
579
+ transform: translateX(16px);
580
+ background: #fff;
581
+ }
582
+ .gs-rec-body {
583
+ flex: 1;
584
+ min-width: 0;
585
+ }
586
+ .gs-rec-header {
587
+ display: flex;
588
+ align-items: center;
589
+ gap: 8px;
590
+ flex-wrap: wrap;
591
+ margin-bottom: 4px;
592
+ }
593
+ .gs-rec-actions {
594
+ display: flex;
595
+ align-items: center;
596
+ gap: 14px;
597
+ margin-top: 16px;
598
+ padding-top: 14px;
599
+ border-top: 1px solid var(--border);
600
+ }
601
+ .gs-rec-count {
602
+ font-size: 12px;
603
+ color: var(--text-muted);
604
+ }
605
+
440
606
  @media (max-width: 768px) {
441
607
  .gs-steps {
442
608
  grid-template-columns: 1fr;
@@ -924,6 +1090,67 @@ a:hover { color: var(--accent-hover); }
924
1090
  padding: 12px 24px 20px;
925
1091
  }
926
1092
 
1093
+ /* Apply Modal */
1094
+ .apply-modal-list {
1095
+ display: flex;
1096
+ flex-direction: column;
1097
+ gap: 8px;
1098
+ margin-bottom: 16px;
1099
+ max-height: 200px;
1100
+ overflow-y: auto;
1101
+ }
1102
+ .apply-modal-item {
1103
+ display: flex;
1104
+ align-items: center;
1105
+ gap: 10px;
1106
+ padding: 10px 12px;
1107
+ background: var(--bg-card);
1108
+ border: 1px solid var(--border);
1109
+ border-radius: var(--radius-sm);
1110
+ font-size: 13px;
1111
+ color: var(--text-primary);
1112
+ }
1113
+ .apply-modal-dot {
1114
+ flex-shrink: 0;
1115
+ width: 8px;
1116
+ height: 8px;
1117
+ border-radius: 50%;
1118
+ }
1119
+ .apply-modal-dot.high { background: var(--green); }
1120
+ .apply-modal-dot.medium { background: var(--accent); }
1121
+ .apply-modal-dot.low { background: var(--text-muted); }
1122
+ .apply-revert-info {
1123
+ display: flex;
1124
+ gap: 10px;
1125
+ padding: 12px 14px;
1126
+ background: var(--accent-subtle);
1127
+ border: 1px solid rgba(99,102,241,.2);
1128
+ border-radius: var(--radius-sm);
1129
+ font-size: 12px;
1130
+ color: var(--text-secondary);
1131
+ line-height: 1.5;
1132
+ }
1133
+ .apply-revert-info svg {
1134
+ flex-shrink: 0;
1135
+ margin-top: 2px;
1136
+ }
1137
+ .apply-revert-info strong {
1138
+ display: block;
1139
+ color: var(--text-primary);
1140
+ font-size: 13px;
1141
+ margin-bottom: 4px;
1142
+ }
1143
+ .apply-revert-info p {
1144
+ margin: 0;
1145
+ }
1146
+ .apply-revert-info code {
1147
+ background: rgba(255,255,255,.06);
1148
+ padding: 1px 5px;
1149
+ border-radius: 3px;
1150
+ font-size: 11px;
1151
+ font-family: var(--font-mono);
1152
+ }
1153
+
927
1154
  /* Form */
928
1155
  .form-label {
929
1156
  display: block;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-smartmeter",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "AI cost optimization for OpenClaw - analyze usage and reduce costs by 48%",
5
5
  "main": "src/cli/index.js",
6
6
  "bin": {
@@ -92,9 +92,9 @@ export async function cmdAnalyze(opts = {}) {
92
92
  const apiServer = await startApiServer({ port: apiPort });
93
93
  const actualApiPort = apiServer.server.address().port;
94
94
 
95
- // Start static file server in background
95
+ // Start static file server in background (pass API port for injection)
96
96
  console.log("✓ Starting dashboard server...");
97
- const staticServer = await startStaticFileServer(deployer.canvasDir, port);
97
+ const staticServer = await startStaticFileServer(deployer.canvasDir, port, { apiPort: actualApiPort });
98
98
  const actualPort = staticServer.port;
99
99
 
100
100
  const url = `http://localhost:${actualPort}`;
@@ -885,10 +885,15 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
885
885
  return null;
886
886
  }
887
887
 
888
- async function startStaticFileServer(directory, port) {
888
+ async function startStaticFileServer(directory, port, options = {}) {
889
889
  const { createServer } = await import("node:http");
890
890
  const { readFile, stat } = await import("node:fs/promises");
891
891
  const { join, extname } = await import("node:path");
892
+ const { userInfo } = await import("node:os");
893
+
894
+ const apiPort = options.apiPort || 3001;
895
+ const osUser = (() => { try { return userInfo().username; } catch { return ''; } })();
896
+ const portScript = `<script>window.__SMARTMETER_API_PORT=${apiPort};window.__SMARTMETER_USER=${JSON.stringify(osUser)};</script>`;
892
897
 
893
898
  const mimeTypes = {
894
899
  '.html': 'text/html',
@@ -916,10 +921,20 @@ async function startStaticFileServer(directory, port) {
916
921
  }
917
922
 
918
923
  // Read and serve file
919
- const content = await readFile(filePath);
924
+ let content = await readFile(filePath);
920
925
  const ext = extname(filePath);
921
926
  const mimeType = mimeTypes[ext] || 'application/octet-stream';
922
927
 
928
+ // Inject API port and OS username into index.html so dashboard connects
929
+ // to the correct API server and scopes localStorage per user
930
+ if (ext === '.html') {
931
+ let html = content.toString('utf-8');
932
+ html = html.replace('</head>', `${portScript}\n</head>`);
933
+ res.writeHead(200, { 'Content-Type': mimeType });
934
+ res.end(html);
935
+ return;
936
+ }
937
+
923
938
  res.writeHead(200, { 'Content-Type': mimeType });
924
939
  res.end(content);
925
940
  } catch (error) {