lazyclaw 3.99.11 → 3.99.13

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.
@@ -164,6 +164,69 @@
164
164
  border-radius: 6px;
165
165
  font: inherit;
166
166
  }
167
+ .toolbar input[type="text"], .toolbar input[type="search"] {
168
+ background: var(--card);
169
+ border: 1px solid var(--border);
170
+ color: var(--text);
171
+ padding: 6px 10px;
172
+ border-radius: 6px;
173
+ font: inherit;
174
+ min-width: 0;
175
+ }
176
+ .grid {
177
+ display: grid;
178
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
179
+ gap: 10px;
180
+ margin-bottom: 14px;
181
+ }
182
+ .stat {
183
+ background: var(--card);
184
+ border: 1px solid var(--border);
185
+ border-radius: 8px;
186
+ padding: 12px 14px;
187
+ }
188
+ .stat .label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
189
+ .stat .value { font-size: 22px; font-weight: 600; margin-top: 4px; word-break: break-all; }
190
+ .stat .sub { color: var(--dim); font-size: 11px; margin-top: 4px; }
191
+ table.tbl {
192
+ width: 100%;
193
+ border-collapse: collapse;
194
+ background: var(--card);
195
+ border: 1px solid var(--border);
196
+ border-radius: 8px;
197
+ overflow: hidden;
198
+ font-size: 13px;
199
+ }
200
+ table.tbl th, table.tbl td {
201
+ padding: 8px 12px;
202
+ text-align: left;
203
+ border-bottom: 1px solid var(--border);
204
+ vertical-align: top;
205
+ }
206
+ table.tbl th { color: var(--dim); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; background: rgba(255,255,255,0.02); }
207
+ table.tbl tr:last-child td { border-bottom: 0; }
208
+ table.tbl td.num { text-align: right; font-variant-numeric: tabular-nums; }
209
+ .banner {
210
+ padding: 10px 14px;
211
+ border-radius: 8px;
212
+ margin-bottom: 12px;
213
+ border: 1px solid var(--border);
214
+ background: var(--card);
215
+ display: flex;
216
+ align-items: center;
217
+ gap: 10px;
218
+ }
219
+ .banner.ok { border-color: rgba(74, 222, 128, 0.4); background: rgba(74, 222, 128, 0.06); }
220
+ .banner.warn { border-color: rgba(245, 158, 11, 0.4); background: rgba(245, 158, 11, 0.06); }
221
+ .banner.err { border-color: rgba(239, 68, 68, 0.4); background: rgba(239, 68, 68, 0.06); }
222
+ .banner ul { margin: 6px 0 0 18px; padding: 0; }
223
+ .banner li { font-size: 12px; }
224
+ @media (max-width: 480px) {
225
+ main { padding: 14px; }
226
+ .grid { grid-template-columns: 1fr; }
227
+ table.tbl { font-size: 12px; }
228
+ table.tbl th, table.tbl td { padding: 6px 8px; }
229
+ }
167
230
  footer {
168
231
  padding: 10px 22px;
169
232
  border-top: 1px solid var(--border);
@@ -183,8 +246,13 @@
183
246
  <nav class="tabs">
184
247
  <button data-tab="chat" class="active">Chat</button>
185
248
  <button data-tab="sessions">Sessions</button>
249
+ <button data-tab="workflows">Workflows</button>
186
250
  <button data-tab="skills">Skills</button>
187
251
  <button data-tab="providers">Providers</button>
252
+ <button data-tab="rates">Rates</button>
253
+ <button data-tab="metrics">Metrics</button>
254
+ <button data-tab="doctor">Doctor</button>
255
+ <button data-tab="config">Config</button>
188
256
  <button data-tab="status">Status</button>
189
257
  </nav>
190
258
 
@@ -222,6 +290,68 @@
222
290
  <h2>Status</h2>
223
291
  <div id="status-card"><div class="empty">Loading…</div></div>
224
292
  </section>
293
+
294
+ <section id="tab-workflows">
295
+ <h2>Workflows</h2>
296
+ <div class="toolbar">
297
+ <select id="wf-status">
298
+ <option value="">all</option>
299
+ <option value="running">running</option>
300
+ <option value="resumable">resumable</option>
301
+ <option value="failed">failed</option>
302
+ <option value="done">done</option>
303
+ </select>
304
+ <input type="search" id="wf-filter" placeholder="filter by id substring">
305
+ <button class="btn btn-secondary" onclick="LOADERS.workflows()">Refresh</button>
306
+ <span class="dim" id="wf-meta"></span>
307
+ </div>
308
+ <div id="wf-summary" class="grid"></div>
309
+ <div id="wf-list"><div class="empty">Loading…</div></div>
310
+ </section>
311
+
312
+ <section id="tab-rates">
313
+ <h2>Rates</h2>
314
+ <div class="toolbar">
315
+ <input type="search" id="rates-filter" placeholder="filter by provider/model">
316
+ <button class="btn btn-secondary" onclick="LOADERS.rates()">Refresh</button>
317
+ <span class="dim" id="rates-meta"></span>
318
+ </div>
319
+ <div id="rates-validate"></div>
320
+ <div id="rates-table"><div class="empty">Loading…</div></div>
321
+ </section>
322
+
323
+ <section id="tab-metrics">
324
+ <h2>Metrics</h2>
325
+ <div class="toolbar">
326
+ <button class="btn btn-secondary" onclick="LOADERS.metrics()">Refresh</button>
327
+ <span class="dim" id="metrics-meta"></span>
328
+ </div>
329
+ <div id="metrics-cards" class="grid"></div>
330
+ <div id="metrics-detail"></div>
331
+ </section>
332
+
333
+ <section id="tab-doctor">
334
+ <h2>Doctor</h2>
335
+ <div class="toolbar">
336
+ <button class="btn btn-secondary" onclick="LOADERS.doctor()">Run</button>
337
+ <span class="dim" id="doctor-meta"></span>
338
+ </div>
339
+ <div id="doctor-card"><div class="empty">Loading…</div></div>
340
+ </section>
341
+
342
+ <section id="tab-config">
343
+ <h2>Config</h2>
344
+ <div class="toolbar">
345
+ <button class="btn btn-secondary" onclick="LOADERS.config()">Refresh</button>
346
+ <span class="dim" id="config-meta"></span>
347
+ </div>
348
+ <div id="config-validate"></div>
349
+ <div id="config-table"><div class="empty">Loading…</div></div>
350
+ <details style="margin-top:12px;">
351
+ <summary class="dim" style="cursor:pointer;">Raw JSON</summary>
352
+ <pre id="config-raw"></pre>
353
+ </details>
354
+ </section>
225
355
  </main>
226
356
 
227
357
  <footer>
@@ -252,6 +382,34 @@
252
382
  }
253
383
  return r.json();
254
384
  }
385
+ // Soft variant: returns { status, body } no matter what — used by the
386
+ // /doctor (503 on issues), /rates/validate (422), /config/validate (422)
387
+ // endpoints where a non-200 carries a meaningful payload, not an error.
388
+ async function apiSoft(path, opts = {}) {
389
+ const r = await fetch(path, opts);
390
+ let body = null;
391
+ try { body = await r.json(); } catch {}
392
+ return { status: r.status, ok: r.ok, body };
393
+ }
394
+ function escHtml(s) {
395
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
396
+ }
397
+ function fmtDuration(ms) {
398
+ if (!Number.isFinite(ms) || ms < 0) return '—';
399
+ const s = Math.floor(ms / 1000);
400
+ if (s < 60) return s + 's';
401
+ const m = Math.floor(s / 60);
402
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
403
+ const h = Math.floor(m / 60);
404
+ if (h < 24) return h + 'h ' + (m % 60) + 'm';
405
+ return Math.floor(h / 24) + 'd ' + (h % 24) + 'h';
406
+ }
407
+ function fmtBytes(n) {
408
+ if (!Number.isFinite(n)) return '—';
409
+ if (n < 1024) return n + ' B';
410
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
411
+ return (n / (1024 * 1024)).toFixed(1) + ' MB';
412
+ }
255
413
 
256
414
  // ── Status / version (always shown in header) ────────────────
257
415
  api('/version').then((v) => {
@@ -377,6 +535,234 @@
377
535
  }
378
536
  };
379
537
 
538
+ // ── Workflows ────────────────────────────────────────────────
539
+ document.getElementById('wf-status').addEventListener('change', () => LOADERS.workflows());
540
+ document.getElementById('wf-filter').addEventListener('input', debounce(() => LOADERS.workflows(), 250));
541
+ function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
542
+
543
+ LOADERS.workflows = async function loadWorkflows() {
544
+ const list = document.getElementById('wf-list');
545
+ const grid = document.getElementById('wf-summary');
546
+ const meta = document.getElementById('wf-meta');
547
+ list.innerHTML = '<div class="empty">Loading…</div>';
548
+ grid.innerHTML = '';
549
+ try {
550
+ const status = document.getElementById('wf-status').value;
551
+ const filter = document.getElementById('wf-filter').value.trim();
552
+ const qs = new URLSearchParams();
553
+ if (status) qs.set('status', status);
554
+ if (filter) qs.set('filter', filter);
555
+ const url = '/workflows' + (qs.toString() ? '?' + qs : '');
556
+ const [r, agg] = await Promise.all([api(url), api('/workflows/aggregate').catch(() => null)]);
557
+ const sessions = r.sessions || [];
558
+ meta.textContent = `${sessions.length} session${sessions.length === 1 ? '' : 's'} · dir ${r.dir || '?'}`;
559
+ const counts = sessions.reduce((acc, s) => {
560
+ const sm = s.summary || {};
561
+ if (sm.done) acc.done++;
562
+ if (sm.resumable) acc.resumable++;
563
+ if (sm.failed > 0) acc.failed++;
564
+ if (sm.running > 0) acc.running++;
565
+ return acc;
566
+ }, { done: 0, resumable: 0, failed: 0, running: 0 });
567
+ grid.innerHTML = `
568
+ <div class="stat"><div class="label">Total</div><div class="value">${sessions.length}</div></div>
569
+ <div class="stat"><div class="label">Running</div><div class="value">${counts.running}</div></div>
570
+ <div class="stat"><div class="label">Resumable</div><div class="value">${counts.resumable}</div></div>
571
+ <div class="stat"><div class="label">Failed</div><div class="value" style="color:${counts.failed ? 'var(--err)' : 'inherit'};">${counts.failed}</div></div>
572
+ <div class="stat"><div class="label">Done</div><div class="value" style="color:${counts.done ? 'var(--ok)' : 'inherit'};">${counts.done}</div></div>
573
+ ${agg && agg.sessionCount != null ? `<div class="stat"><div class="label">Aggregate sessions</div><div class="value">${agg.sessionCount}</div><div class="sub">${Object.keys(agg.nodeStats || {}).length} distinct nodes</div></div>` : ''}
574
+ `;
575
+ if (sessions.length === 0) {
576
+ list.innerHTML = '<div class="empty">No workflow runs yet. Run one with <code>lazyclaw run &lt;id&gt; ./flow.mjs</code>.</div>';
577
+ return;
578
+ }
579
+ const rows = sessions.map((s) => {
580
+ const sm = s.summary || {};
581
+ const tags = [];
582
+ if (sm.running > 0) tags.push('<span class="pill warn">running</span>');
583
+ if (sm.failed > 0) tags.push('<span class="pill err">failed</span>');
584
+ if (sm.resumable) tags.push('<span class="pill warn">resumable</span>');
585
+ if (sm.done) tags.push('<span class="pill ok">done</span>');
586
+ const total = sm.total ?? '';
587
+ return `<tr>
588
+ <td><code>${escHtml(s.sessionId)}</code></td>
589
+ <td>${tags.join(' ') || '<span class="dim">—</span>'}</td>
590
+ <td class="num">${sm.done ?? 0} / ${total}</td>
591
+ <td class="num">${sm.failed ?? 0}</td>
592
+ <td class="dim">${escHtml(s.updatedAt || s.startedAt || '')}</td>
593
+ </tr>`;
594
+ }).join('');
595
+ list.innerHTML = `<table class="tbl">
596
+ <thead><tr><th>Session</th><th>State</th><th>Done / Total</th><th>Failed</th><th>Updated</th></tr></thead>
597
+ <tbody>${rows}</tbody>
598
+ </table>`;
599
+ } catch (e) {
600
+ list.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
601
+ }
602
+ };
603
+
604
+ // ── Rates ────────────────────────────────────────────────────
605
+ document.getElementById('rates-filter').addEventListener('input', debounce(() => LOADERS.rates(), 250));
606
+
607
+ LOADERS.rates = async function loadRates() {
608
+ const root = document.getElementById('rates-table');
609
+ const meta = document.getElementById('rates-meta');
610
+ const banner = document.getElementById('rates-validate');
611
+ root.innerHTML = '<div class="empty">Loading…</div>';
612
+ banner.innerHTML = '';
613
+ try {
614
+ const filter = document.getElementById('rates-filter').value.trim();
615
+ const url = '/rates' + (filter ? '?filter=' + encodeURIComponent(filter) : '');
616
+ const [rates, validate] = await Promise.all([api(url), apiSoft('/rates/validate')]);
617
+ const entries = Object.entries(rates || {});
618
+ meta.textContent = `${entries.length} card${entries.length === 1 ? '' : 's'}`;
619
+ // Validation banner
620
+ if (validate.body) {
621
+ const v = validate.body;
622
+ const issues = (v.issues || []).map((i) => `<li>${escHtml(typeof i === 'string' ? i : JSON.stringify(i))}</li>`).join('');
623
+ const warnings = (v.warnings || []).map((w) => `<li>${escHtml(typeof w === 'string' ? w : JSON.stringify(w))}</li>`).join('');
624
+ if (v.ok && !warnings) {
625
+ banner.innerHTML = '<div class="banner ok">All rate cards valid.</div>';
626
+ } else {
627
+ const cls = v.ok ? 'warn' : 'err';
628
+ banner.innerHTML = `<div class="banner ${cls}">
629
+ <div><strong>${v.ok ? 'Warnings' : 'Validation issues'}</strong>
630
+ <ul>${issues}${warnings}</ul>
631
+ </div>
632
+ </div>`;
633
+ }
634
+ }
635
+ if (entries.length === 0) {
636
+ root.innerHTML = '<div class="empty">No rate cards configured. Add one with <code>lazyclaw rates set &lt;provider/model&gt; --in &lt;usd&gt; --out &lt;usd&gt;</code>.</div>';
637
+ return;
638
+ }
639
+ const rows = entries.map(([key, card]) => {
640
+ const c = card || {};
641
+ return `<tr>
642
+ <td><code>${escHtml(key)}</code></td>
643
+ <td class="num">${c.in ?? '—'}</td>
644
+ <td class="num">${c.out ?? '—'}</td>
645
+ <td class="num">${c['cache-read'] ?? '—'}</td>
646
+ <td class="num">${c['cache-create'] ?? '—'}</td>
647
+ <td class="dim">${escHtml(c.currency || 'USD')} / 1M tok</td>
648
+ </tr>`;
649
+ }).join('');
650
+ root.innerHTML = `<table class="tbl">
651
+ <thead><tr><th>Provider / Model</th><th>In</th><th>Out</th><th>Cache read</th><th>Cache create</th><th>Unit</th></tr></thead>
652
+ <tbody>${rows}</tbody>
653
+ </table>`;
654
+ } catch (e) {
655
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
656
+ }
657
+ };
658
+
659
+ // ── Metrics ──────────────────────────────────────────────────
660
+ LOADERS.metrics = async function loadMetrics() {
661
+ const cards = document.getElementById('metrics-cards');
662
+ const detail = document.getElementById('metrics-detail');
663
+ const meta = document.getElementById('metrics-meta');
664
+ cards.innerHTML = '';
665
+ detail.innerHTML = '<div class="empty">Loading…</div>';
666
+ try {
667
+ const m = await api('/metrics');
668
+ meta.textContent = m.timestamp ? new Date(m.timestamp).toLocaleString() : '';
669
+ const cache = m.cache || { hits: 0, misses: 0, size: 0 };
670
+ const totalCache = (cache.hits || 0) + (cache.misses || 0);
671
+ const hitRate = totalCache > 0 ? ((cache.hits / totalCache) * 100).toFixed(1) + '%' : '—';
672
+ const tokens = m.tokensTotal || {};
673
+ const tokIn = tokens.inputTokens || tokens.input || tokens.in || 0;
674
+ const tokOut = tokens.outputTokens || tokens.output || tokens.out || 0;
675
+ const wf = m.workflows || {};
676
+ const costs = m.costsByCurrency || {};
677
+ const costPairs = Object.entries(costs);
678
+ const costStr = costPairs.length ? costPairs.map(([cur, n]) => `${n.toFixed(4)} ${cur}`).join(' · ') : '—';
679
+ cards.innerHTML = `
680
+ <div class="stat"><div class="label">Uptime</div><div class="value">${fmtDuration(m.uptimeMs)}</div></div>
681
+ <div class="stat"><div class="label">Requests</div><div class="value">${m.requestsTotal ?? 0}</div><div class="sub">denied ${m.rateLimitDenied ?? 0}</div></div>
682
+ <div class="stat"><div class="label">Cache hit rate</div><div class="value">${hitRate}</div><div class="sub">${cache.hits || 0} hits / ${cache.misses || 0} misses · ${cache.size || 0} entries</div></div>
683
+ <div class="stat"><div class="label">Tokens (in / out)</div><div class="value">${tokIn.toLocaleString()} / ${tokOut.toLocaleString()}</div></div>
684
+ <div class="stat"><div class="label">Cost</div><div class="value" style="font-size:16px;">${costStr}</div></div>
685
+ ${wf && wf.total != null ? `<div class="stat"><div class="label">Workflows</div><div class="value">${wf.total}</div><div class="sub">${wf.running || 0} running · ${wf.failed || 0} failed · ${wf.done || 0} done</div></div>` : ''}
686
+ `;
687
+ const byStatus = m.requestsByStatus || {};
688
+ const statusRows = Object.keys(byStatus).sort().map((s) => `<tr><td>${escHtml(s)}</td><td class="num">${byStatus[s]}</td></tr>`).join('');
689
+ detail.innerHTML = `<div class="card">
690
+ <div class="dim" style="margin-bottom:6px;">Requests by status</div>
691
+ ${statusRows ? `<table class="tbl"><thead><tr><th>Status</th><th>Count</th></tr></thead><tbody>${statusRows}</tbody></table>` : '<div class="empty">No requests served yet.</div>'}
692
+ </div>`;
693
+ } catch (e) {
694
+ detail.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
695
+ }
696
+ };
697
+
698
+ // ── Doctor ───────────────────────────────────────────────────
699
+ LOADERS.doctor = async function loadDoctor() {
700
+ const root = document.getElementById('doctor-card');
701
+ const meta = document.getElementById('doctor-meta');
702
+ root.innerHTML = '<div class="empty">Running…</div>';
703
+ const r = await apiSoft('/doctor');
704
+ const d = r.body || {};
705
+ meta.textContent = d.timestamp ? new Date(d.timestamp).toLocaleString() : '';
706
+ const issues = d.issues || [];
707
+ const okBanner = d.ok
708
+ ? '<div class="banner ok"><strong>All checks passed.</strong></div>'
709
+ : `<div class="banner err"><div><strong>${issues.length} issue${issues.length === 1 ? '' : 's'}:</strong>
710
+ <ul>${issues.map((i) => `<li>${escHtml(i)}</li>`).join('')}</ul>
711
+ </div></div>`;
712
+ root.innerHTML = okBanner + `
713
+ <div class="card">
714
+ <div class="row"><div class="name">Provider</div><div class="dim" style="margin-left:auto;">${escHtml(d.provider || '—')}</div></div>
715
+ <div class="row"><div class="name">Model</div><div class="dim" style="margin-left:auto;">${escHtml(d.model || '—')}</div></div>
716
+ <div class="row"><div class="name">API key</div><div class="dim" style="margin-left:auto;">${d.hasApiKey ? '<span class="pill ok">present</span>' : '<span class="pill warn">none</span>'}</div></div>
717
+ <div class="row"><div class="name">Node</div><div class="dim" style="margin-left:auto;">${escHtml(d.nodeVersion || '—')}</div></div>
718
+ <div class="row"><div class="name">Platform</div><div class="dim" style="margin-left:auto;">${escHtml(d.platform || '—')}</div></div>
719
+ <div class="row"><div class="name">Known providers</div><div class="dim" style="margin-left:auto;">${(d.knownProviders || []).map(escHtml).join(' · ') || '—'}</div></div>
720
+ </div>`;
721
+ };
722
+
723
+ // ── Config ───────────────────────────────────────────────────
724
+ LOADERS.config = async function loadConfig() {
725
+ const root = document.getElementById('config-table');
726
+ const banner = document.getElementById('config-validate');
727
+ const raw = document.getElementById('config-raw');
728
+ const meta = document.getElementById('config-meta');
729
+ root.innerHTML = '<div class="empty">Loading…</div>';
730
+ banner.innerHTML = '';
731
+ raw.textContent = '';
732
+ try {
733
+ const [cfg, validate] = await Promise.all([api('/config'), apiSoft('/config/validate')]);
734
+ const keys = Object.keys(cfg);
735
+ meta.textContent = `${keys.length} key${keys.length === 1 ? '' : 's'}`;
736
+ if (validate.body) {
737
+ const v = validate.body;
738
+ const issues = (v.issues || []).map((i) => `<li>${escHtml(typeof i === 'string' ? i : JSON.stringify(i))}</li>`).join('');
739
+ const warnings = (v.warnings || []).map((w) => `<li>${escHtml(typeof w === 'string' ? w : JSON.stringify(w))}</li>`).join('');
740
+ if (v.ok && !warnings) {
741
+ banner.innerHTML = '<div class="banner ok">Config valid.</div>';
742
+ } else {
743
+ const cls = v.ok ? 'warn' : 'err';
744
+ banner.innerHTML = `<div class="banner ${cls}"><div><strong>${v.ok ? 'Warnings' : 'Validation issues'}</strong><ul>${issues}${warnings}</ul></div></div>`;
745
+ }
746
+ }
747
+ if (keys.length === 0) {
748
+ root.innerHTML = '<div class="empty">No config yet. Run <code>lazyclaw onboard</code>.</div>';
749
+ return;
750
+ }
751
+ const rows = keys.sort().map((k) => {
752
+ const v = cfg[k];
753
+ const display = v && typeof v === 'object' ? JSON.stringify(v) : String(v);
754
+ return `<tr><td><code>${escHtml(k)}</code></td><td>${escHtml(display)}</td></tr>`;
755
+ }).join('');
756
+ root.innerHTML = `<table class="tbl">
757
+ <thead><tr><th style="width:30%">Key</th><th>Value</th></tr></thead>
758
+ <tbody>${rows}</tbody>
759
+ </table>`;
760
+ raw.textContent = JSON.stringify(cfg, null, 2);
761
+ } catch (e) {
762
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
763
+ }
764
+ };
765
+
380
766
  // First load = chat tab.
381
767
  LOADERS.chat();
382
768