lazyclaw 5.4.4 → 6.0.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.
Files changed (91) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/channels-discord/index.mjs +76 -0
  3. package/channels-discord/package.json +14 -0
  4. package/channels-email/index.mjs +109 -0
  5. package/channels-email/package.json +14 -0
  6. package/channels-signal/index.mjs +69 -0
  7. package/channels-signal/package.json +9 -0
  8. package/channels-voice/index.mjs +81 -0
  9. package/channels-voice/package.json +9 -0
  10. package/channels-whatsapp/index.mjs +70 -0
  11. package/channels-whatsapp/package.json +13 -0
  12. package/cli.mjs +73 -7399
  13. package/commands/agents.mjs +669 -0
  14. package/commands/auth_nodes.mjs +323 -0
  15. package/commands/automation.mjs +582 -0
  16. package/commands/channels.mjs +255 -0
  17. package/commands/chat.mjs +1217 -0
  18. package/commands/config.mjs +315 -0
  19. package/commands/daemon.mjs +260 -0
  20. package/commands/misc.mjs +128 -0
  21. package/commands/providers.mjs +511 -0
  22. package/commands/sessions.mjs +343 -0
  23. package/commands/setup.mjs +741 -0
  24. package/commands/skills.mjs +218 -0
  25. package/commands/workflow.mjs +661 -0
  26. package/daemon/lib/auth.mjs +58 -0
  27. package/daemon/lib/cost.mjs +30 -0
  28. package/daemon/lib/provider.mjs +69 -0
  29. package/daemon/lib/respond.mjs +83 -0
  30. package/daemon/route_table.mjs +96 -0
  31. package/daemon/routes/_deps.mjs +36 -0
  32. package/daemon/routes/config.mjs +99 -0
  33. package/daemon/routes/conversation.mjs +371 -0
  34. package/daemon/routes/meta.mjs +239 -0
  35. package/daemon/routes/ops.mjs +185 -0
  36. package/daemon/routes/providers.mjs +211 -0
  37. package/daemon/routes/rates.mjs +90 -0
  38. package/daemon/routes/registry.mjs +223 -0
  39. package/daemon/routes/sessions.mjs +213 -0
  40. package/daemon/routes/skills.mjs +260 -0
  41. package/daemon/routes/workflows.mjs +224 -0
  42. package/daemon.mjs +23 -2085
  43. package/dotenv_min.mjs +23 -0
  44. package/first_run.mjs +15 -0
  45. package/goals_cron.mjs +37 -0
  46. package/lib/args.mjs +216 -0
  47. package/lib/config.mjs +113 -0
  48. package/lib/registry_boot.mjs +55 -0
  49. package/mas/agent_turn.mjs +2 -1
  50. package/mas/index_db.mjs +82 -0
  51. package/mas/learning.mjs +17 -1
  52. package/mas/mention_router.mjs +38 -10
  53. package/mas/provider_adapters.mjs +28 -4
  54. package/mas/scrub_env.mjs +34 -0
  55. package/mas/tool_runner.mjs +23 -7
  56. package/mas/tools/bash.mjs +10 -5
  57. package/mas/tools/browser.mjs +18 -0
  58. package/mas/tools/learning.mjs +24 -14
  59. package/mas/tools/recall.mjs +5 -1
  60. package/mas/tools/web.mjs +47 -11
  61. package/mas/trajectory_store.mjs +7 -4
  62. package/package.json +16 -2
  63. package/providers/auth_store.mjs +22 -0
  64. package/providers/claude_cli.mjs +28 -2
  65. package/providers/claude_cli_detect.mjs +46 -0
  66. package/providers/custom_provider.mjs +70 -0
  67. package/providers/model_catalogue.mjs +86 -0
  68. package/providers/orchestrator.mjs +30 -9
  69. package/providers/rates.mjs +12 -2
  70. package/providers/registry.mjs +10 -7
  71. package/sandbox/confiners/landlock.mjs +14 -8
  72. package/sandbox/confiners/seatbelt.mjs +18 -2
  73. package/scripts/loop-worker.mjs +18 -7
  74. package/scripts/migrate-v5.mjs +5 -61
  75. package/secure_write.mjs +46 -0
  76. package/sessions.mjs +0 -0
  77. package/tui/modal_filter.mjs +59 -0
  78. package/tui/modal_picker.mjs +12 -37
  79. package/tui/pickers.mjs +917 -0
  80. package/tui/provider_families.mjs +41 -0
  81. package/tui/repl.mjs +67 -36
  82. package/tui/slash_commands.mjs +2 -1
  83. package/tui/slash_dispatcher.mjs +717 -58
  84. package/tui/splash.mjs +5 -12
  85. package/tui/subcommands.mjs +17 -0
  86. package/tui/terminal_approve.mjs +37 -0
  87. package/web/dashboard.css +275 -0
  88. package/web/dashboard.html +2 -1685
  89. package/web/dashboard.js +1406 -0
  90. package/workflow/persistent.mjs +13 -6
  91. package/mas/tools/skill_view.mjs +0 -43
@@ -14,266 +14,7 @@
14
14
  <meta charset="utf-8">
15
15
  <meta name="viewport" content="width=device-width, initial-scale=1">
16
16
  <title>LazyClaw</title>
17
- <style>
18
- :root {
19
- --bg: #0a0a0a;
20
- --card: #14141c;
21
- --border: #2a2a36;
22
- --text: #e8e8ea;
23
- --dim: #a8a8b8;
24
- --accent: #d97757;
25
- --ok: #4ade80;
26
- --warn: #f59e0b;
27
- --err: #ef4444;
28
- }
29
- * { box-sizing: border-box; }
30
- body {
31
- margin: 0;
32
- font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
33
- background: var(--bg);
34
- color: var(--text);
35
- min-height: 100vh;
36
- display: flex;
37
- flex-direction: column;
38
- }
39
- header {
40
- padding: 14px 22px;
41
- border-bottom: 1px solid var(--border);
42
- display: flex;
43
- align-items: center;
44
- gap: 14px;
45
- }
46
- .logo { font-weight: 700; font-size: 16px; color: var(--accent); display: flex; align-items: center; gap: 10px; }
47
- .logo .mascot { width: 44px; height: 44px; flex: none; image-rendering: pixelated; image-rendering: crisp-edges; }
48
- .ver { color: var(--dim); font-size: 11px; }
49
- /* lazyclaw 16x16 pixel mascot — Claude Design handoff (mascot sheet
50
- v0.1, "claude original" palette). Claude's asterisk star (#d97757)
51
- worn under a crustacean helmet (#c33d2a) with two antenna-claws.
52
- Idle pose (sleepy slits). Hover gently brightens the helmet. */
53
- .mascot { transition: filter 0.2s ease; }
54
- .logo:hover .mascot { filter: drop-shadow(0 0 6px rgba(217, 119, 87, 0.45)); }
55
- nav.tabs {
56
- display: flex;
57
- gap: 2px;
58
- padding: 0 14px;
59
- border-bottom: 1px solid var(--border);
60
- overflow-x: auto;
61
- }
62
- nav.tabs button {
63
- background: none;
64
- border: 0;
65
- color: var(--dim);
66
- padding: 12px 16px;
67
- cursor: pointer;
68
- font-size: 13px;
69
- border-bottom: 2px solid transparent;
70
- white-space: nowrap;
71
- }
72
- nav.tabs button:hover { color: var(--text); }
73
- nav.tabs button.active {
74
- color: var(--accent);
75
- border-bottom-color: var(--accent);
76
- }
77
- main {
78
- flex: 1;
79
- padding: 22px;
80
- overflow-y: auto;
81
- }
82
- section { display: none; }
83
- section.active { display: block; }
84
- h2 { margin: 0 0 14px; font-size: 18px; }
85
- .card {
86
- background: var(--card);
87
- border: 1px solid var(--border);
88
- border-radius: 8px;
89
- padding: 14px 16px;
90
- margin-bottom: 12px;
91
- }
92
- .row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
93
- .row + .row { border-top: 1px solid var(--border); }
94
- .name { font-weight: 600; }
95
- .dim { color: var(--dim); font-size: 12px; }
96
- button.btn {
97
- background: var(--accent);
98
- color: #fff;
99
- border: 0;
100
- border-radius: 6px;
101
- padding: 8px 14px;
102
- cursor: pointer;
103
- font-size: 13px;
104
- font-weight: 500;
105
- }
106
- button.btn:hover { filter: brightness(1.1); }
107
- button.btn-secondary {
108
- background: transparent;
109
- border: 1px solid var(--border);
110
- color: var(--text);
111
- }
112
- button.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
113
- .empty { color: var(--dim); padding: 24px; text-align: center; font-style: italic; }
114
- pre {
115
- background: #06060c;
116
- padding: 12px 14px;
117
- border-radius: 6px;
118
- overflow-x: auto;
119
- font-size: 12px;
120
- color: #cfcfd6;
121
- border: 1px solid var(--border);
122
- }
123
- /* Chat */
124
- #chat-stream {
125
- background: var(--card);
126
- border: 1px solid var(--border);
127
- border-radius: 8px;
128
- padding: 12px;
129
- height: 50vh;
130
- overflow-y: auto;
131
- display: flex;
132
- flex-direction: column;
133
- gap: 14px;
134
- }
135
- .msg { padding: 8px 12px; border-radius: 6px; max-width: 90%; white-space: pre-wrap; word-wrap: break-word; }
136
- .msg.user { align-self: flex-end; background: rgba(217, 119, 87, 0.15); border: 1px solid rgba(217, 119, 87, 0.3); }
137
- .msg.assistant { align-self: flex-start; background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
138
- .msg.error { align-self: stretch; background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.3); color: #ffd3d3; }
139
- .input-row {
140
- display: flex;
141
- gap: 8px;
142
- margin-top: 12px;
143
- }
144
- .input-row textarea {
145
- flex: 1;
146
- background: var(--card);
147
- border: 1px solid var(--border);
148
- border-radius: 6px;
149
- padding: 10px 12px;
150
- color: var(--text);
151
- resize: vertical;
152
- min-height: 60px;
153
- font: inherit;
154
- }
155
- .pill {
156
- display: inline-block;
157
- padding: 2px 8px;
158
- border-radius: 999px;
159
- font-size: 10px;
160
- background: var(--border);
161
- color: var(--dim);
162
- margin-left: 6px;
163
- }
164
- .pill.ok { background: rgba(74, 222, 128, 0.15); color: var(--ok); }
165
- .pill.warn { background: rgba(245, 158, 11, 0.15); color: var(--warn); }
166
- .pill.err { background: rgba(239, 68, 68, 0.15); color: var(--err); }
167
- .toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
168
- .toolbar select {
169
- background: var(--card);
170
- border: 1px solid var(--border);
171
- color: var(--text);
172
- padding: 6px 10px;
173
- border-radius: 6px;
174
- font: inherit;
175
- }
176
- .toolbar input[type="text"], .toolbar input[type="search"] {
177
- background: var(--card);
178
- border: 1px solid var(--border);
179
- color: var(--text);
180
- padding: 6px 10px;
181
- border-radius: 6px;
182
- font: inherit;
183
- min-width: 0;
184
- }
185
- .grid {
186
- display: grid;
187
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
188
- gap: 10px;
189
- margin-bottom: 14px;
190
- }
191
- .stat {
192
- background: var(--card);
193
- border: 1px solid var(--border);
194
- border-radius: 8px;
195
- padding: 12px 14px;
196
- }
197
- .stat .label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
198
- .stat .value { font-size: 22px; font-weight: 600; margin-top: 4px; word-break: break-all; }
199
- .stat .sub { color: var(--dim); font-size: 11px; margin-top: 4px; }
200
- table.tbl {
201
- width: 100%;
202
- border-collapse: collapse;
203
- background: var(--card);
204
- border: 1px solid var(--border);
205
- border-radius: 8px;
206
- overflow: hidden;
207
- font-size: 13px;
208
- }
209
- table.tbl th, table.tbl td {
210
- padding: 8px 12px;
211
- text-align: left;
212
- border-bottom: 1px solid var(--border);
213
- vertical-align: top;
214
- }
215
- 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); }
216
- table.tbl tr:last-child td { border-bottom: 0; }
217
- table.tbl td.num { text-align: right; font-variant-numeric: tabular-nums; }
218
- .banner {
219
- padding: 10px 14px;
220
- border-radius: 8px;
221
- margin-bottom: 12px;
222
- border: 1px solid var(--border);
223
- background: var(--card);
224
- display: flex;
225
- align-items: center;
226
- gap: 10px;
227
- }
228
- .banner.ok { border-color: rgba(74, 222, 128, 0.4); background: rgba(74, 222, 128, 0.06); }
229
- .banner.warn { border-color: rgba(245, 158, 11, 0.4); background: rgba(245, 158, 11, 0.06); }
230
- .banner.err { border-color: rgba(239, 68, 68, 0.4); background: rgba(239, 68, 68, 0.06); }
231
- .banner ul { margin: 6px 0 0 18px; padding: 0; }
232
- .banner li { font-size: 12px; }
233
- /* Modal — used by session/workflow/skill detail views, the rate-card
234
- editor, and the custom-provider form. One stacking layer; only one
235
- open at a time. */
236
- .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); display: none; align-items: center; justify-content: center; z-index: 100; padding: 20px; }
237
- .modal-backdrop.open { display: flex; }
238
- .modal { background: var(--card); border: 1px solid var(--border); border-radius: 10px; max-width: min(720px, 96vw); width: 100%; max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
239
- .modal-head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
240
- .modal-head h3 { margin: 0; font-size: 16px; font-weight: 600; flex: 1; }
241
- .modal-close { background: none; border: 0; color: var(--dim); cursor: pointer; font-size: 20px; line-height: 1; padding: 4px 8px; }
242
- .modal-close:hover { color: var(--text); }
243
- .modal-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
244
- .modal-foot { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; }
245
- .clickable { cursor: pointer; }
246
- .clickable:hover { background: rgba(217, 119, 87, 0.05); }
247
- .turn { padding: 8px 12px; border-radius: 6px; margin-bottom: 8px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; }
248
- .turn.user { background: rgba(217, 119, 87, 0.10); border: 1px solid rgba(217, 119, 87, 0.25); }
249
- .turn.assistant { background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
250
- .turn.system { background: rgba(245, 158, 11, 0.06); border: 1px solid rgba(245, 158, 11, 0.20); }
251
- .turn .role-tag { display: block; color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
252
- .form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
253
- .form-row label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
254
- .form-row input, .form-row textarea { background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 8px 10px; border-radius: 6px; font: inherit; font-size: 13px; }
255
- .form-row textarea { resize: vertical; min-height: 80px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
256
- .row-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
257
- button.btn-sm { font-size: 12px; padding: 4px 10px; }
258
- button.btn-danger { background: rgba(239,68,68,0.15); color: #ffb4b4; border: 1px solid rgba(239,68,68,0.3); }
259
- button.btn-danger:hover { background: rgba(239,68,68,0.25); }
260
- .markdown { font-size: 13px; line-height: 1.55; }
261
- .markdown pre { font-size: 12px; }
262
- @media (max-width: 480px) {
263
- main { padding: 14px; }
264
- .grid { grid-template-columns: 1fr; }
265
- table.tbl { font-size: 12px; }
266
- table.tbl th, table.tbl td { padding: 6px 8px; }
267
- }
268
- footer {
269
- padding: 10px 22px;
270
- border-top: 1px solid var(--border);
271
- color: var(--dim);
272
- font-size: 11px;
273
- display: flex;
274
- justify-content: space-between;
275
- }
276
- </style>
17
+ <link rel="stylesheet" href="/dashboard.css">
277
18
  </head>
278
19
  <body>
279
20
  <header>
@@ -535,1430 +276,6 @@
535
276
  </div>
536
277
  </div>
537
278
 
538
- <script>
539
- // Tab switching ────────────────────────────────────────────────
540
- const tabs = document.querySelectorAll('nav.tabs button');
541
- const sections = document.querySelectorAll('main section');
542
- tabs.forEach((b) => b.addEventListener('click', () => {
543
- tabs.forEach((x) => x.classList.toggle('active', x === b));
544
- sections.forEach((s) => s.classList.toggle('active', s.id === 'tab-' + b.dataset.tab));
545
- const loader = LOADERS[b.dataset.tab];
546
- if (loader) loader();
547
- }));
548
-
549
- document.getElementById('footer-url').textContent = location.href;
550
-
551
- // Tiny fetch helper that surfaces errors as toasts on the page.
552
- async function api(path, opts = {}) {
553
- const r = await fetch(path, opts);
554
- if (!r.ok && r.status !== 200) {
555
- let body = '';
556
- try { body = JSON.stringify(await r.json()); } catch {}
557
- throw new Error(`${r.status} ${r.statusText}${body ? ' — ' + body : ''}`);
558
- }
559
- return r.json();
560
- }
561
- // Soft variant: returns { status, body } no matter what — used by the
562
- // /doctor (503 on issues), /rates/validate (422), /config/validate (422)
563
- // endpoints where a non-200 carries a meaningful payload, not an error.
564
- async function apiSoft(path, opts = {}) {
565
- const r = await fetch(path, opts);
566
- let body = null;
567
- try { body = await r.json(); } catch {}
568
- return { status: r.status, ok: r.ok, body };
569
- }
570
- function escHtml(s) {
571
- return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
572
- }
573
- // ── Shared modal ────────────────────────────────────────────────
574
- // openModal({ title, bodyHtml, footHtml }) renders into the markup
575
- // declared at the bottom of <body> and shows the backdrop. ESC and
576
- // backdrop click close. Only one modal is open at a time — calling
577
- // openModal while another is already open replaces its contents.
578
- function openModal({ title, bodyHtml, footHtml }) {
579
- document.getElementById('modal-title').textContent = title || '';
580
- document.getElementById('modal-body').innerHTML = bodyHtml || '';
581
- document.getElementById('modal-foot').innerHTML = footHtml || '';
582
- document.getElementById('modal-backdrop').classList.add('open');
583
- }
584
- function closeModal() {
585
- document.getElementById('modal-backdrop').classList.remove('open');
586
- document.getElementById('modal-body').innerHTML = '';
587
- document.getElementById('modal-foot').innerHTML = '';
588
- }
589
- document.addEventListener('keydown', (e) => {
590
- if (e.key === 'Escape' && document.getElementById('modal-backdrop').classList.contains('open')) closeModal();
591
- });
592
- function fmtDuration(ms) {
593
- if (!Number.isFinite(ms) || ms < 0) return '—';
594
- const s = Math.floor(ms / 1000);
595
- if (s < 60) return s + 's';
596
- const m = Math.floor(s / 60);
597
- if (m < 60) return m + 'm ' + (s % 60) + 's';
598
- const h = Math.floor(m / 60);
599
- if (h < 24) return h + 'h ' + (m % 60) + 'm';
600
- return Math.floor(h / 24) + 'd ' + (h % 24) + 'h';
601
- }
602
- function fmtBytes(n) {
603
- if (!Number.isFinite(n)) return '—';
604
- if (n < 1024) return n + ' B';
605
- if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
606
- return (n / (1024 * 1024)).toFixed(1) + ' MB';
607
- }
608
-
609
- // ── Status / version (always shown in header) ────────────────
610
- api('/version').then((v) => {
611
- document.getElementById('version').textContent = `v${v.version}`;
612
- }).catch(() => {});
613
-
614
- // ── Loaders per tab ──────────────────────────────────────────
615
- const LOADERS = {};
616
-
617
- LOADERS.chat = async function loadChat() {
618
- try {
619
- const r = await api('/providers');
620
- // GET /providers returns a bare array; older drafts wrapped it
621
- // as { providers: [...] }. Accept both so the dashboard works
622
- // against any daemon version users might happen to be running.
623
- const arr = Array.isArray(r) ? r : (r.providers || []);
624
- const sel = document.getElementById('chat-assignee');
625
- sel.innerHTML = '';
626
- if (arr.length === 0) {
627
- const opt = document.createElement('option');
628
- opt.value = ''; opt.textContent = '(no providers — run lazyclaw onboard)';
629
- sel.appendChild(opt);
630
- return;
631
- }
632
- // Preselect the configured default when possible so the user
633
- // doesn't have to scroll through the list before sending the
634
- // first message.
635
- let defaultStatus = null;
636
- try { defaultStatus = await api('/status'); } catch { /* keep going */ }
637
- const defaultProv = defaultStatus?.provider || null;
638
- const defaultModel = defaultStatus?.model || null;
639
- const defaultValue = defaultProv && defaultModel ? `${defaultProv}:${defaultModel}` : defaultProv;
640
- for (const p of arr) {
641
- const ms = (p.suggestedModels || []);
642
- if (!ms.length) {
643
- const opt = document.createElement('option');
644
- opt.value = p.name; opt.textContent = p.name;
645
- sel.appendChild(opt);
646
- continue;
647
- }
648
- for (const m of ms.slice(0, 6)) {
649
- const opt = document.createElement('option');
650
- opt.value = `${p.name}:${m}`;
651
- opt.textContent = `${p.name} · ${m}`;
652
- sel.appendChild(opt);
653
- }
654
- }
655
- if (defaultValue) {
656
- // Try exact match first (provider:model); fall back to any
657
- // option starting with `<provider>:` if the configured model
658
- // isn't in the suggested list.
659
- const exact = Array.from(sel.options).find((o) => o.value === defaultValue);
660
- if (exact) sel.value = defaultValue;
661
- else {
662
- const prefix = (defaultProv || '') + ':';
663
- const byProv = Array.from(sel.options).find((o) => o.value.startsWith(prefix) || o.value === defaultProv);
664
- if (byProv) sel.value = byProv.value;
665
- }
666
- }
667
- } catch (e) {
668
- document.getElementById('chat-meta').textContent = '⚠ ' + e.message;
669
- }
670
- };
671
-
672
- LOADERS.sessions = async function loadSessions() {
673
- const root = document.getElementById('sessions-list');
674
- root.innerHTML = '<div class="empty">Loading…</div>';
675
- try {
676
- const r = await api('/sessions?withV5=true');
677
- const arr = r.sessions || r;
678
- if (!Array.isArray(arr) || arr.length === 0) {
679
- root.innerHTML = '<div class="empty">No persisted sessions yet. Start one with <code>lazyclaw chat --session &lt;id&gt;</code>.</div>';
680
- return;
681
- }
682
- root.innerHTML = '';
683
- arr.forEach((s) => {
684
- const div = document.createElement('div');
685
- div.className = 'card row clickable';
686
- const id = s.id || s.sessionId || s.name || JSON.stringify(s);
687
- const turns = s.turns ?? s.turnCount ?? '';
688
- const updated = s.updatedAt || s.mtime || '';
689
- // v5 columns: trainerHandled / agentName / trajectoryId.
690
- const tagTrained = s.trainerHandled
691
- ? `<span class="pill ok" title="trained by ${escHtml(s.trainedBy || 'trainer')}">trained: ${escHtml(s.trainedBy || 'on')}</span>`
692
- : '';
693
- const tagAgent = s.agentName
694
- ? `<span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">@${escHtml(s.agentName)}</span>`
695
- : '';
696
- const trajBtn = s.trajectoryId
697
- ? `<button class="btn btn-secondary btn-sm" data-action="trajectory" data-traj="${escHtml(s.trajectoryId)}">Trajectory</button>`
698
- : '';
699
- div.innerHTML = `<div class="name">${escHtml(id)}</div>
700
- <div class="dim">${turns ? turns + ' turns' : ''}</div>
701
- ${tagTrained}${tagAgent}
702
- <div class="dim row-actions">${escHtml(updated)}</div>
703
- <button class="btn btn-secondary btn-sm" data-action="view">View</button>
704
- <button class="btn btn-secondary btn-sm" data-action="export">Export</button>
705
- ${trajBtn}
706
- <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>`;
707
- div.addEventListener('click', (e) => {
708
- const btn = e.target.closest('button');
709
- const action = btn?.dataset.action;
710
- if (action === 'export') return openSessionExport(id);
711
- if (action === 'delete') return deleteSession(id);
712
- if (action === 'trajectory') {
713
- const traj = btn.dataset.traj;
714
- window.open('/trajectories/' + encodeURIComponent(traj), '_blank');
715
- return;
716
- }
717
- return openSessionDetail(id);
718
- });
719
- root.appendChild(div);
720
- });
721
- } catch (e) {
722
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
723
- }
724
- };
725
-
726
- async function openSessionDetail(id) {
727
- openModal({ title: `Session — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
728
- try {
729
- const r = await api('/sessions/' + encodeURIComponent(id));
730
- const turns = r.turns || r.entries || r;
731
- if (!Array.isArray(turns) || turns.length === 0) {
732
- document.getElementById('modal-body').innerHTML = '<div class="empty">Empty session.</div>';
733
- return;
734
- }
735
- const html = turns.map((t) => {
736
- const role = (t.role || 'note').toLowerCase();
737
- const content = String(t.content ?? t.text ?? '');
738
- const ts = t.ts || t.timestamp || '';
739
- return `<div class="turn ${escHtml(role)}">
740
- <span class="role-tag">${escHtml(role)}${ts ? ' · ' + escHtml(ts) : ''}</span>${escHtml(content)}
741
- </div>`;
742
- }).join('');
743
- document.getElementById('modal-body').innerHTML = html;
744
- } catch (e) {
745
- document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
746
- }
747
- }
748
-
749
- async function openSessionExport(id) {
750
- openModal({ title: `Export — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
751
- try {
752
- const r = await fetch('/sessions/' + encodeURIComponent(id) + '/export?format=md');
753
- const text = await r.text();
754
- document.getElementById('modal-body').innerHTML = `<pre>${escHtml(text)}</pre>`;
755
- document.getElementById('modal-foot').innerHTML = `
756
- <button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy markdown',1200)">Copy markdown</button>
757
- <button class="btn" onclick="closeModal()">Close</button>`;
758
- } catch (e) {
759
- document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
760
- }
761
- }
762
-
763
- async function deleteSession(id) {
764
- if (!confirm(`Delete session "${id}"?\nTurn log will be permanently removed.`)) return;
765
- try {
766
- await fetch('/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
767
- LOADERS.sessions();
768
- } catch (e) {
769
- alert('Delete failed: ' + e.message);
770
- }
771
- }
772
-
773
- // v5 confidence pill color: red <0.4, amber <0.7, green >=0.7.
774
- function confidencePill(c) {
775
- if (c == null || c === '') return '';
776
- const n = Number(c);
777
- if (!Number.isFinite(n)) return '';
778
- const cls = n >= 0.7 ? 'ok' : (n >= 0.4 ? 'warn' : 'err');
779
- return `<span class="pill ${cls}" title="confidence">${n.toFixed(2)}</span>`;
780
- }
781
-
782
- LOADERS.skills = async function loadSkills() {
783
- const root = document.getElementById('skills-list');
784
- const meta = document.getElementById('skills-meta');
785
- const suggBox = document.getElementById('skills-suggestions');
786
- root.innerHTML = '<div class="empty">Loading…</div>';
787
- // Fire suggestions fetch in parallel; render when ready.
788
- apiSoft('/skills/suggestions').then(({ body }) => {
789
- const items = (body && body.suggestions) || [];
790
- if (!items.length) { suggBox.innerHTML = ''; return; }
791
- suggBox.innerHTML = `<div class="card" style="border-color:var(--accent);">
792
- <div class="name" style="color:var(--accent);">Curator suggestions (${items.length})</div>
793
- ${items.slice(0, 5).map((s) => `<div class="dim" style="margin-top:6px;font-size:12px;">
794
- ${escHtml(s.suggestion || s.cluster?.sample || '')}
795
- <span class="dim" style="margin-left:6px;">${s.ts ? new Date(s.ts).toLocaleString() : ''}</span>
796
- </div>`).join('')}
797
- </div>`;
798
- }).catch(() => { suggBox.innerHTML = ''; });
799
-
800
- try {
801
- const r = await api('/skills');
802
- const arr = r.skills || r;
803
- if (!Array.isArray(arr) || arr.length === 0) {
804
- root.innerHTML = '<div class="empty">No skills yet. Install one: <code>lazyclaw skills install &lt;user&gt;/&lt;repo&gt;</code>.</div>';
805
- if (meta) meta.textContent = '';
806
- return;
807
- }
808
- if (meta) meta.textContent = `${arr.length} skill${arr.length === 1 ? '' : 's'}`;
809
- // Group by `group` field; fall back to 'ungrouped'.
810
- const groups = new Map();
811
- for (const s of arr) {
812
- const g = (s.group && String(s.group)) || 'ungrouped';
813
- if (!groups.has(g)) groups.set(g, []);
814
- groups.get(g).push(s);
815
- }
816
- const groupNames = [...groups.keys()].sort();
817
- root.innerHTML = '';
818
- for (const g of groupNames) {
819
- const wrap = document.createElement('div');
820
- wrap.style.marginTop = '12px';
821
- wrap.innerHTML = `<h3 style="font-size:13px;color:var(--dim);margin:0 0 6px 0;text-transform:uppercase;letter-spacing:0.05em;">${escHtml(g)} · ${groups.get(g).length}</h3>`;
822
- for (const s of groups.get(g)) {
823
- const div = document.createElement('div');
824
- div.className = 'card clickable';
825
- const trainedTag = s.trained_by
826
- ? `<span class="pill" title="trained_by">${escHtml(s.trained_by)}</span>`
827
- : '';
828
- const conf = confidencePill(s.confidence);
829
- const crossOk = Array.isArray(s.cross_cli_tested) && s.cross_cli_tested.length
830
- ? `<span class="pill ok" title="cross-CLI: ${escHtml(s.cross_cli_tested.map((x) => x.provider || '').join(', '))}">x-cli ✓</span>`
831
- : (s.cross_cli_tested === true
832
- ? '<span class="pill ok" title="cross-CLI tested">x-cli ✓</span>'
833
- : '<span class="pill" title="not cross-CLI tested" style="opacity:0.4;">x-cli —</span>');
834
- div.innerHTML = `<div class="row" style="border:0;padding:0;">
835
- <div class="name">${escHtml(s.name)}</div>
836
- ${trainedTag}${conf}${crossOk}
837
- <div class="dim row-actions">${(s.bytes ?? '')} bytes</div>
838
- <button class="btn btn-secondary btn-sm" data-action="view">View</button>
839
- <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>
840
- </div>
841
- <div class="dim" style="margin-top:6px;">${escHtml(s.summary || s.description || '')}</div>`;
842
- div.addEventListener('click', (e) => {
843
- const action = e.target.closest('button')?.dataset.action;
844
- if (action === 'delete') return deleteSkill(s.name);
845
- return openSkillDetail(s.name);
846
- });
847
- wrap.appendChild(div);
848
- }
849
- root.appendChild(wrap);
850
- }
851
- // Synth-from-task footer.
852
- const synthBar = document.createElement('div');
853
- synthBar.className = 'toolbar';
854
- synthBar.style.marginTop = '14px';
855
- synthBar.innerHTML = `<input type="text" id="skill-synth-sid" placeholder="sessionId to synthesize" style="flex:1;min-width:200px;">
856
- <button class="btn" onclick="skillSynth()">Synthesize from task</button>
857
- <span class="dim" id="skill-synth-result"></span>`;
858
- root.appendChild(synthBar);
859
- } catch (e) {
860
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
861
- }
862
- };
863
-
864
- async function skillSynth() {
865
- const sid = (document.getElementById('skill-synth-sid').value || '').trim();
866
- const out = document.getElementById('skill-synth-result');
867
- if (!sid) { out.style.color = 'var(--warn)'; out.textContent = 'enter a sessionId'; return; }
868
- out.style.color = 'var(--dim)';
869
- out.textContent = '⏳ synthesizing…';
870
- try {
871
- const r = await fetch('/skills/synth', {
872
- method: 'POST',
873
- headers: { 'content-type': 'application/json' },
874
- body: JSON.stringify({ sessionId: sid }),
875
- });
876
- const body = await r.json().catch(() => ({}));
877
- if (r.ok) {
878
- out.style.color = 'var(--ok)';
879
- out.textContent = '✓ ' + (body.name ? `created skill "${body.name}"` : (body.message || 'done'));
880
- LOADERS.skills();
881
- } else {
882
- out.style.color = 'var(--err)';
883
- out.textContent = '✗ ' + (body.error || r.statusText);
884
- }
885
- } catch (e) {
886
- out.style.color = 'var(--err)';
887
- out.textContent = '✗ ' + e.message;
888
- }
889
- }
890
-
891
- async function openSkillDetail(name) {
892
- openModal({ title: `Skill — ${name}`, bodyHtml: '<div class="empty">Loading…</div>' });
893
- try {
894
- // GET /skills/<name> returns the markdown body as text/markdown.
895
- const r = await fetch('/skills/' + encodeURIComponent(name));
896
- if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
897
- const text = await r.text();
898
- document.getElementById('modal-body').innerHTML = `<pre class="markdown">${escHtml(text)}</pre>`;
899
- document.getElementById('modal-foot').innerHTML = `
900
- <button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy',1200)">Copy</button>
901
- <button class="btn" onclick="closeModal()">Close</button>`;
902
- } catch (e) {
903
- document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
904
- }
905
- }
906
-
907
- async function deleteSkill(name) {
908
- if (!confirm(`Remove skill "${name}"?`)) return;
909
- try {
910
- await fetch('/skills/' + encodeURIComponent(name), { method: 'DELETE' });
911
- LOADERS.skills();
912
- } catch (e) {
913
- alert('Delete failed: ' + e.message);
914
- }
915
- }
916
-
917
- LOADERS.providers = async function loadProviders() {
918
- const root = document.getElementById('providers-list');
919
- const meta = document.getElementById('providers-meta');
920
- root.innerHTML = '<div class="empty">Loading…</div>';
921
- try {
922
- const r = await api('/providers');
923
- const arr = r.providers || r;
924
- meta.textContent = `${arr.length} registered`;
925
- root.innerHTML = '';
926
- arr.forEach((p) => {
927
- const div = document.createElement('div');
928
- div.className = 'card';
929
- const tag = p.requiresApiKey
930
- ? '<span class="pill warn">api key</span>'
931
- : '<span class="pill ok">no key</span>';
932
- const customTag = p.custom ? ' <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">custom</span>' : '';
933
- const builtinCompat = p.builtinOpenAICompat ? ' <span class="pill" style="background:rgba(74,222,128,0.12);color:var(--ok);">openai-compat</span>' : '';
934
- const models = (p.suggestedModels || []).slice(0, 6).join(' · ') || '<span class="dim">(default)</span>';
935
- const removeBtn = p.custom
936
- ? `<button class="btn btn-danger btn-sm" data-action="remove">Remove</button>`
937
- : '';
938
- div.innerHTML = `<div class="row" style="border:0;padding:0;">
939
- <div class="name">${escHtml(p.name)}</div>${tag}${customTag}${builtinCompat}
940
- <div class="dim row-actions">${escHtml(p.endpoint || '')}</div>
941
- <button class="btn btn-secondary btn-sm" data-action="test">Test</button>
942
- ${removeBtn}
943
- </div>
944
- <div class="dim" style="margin-top:6px;">${escHtml(p.docs || '')}</div>
945
- <div style="margin-top:8px;font-size:12px;">${models}</div>
946
- <div class="dim" data-test-result style="margin-top:6px;font-size:11px;"></div>`;
947
- div.addEventListener('click', async (e) => {
948
- const btn = e.target.closest('button');
949
- if (!btn) return;
950
- if (btn.dataset.action === 'test') return testProvider(p.name, div);
951
- if (btn.dataset.action === 'remove') return removeProvider(p.name);
952
- });
953
- root.appendChild(div);
954
- });
955
- } catch (e) {
956
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
957
- }
958
- };
959
-
960
- async function testProvider(name, cardEl) {
961
- const out = cardEl.querySelector('[data-test-result]');
962
- out.textContent = '⏳ probing…';
963
- out.style.color = 'var(--dim)';
964
- try {
965
- const r = await fetch('/providers/' + encodeURIComponent(name) + '/test');
966
- const body = await r.json();
967
- if (body.ok) {
968
- out.style.color = 'var(--ok)';
969
- const reply = (body.reply || '').replace(/\s+/g, ' ').slice(0, 120);
970
- out.textContent = `✓ ok · ${body.model} · ${body.durationMs}ms${reply ? ' · ' + reply : ''}`;
971
- } else {
972
- out.style.color = 'var(--err)';
973
- out.textContent = `✗ ${body.error || 'failed'} · ${body.code || r.status}`;
974
- }
975
- } catch (e) {
976
- out.style.color = 'var(--err)';
977
- out.textContent = '✗ ' + (e.message || String(e));
978
- }
979
- }
980
-
981
- async function removeProvider(name) {
982
- if (!confirm(`Remove custom provider "${name}"?`)) return;
983
- try {
984
- const r = await fetch('/providers/' + encodeURIComponent(name), { method: 'DELETE' });
985
- const body = await r.json();
986
- if (!r.ok) throw new Error(body.error || `${r.status}`);
987
- LOADERS.providers();
988
- } catch (e) {
989
- alert('Remove failed: ' + e.message);
990
- }
991
- }
992
-
993
- function openAddProviderModal() {
994
- openModal({
995
- title: 'Add custom OpenAI-compat provider',
996
- bodyHtml: `
997
- <div class="dim" style="margin-bottom:14px;font-size:12px;">
998
- Works with any service that speaks the OpenAI v1 wire format
999
- (vLLM · LM Studio · private gateways · self-hosted DeepInfra).
1000
- Built-in aliases (<code>nim</code>, <code>openrouter</code>, <code>groq</code>, …)
1001
- can be overridden by registering a custom entry of the same name.
1002
- </div>
1003
- <div class="form-row">
1004
- <label for="add-prov-name">Name (short id, e.g. "nim", "openrouter")</label>
1005
- <input id="add-prov-name" autofocus placeholder="e.g. my-vllm" />
1006
- </div>
1007
- <div class="form-row">
1008
- <label for="add-prov-baseurl">Base URL (must end in /v1)</label>
1009
- <input id="add-prov-baseurl" placeholder="https://integrate.api.nvidia.com/v1" />
1010
- </div>
1011
- <div class="form-row">
1012
- <label for="add-prov-apikey">API key (blank for auth-less endpoints)</label>
1013
- <input id="add-prov-apikey" type="password" placeholder="nvapi-…" />
1014
- </div>
1015
- <div class="form-row">
1016
- <label for="add-prov-model">Default model id (optional)</label>
1017
- <input id="add-prov-model" placeholder="meta/llama-3.1-405b-instruct" />
1018
- </div>
1019
- <div id="add-prov-status" class="dim" style="font-size:12px;"></div>
1020
- `,
1021
- footHtml: `
1022
- <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
1023
- <button class="btn" onclick="submitAddProvider()">Save</button>
1024
- `,
1025
- });
1026
- }
1027
-
1028
- async function submitAddProvider() {
1029
- const name = document.getElementById('add-prov-name').value.trim();
1030
- const baseUrl = document.getElementById('add-prov-baseurl').value.trim();
1031
- const apiKey = document.getElementById('add-prov-apikey').value.trim();
1032
- const defaultModel = document.getElementById('add-prov-model').value.trim();
1033
- const status = document.getElementById('add-prov-status');
1034
- status.style.color = 'var(--dim)';
1035
- status.textContent = 'Saving…';
1036
- try {
1037
- const r = await fetch('/providers', {
1038
- method: 'POST',
1039
- headers: { 'content-type': 'application/json' },
1040
- body: JSON.stringify({ name, baseUrl, apiKey: apiKey || undefined, defaultModel: defaultModel || undefined }),
1041
- });
1042
- const body = await r.json();
1043
- if (!r.ok) {
1044
- status.style.color = 'var(--err)';
1045
- status.textContent = '✗ ' + (body.error || `${r.status} ${r.statusText}`);
1046
- return;
1047
- }
1048
- status.style.color = 'var(--ok)';
1049
- const overrideNote = body.overridesBuiltin ? ' (overrides built-in)' : '';
1050
- status.textContent = `✓ saved — ${body.name} → ${body.baseUrl}${overrideNote}`;
1051
- setTimeout(() => { closeModal(); LOADERS.providers(); }, 700);
1052
- } catch (e) {
1053
- status.style.color = 'var(--err)';
1054
- status.textContent = '✗ ' + (e.message || String(e));
1055
- }
1056
- }
1057
-
1058
- LOADERS.status = async function loadStatus() {
1059
- const root = document.getElementById('status-card');
1060
- root.innerHTML = '<div class="empty">Loading…</div>';
1061
- try {
1062
- const r = await api('/status');
1063
- // v5 one-line banner under the JSON card.
1064
- const v5 = r.v5 || {};
1065
- const trainer = v5.trainer || {};
1066
- const banner = `<div class="banner ok" style="margin-bottom:10px;">
1067
- <strong>v5:</strong>
1068
- trainer: <code>${escHtml(trainer.provider || 'off')}/${escHtml(trainer.model || '-')}</code>
1069
- · index: <code>${escHtml(String(v5.indexRows ?? '?'))} rows</code>
1070
- · sandbox: <code>${escHtml(v5.sandboxBackend || 'local')}</code>
1071
- ${v5.migrateBackup ? ` · backup: <code>${escHtml(v5.migrateBackup)}</code>` : ''}
1072
- </div>`;
1073
- root.innerHTML = banner + `<div class="card"><pre>${JSON.stringify(r, null, 2)}</pre></div>`;
1074
- } catch (e) {
1075
- root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
1076
- }
1077
- };
1078
-
1079
- // ── Workflows ────────────────────────────────────────────────
1080
- document.getElementById('wf-status').addEventListener('change', () => LOADERS.workflows());
1081
- document.getElementById('wf-filter').addEventListener('input', debounce(() => LOADERS.workflows(), 250));
1082
- function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
1083
-
1084
- LOADERS.workflows = async function loadWorkflows() {
1085
- const list = document.getElementById('wf-list');
1086
- const grid = document.getElementById('wf-summary');
1087
- const meta = document.getElementById('wf-meta');
1088
- list.innerHTML = '<div class="empty">Loading…</div>';
1089
- grid.innerHTML = '';
1090
- try {
1091
- const status = document.getElementById('wf-status').value;
1092
- const filter = document.getElementById('wf-filter').value.trim();
1093
- const qs = new URLSearchParams();
1094
- if (status) qs.set('status', status);
1095
- if (filter) qs.set('filter', filter);
1096
- const url = '/workflows' + (qs.toString() ? '?' + qs : '');
1097
- const [r, agg] = await Promise.all([api(url), api('/workflows/aggregate').catch(() => null)]);
1098
- const sessions = r.sessions || [];
1099
- meta.textContent = `${sessions.length} session${sessions.length === 1 ? '' : 's'} · dir ${r.dir || '?'}`;
1100
- const counts = sessions.reduce((acc, s) => {
1101
- const sm = s.summary || {};
1102
- if (sm.done) acc.done++;
1103
- if (sm.resumable) acc.resumable++;
1104
- if (sm.failed > 0) acc.failed++;
1105
- if (sm.running > 0) acc.running++;
1106
- return acc;
1107
- }, { done: 0, resumable: 0, failed: 0, running: 0 });
1108
- grid.innerHTML = `
1109
- <div class="stat"><div class="label">Total</div><div class="value">${sessions.length}</div></div>
1110
- <div class="stat"><div class="label">Running</div><div class="value">${counts.running}</div></div>
1111
- <div class="stat"><div class="label">Resumable</div><div class="value">${counts.resumable}</div></div>
1112
- <div class="stat"><div class="label">Failed</div><div class="value" style="color:${counts.failed ? 'var(--err)' : 'inherit'};">${counts.failed}</div></div>
1113
- <div class="stat"><div class="label">Done</div><div class="value" style="color:${counts.done ? 'var(--ok)' : 'inherit'};">${counts.done}</div></div>
1114
- ${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>` : ''}
1115
- `;
1116
- if (sessions.length === 0) {
1117
- list.innerHTML = '<div class="empty">No workflow runs yet. Run one with <code>lazyclaw run &lt;id&gt; ./flow.mjs</code>.</div>';
1118
- return;
1119
- }
1120
- const rows = sessions.map((s) => {
1121
- const sm = s.summary || {};
1122
- const tags = [];
1123
- if (sm.running > 0) tags.push('<span class="pill warn">running</span>');
1124
- if (sm.failed > 0) tags.push('<span class="pill err">failed</span>');
1125
- if (sm.resumable) tags.push('<span class="pill warn">resumable</span>');
1126
- if (sm.done) tags.push('<span class="pill ok">done</span>');
1127
- const total = sm.total ?? '';
1128
- return `<tr class="clickable" data-wf-id="${escHtml(s.sessionId)}">
1129
- <td><code>${escHtml(s.sessionId)}</code></td>
1130
- <td>${tags.join(' ') || '<span class="dim">—</span>'}</td>
1131
- <td class="num">${sm.done ?? 0} / ${total}</td>
1132
- <td class="num">${sm.failed ?? 0}</td>
1133
- <td class="dim">${escHtml(s.updatedAt || s.startedAt || '')}</td>
1134
- <td><button class="btn btn-danger btn-sm" data-action="wf-delete">Delete</button></td>
1135
- </tr>`;
1136
- }).join('');
1137
- list.innerHTML = `<table class="tbl">
1138
- <thead><tr><th>Session</th><th>State</th><th>Done / Total</th><th>Failed</th><th>Updated</th><th></th></tr></thead>
1139
- <tbody>${rows}</tbody>
1140
- </table>`;
1141
- list.querySelectorAll('tr[data-wf-id]').forEach((tr) => {
1142
- tr.addEventListener('click', (e) => {
1143
- const id = tr.getAttribute('data-wf-id');
1144
- const action = e.target.closest('button')?.dataset.action;
1145
- if (action === 'wf-delete') return deleteWorkflow(id);
1146
- return openWorkflowDetail(id);
1147
- });
1148
- });
1149
- } catch (e) {
1150
- list.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1151
- }
1152
- };
1153
-
1154
- async function openWorkflowDetail(id) {
1155
- openModal({ title: `Workflow — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
1156
- try {
1157
- const r = await api('/workflows/' + encodeURIComponent(id));
1158
- const sm = r.summary || {};
1159
- const nodes = r.state?.nodeResults || r.nodeResults || {};
1160
- const nodeRows = Object.entries(nodes).map(([nid, n]) => {
1161
- const status = (n.status || '').toLowerCase();
1162
- const pillClass = status === 'failed' ? 'err' : (status === 'done' ? 'ok' : (status === 'running' ? 'warn' : ''));
1163
- const dur = n.durationMs != null ? fmtDuration(n.durationMs) : '—';
1164
- const out = String(n.output ?? n.error ?? '');
1165
- const truncated = out.length > 240 ? out.slice(0, 240) + '…' : out;
1166
- return `<tr>
1167
- <td><code>${escHtml(nid)}</code></td>
1168
- <td>${pillClass ? `<span class="pill ${pillClass}">${escHtml(status)}</span>` : escHtml(status || '—')}</td>
1169
- <td class="num">${dur}</td>
1170
- <td class="dim">${escHtml(truncated)}</td>
1171
- </tr>`;
1172
- }).join('');
1173
- const summaryHtml = `<div class="grid" style="margin-bottom:14px;">
1174
- <div class="stat"><div class="label">Total</div><div class="value">${sm.total ?? '—'}</div></div>
1175
- <div class="stat"><div class="label">Done</div><div class="value">${sm.done ?? 0}</div></div>
1176
- <div class="stat"><div class="label">Failed</div><div class="value" style="color:${sm.failed ? 'var(--err)' : 'inherit'}">${sm.failed ?? 0}</div></div>
1177
- <div class="stat"><div class="label">Running</div><div class="value">${sm.running ?? 0}</div></div>
1178
- </div>`;
1179
- const tableHtml = nodeRows
1180
- ? `<table class="tbl">
1181
- <thead><tr><th>Node</th><th>Status</th><th>Duration</th><th>Output / Error</th></tr></thead>
1182
- <tbody>${nodeRows}</tbody>
1183
- </table>`
1184
- : '<div class="empty">No node results yet.</div>';
1185
- document.getElementById('modal-body').innerHTML = summaryHtml + tableHtml;
1186
- } catch (e) {
1187
- document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1188
- }
1189
- }
1190
-
1191
- async function deleteWorkflow(id) {
1192
- if (!confirm(`Delete workflow session "${id}"?\nState file will be permanently removed.`)) return;
1193
- try {
1194
- await fetch('/workflows/' + encodeURIComponent(id), { method: 'DELETE' });
1195
- LOADERS.workflows();
1196
- } catch (e) {
1197
- alert('Delete failed: ' + e.message);
1198
- }
1199
- }
1200
-
1201
- // ── Rates ────────────────────────────────────────────────────
1202
- document.getElementById('rates-filter').addEventListener('input', debounce(() => LOADERS.rates(), 250));
1203
-
1204
- LOADERS.rates = async function loadRates() {
1205
- const root = document.getElementById('rates-table');
1206
- const meta = document.getElementById('rates-meta');
1207
- const banner = document.getElementById('rates-validate');
1208
- root.innerHTML = '<div class="empty">Loading…</div>';
1209
- banner.innerHTML = '';
1210
- try {
1211
- const filter = document.getElementById('rates-filter').value.trim();
1212
- const url = '/rates' + (filter ? '?filter=' + encodeURIComponent(filter) : '');
1213
- const [rates, validate] = await Promise.all([api(url), apiSoft('/rates/validate')]);
1214
- const entries = Object.entries(rates || {});
1215
- meta.textContent = `${entries.length} card${entries.length === 1 ? '' : 's'}`;
1216
- // Validation banner
1217
- if (validate.body) {
1218
- const v = validate.body;
1219
- const issues = (v.issues || []).map((i) => `<li>${escHtml(typeof i === 'string' ? i : JSON.stringify(i))}</li>`).join('');
1220
- const warnings = (v.warnings || []).map((w) => `<li>${escHtml(typeof w === 'string' ? w : JSON.stringify(w))}</li>`).join('');
1221
- if (v.ok && !warnings) {
1222
- banner.innerHTML = '<div class="banner ok">All rate cards valid.</div>';
1223
- } else {
1224
- const cls = v.ok ? 'warn' : 'err';
1225
- banner.innerHTML = `<div class="banner ${cls}">
1226
- <div><strong>${v.ok ? 'Warnings' : 'Validation issues'}</strong>
1227
- <ul>${issues}${warnings}</ul>
1228
- </div>
1229
- </div>`;
1230
- }
1231
- }
1232
- if (entries.length === 0) {
1233
- 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>';
1234
- return;
1235
- }
1236
- const rows = entries.map(([key, card]) => {
1237
- const c = card || {};
1238
- return `<tr data-rate-key="${escHtml(key)}">
1239
- <td><code>${escHtml(key)}</code></td>
1240
- <td class="num">${c.in ?? '—'}</td>
1241
- <td class="num">${c.out ?? '—'}</td>
1242
- <td class="num">${c['cache-read'] ?? '—'}</td>
1243
- <td class="num">${c['cache-create'] ?? '—'}</td>
1244
- <td class="dim">${escHtml(c.currency || 'USD')} / 1M tok</td>
1245
- <td>
1246
- <button class="btn btn-secondary btn-sm" data-action="rate-edit">Edit</button>
1247
- <button class="btn btn-danger btn-sm" data-action="rate-delete">Delete</button>
1248
- </td>
1249
- </tr>`;
1250
- }).join('');
1251
- root.innerHTML = `<table class="tbl">
1252
- <thead><tr><th>Provider / Model</th><th>In</th><th>Out</th><th>Cache read</th><th>Cache create</th><th>Unit</th><th></th></tr></thead>
1253
- <tbody>${rows}</tbody>
1254
- </table>`;
1255
- root.querySelectorAll('tr[data-rate-key]').forEach((tr) => {
1256
- const key = tr.getAttribute('data-rate-key');
1257
- const card = (rates || {})[key] || {};
1258
- tr.querySelector('[data-action="rate-edit"]')?.addEventListener('click', () => openRateCardModal(key, card));
1259
- tr.querySelector('[data-action="rate-delete"]')?.addEventListener('click', () => deleteRateCard(key));
1260
- });
1261
- } catch (e) {
1262
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1263
- }
1264
- };
1265
-
1266
- function openRateCardModal(existingKey = '', existingCard = {}) {
1267
- const c = existingCard || {};
1268
- openModal({
1269
- title: existingKey ? `Edit rate card — ${existingKey}` : 'Add rate card',
1270
- bodyHtml: `
1271
- <div class="dim" style="margin-bottom:12px;font-size:12px;">
1272
- Cost per 1M tokens (input / output / optional cache pricing).
1273
- Same shape as <code>lazyclaw rates set</code>. Saving the same
1274
- key overwrites the existing card.
1275
- </div>
1276
- <div class="form-row">
1277
- <label for="rate-key">Provider / model key</label>
1278
- <input id="rate-key" placeholder="anthropic/claude-opus-4-7" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
1279
- </div>
1280
- <div class="grid" style="grid-template-columns:1fr 1fr;gap:10px;margin-bottom:0;">
1281
- <div class="form-row"><label for="rate-in">Input (USD / 1M)</label><input id="rate-in" type="number" step="0.01" value="${c.in ?? ''}"/></div>
1282
- <div class="form-row"><label for="rate-out">Output (USD / 1M)</label><input id="rate-out" type="number" step="0.01" value="${c.out ?? ''}"/></div>
1283
- <div class="form-row"><label for="rate-cache-read">Cache read (optional)</label><input id="rate-cache-read" type="number" step="0.01" value="${c['cache-read'] ?? ''}"/></div>
1284
- <div class="form-row"><label for="rate-cache-create">Cache create (optional)</label><input id="rate-cache-create" type="number" step="0.01" value="${c['cache-create'] ?? ''}"/></div>
1285
- <div class="form-row"><label for="rate-currency">Currency</label><input id="rate-currency" value="${escHtml(c.currency || 'USD')}"/></div>
1286
- </div>
1287
- <div id="rate-status" class="dim" style="font-size:12px;margin-top:8px;"></div>
1288
- `,
1289
- footHtml: `
1290
- <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
1291
- <button class="btn" onclick="submitRateCard()">Save</button>
1292
- `,
1293
- });
1294
- }
1295
-
1296
- async function submitRateCard() {
1297
- const key = document.getElementById('rate-key').value.trim();
1298
- const status = document.getElementById('rate-status');
1299
- if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
1300
- const card = {
1301
- in: parseFloat(document.getElementById('rate-in').value) || 0,
1302
- out: parseFloat(document.getElementById('rate-out').value) || 0,
1303
- currency: document.getElementById('rate-currency').value.trim() || 'USD',
1304
- };
1305
- const cr = parseFloat(document.getElementById('rate-cache-read').value);
1306
- const cc = parseFloat(document.getElementById('rate-cache-create').value);
1307
- if (Number.isFinite(cr)) card['cache-read'] = cr;
1308
- if (Number.isFinite(cc)) card['cache-create'] = cc;
1309
- status.style.color = 'var(--dim)';
1310
- status.textContent = 'Saving…';
1311
- try {
1312
- const r = await fetch('/rates/' + encodeURIComponent(key), {
1313
- method: 'PUT',
1314
- headers: { 'content-type': 'application/json' },
1315
- body: JSON.stringify(card),
1316
- });
1317
- const body = await r.json();
1318
- if (!r.ok) {
1319
- status.style.color = 'var(--err)';
1320
- const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
1321
- status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
1322
- return;
1323
- }
1324
- status.style.color = 'var(--ok)';
1325
- status.textContent = `✓ saved`;
1326
- setTimeout(() => { closeModal(); LOADERS.rates(); }, 600);
1327
- } catch (e) {
1328
- status.style.color = 'var(--err)';
1329
- status.textContent = '✗ ' + (e.message || String(e));
1330
- }
1331
- }
1332
-
1333
- async function deleteRateCard(key) {
1334
- if (!confirm(`Delete rate card "${key}"?`)) return;
1335
- try {
1336
- const r = await fetch('/rates/' + encodeURIComponent(key), { method: 'DELETE' });
1337
- if (!r.ok) {
1338
- const body = await r.json().catch(() => ({}));
1339
- throw new Error(body.error || `${r.status}`);
1340
- }
1341
- LOADERS.rates();
1342
- } catch (e) {
1343
- alert('Delete failed: ' + e.message);
1344
- }
1345
- }
1346
-
1347
- // ── Metrics ──────────────────────────────────────────────────
1348
- LOADERS.metrics = async function loadMetrics() {
1349
- const cards = document.getElementById('metrics-cards');
1350
- const detail = document.getElementById('metrics-detail');
1351
- const meta = document.getElementById('metrics-meta');
1352
- cards.innerHTML = '';
1353
- detail.innerHTML = '<div class="empty">Loading…</div>';
1354
- try {
1355
- const m = await api('/metrics');
1356
- meta.textContent = m.timestamp ? new Date(m.timestamp).toLocaleString() : '';
1357
- const cache = m.cache || { hits: 0, misses: 0, size: 0 };
1358
- const totalCache = (cache.hits || 0) + (cache.misses || 0);
1359
- const hitRate = totalCache > 0 ? ((cache.hits / totalCache) * 100).toFixed(1) + '%' : '—';
1360
- const tokens = m.tokensTotal || {};
1361
- const tokIn = tokens.inputTokens || tokens.input || tokens.in || 0;
1362
- const tokOut = tokens.outputTokens || tokens.output || tokens.out || 0;
1363
- const wf = m.workflows || {};
1364
- const costs = m.costsByCurrency || {};
1365
- const costPairs = Object.entries(costs);
1366
- const costStr = costPairs.length ? costPairs.map(([cur, n]) => `${n.toFixed(4)} ${cur}`).join(' · ') : '—';
1367
- cards.innerHTML = `
1368
- <div class="stat"><div class="label">Uptime</div><div class="value">${fmtDuration(m.uptimeMs)}</div></div>
1369
- <div class="stat"><div class="label">Requests</div><div class="value">${m.requestsTotal ?? 0}</div><div class="sub">denied ${m.rateLimitDenied ?? 0}</div></div>
1370
- <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>
1371
- <div class="stat"><div class="label">Tokens (in / out)</div><div class="value">${tokIn.toLocaleString()} / ${tokOut.toLocaleString()}</div></div>
1372
- <div class="stat"><div class="label">Cost</div><div class="value" style="font-size:16px;">${costStr}</div></div>
1373
- ${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>` : ''}
1374
- `;
1375
- const byStatus = m.requestsByStatus || {};
1376
- const statusRows = Object.keys(byStatus).sort().map((s) => `<tr><td>${escHtml(s)}</td><td class="num">${byStatus[s]}</td></tr>`).join('');
1377
- detail.innerHTML = `<div class="card">
1378
- <div class="dim" style="margin-bottom:6px;">Requests by status</div>
1379
- ${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>'}
1380
- </div>`;
1381
- } catch (e) {
1382
- detail.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1383
- }
1384
- };
1385
-
1386
- // ── Doctor ───────────────────────────────────────────────────
1387
- LOADERS.doctor = async function loadDoctor() {
1388
- const root = document.getElementById('doctor-card');
1389
- const meta = document.getElementById('doctor-meta');
1390
- root.innerHTML = '<div class="empty">Running…</div>';
1391
- const r = await apiSoft('/doctor');
1392
- const d = r.body || {};
1393
- meta.textContent = d.timestamp ? new Date(d.timestamp).toLocaleString() : '';
1394
- const issues = d.issues || [];
1395
- const okBanner = d.ok
1396
- ? '<div class="banner ok"><strong>All checks passed.</strong></div>'
1397
- : `<div class="banner err"><div><strong>${issues.length} issue${issues.length === 1 ? '' : 's'}:</strong>
1398
- <ul>${issues.map((i) => `<li>${escHtml(i)}</li>`).join('')}</ul>
1399
- </div></div>`;
1400
- // v5 index integrity row.
1401
- const idx = d.index || null;
1402
- let idxRow = '';
1403
- if (idx) {
1404
- const status = idx.ok
1405
- ? '<span class="pill ok">ok</span>'
1406
- : '<span class="pill err">degraded</span>';
1407
- const rowCounts = idx.rowCounts
1408
- ? Object.entries(idx.rowCounts).map(([k, v]) => `${k}=${v}`).join(' · ')
1409
- : '';
1410
- const rebuildBtn = idx.ok
1411
- ? ''
1412
- : '<button class="btn btn-danger btn-sm" onclick="rebuildIndex()" style="margin-left:8px;">Rebuild</button>';
1413
- idxRow = `<div class="row">
1414
- <div class="name">FTS5 index</div>
1415
- <div class="dim" style="margin-left:auto;">${status} <span class="dim">${escHtml(rowCounts)}</span>${rebuildBtn}</div>
1416
- </div>`;
1417
- }
1418
- root.innerHTML = okBanner + `
1419
- <div class="card">
1420
- <div class="row"><div class="name">Provider</div><div class="dim" style="margin-left:auto;">${escHtml(d.provider || '—')}</div></div>
1421
- <div class="row"><div class="name">Model</div><div class="dim" style="margin-left:auto;">${escHtml(d.model || '—')}</div></div>
1422
- <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>
1423
- <div class="row"><div class="name">Node</div><div class="dim" style="margin-left:auto;">${escHtml(d.nodeVersion || '—')}</div></div>
1424
- <div class="row"><div class="name">Platform</div><div class="dim" style="margin-left:auto;">${escHtml(d.platform || '—')}</div></div>
1425
- ${idxRow}
1426
- <div class="row"><div class="name">Known providers</div><div class="dim" style="margin-left:auto;">${(d.knownProviders || []).map(escHtml).join(' · ') || '—'}</div></div>
1427
- </div>`;
1428
- };
1429
-
1430
- async function rebuildIndex() {
1431
- if (!confirm('Rebuild the FTS5 index? Existing index.db will be deleted and recreated empty.')) return;
1432
- try {
1433
- const r = await fetch('/index/rebuild', { method: 'POST' });
1434
- const body = await r.json().catch(() => ({}));
1435
- if (!r.ok) { alert('Rebuild failed: ' + (body.error || r.statusText)); return; }
1436
- alert('Index rebuilt. Re-run Doctor to confirm.');
1437
- LOADERS.doctor();
1438
- } catch (e) {
1439
- alert('Rebuild failed: ' + e.message);
1440
- }
1441
- }
1442
-
1443
- // ── Config ───────────────────────────────────────────────────
1444
- LOADERS.config = async function loadConfig() {
1445
- const root = document.getElementById('config-table');
1446
- const banner = document.getElementById('config-validate');
1447
- const raw = document.getElementById('config-raw');
1448
- const meta = document.getElementById('config-meta');
1449
- root.innerHTML = '<div class="empty">Loading…</div>';
1450
- banner.innerHTML = '';
1451
- raw.textContent = '';
1452
- try {
1453
- const [cfg, validate] = await Promise.all([api('/config'), apiSoft('/config/validate')]);
1454
- const keys = Object.keys(cfg);
1455
- meta.textContent = `${keys.length} key${keys.length === 1 ? '' : 's'}`;
1456
- if (validate.body) {
1457
- const v = validate.body;
1458
- const issues = (v.issues || []).map((i) => `<li>${escHtml(typeof i === 'string' ? i : JSON.stringify(i))}</li>`).join('');
1459
- const warnings = (v.warnings || []).map((w) => `<li>${escHtml(typeof w === 'string' ? w : JSON.stringify(w))}</li>`).join('');
1460
- if (v.ok && !warnings) {
1461
- banner.innerHTML = '<div class="banner ok">Config valid.</div>';
1462
- } else {
1463
- const cls = v.ok ? 'warn' : 'err';
1464
- banner.innerHTML = `<div class="banner ${cls}"><div><strong>${v.ok ? 'Warnings' : 'Validation issues'}</strong><ul>${issues}${warnings}</ul></div></div>`;
1465
- }
1466
- }
1467
- if (keys.length === 0) {
1468
- root.innerHTML = '<div class="empty">No config yet. Run <code>lazyclaw onboard</code>.</div>';
1469
- return;
1470
- }
1471
- const NESTED = new Set(['customProviders', 'rates', 'authProfiles', 'authActiveProfile']);
1472
- const rows = keys.sort().map((k) => {
1473
- const v = cfg[k];
1474
- const display = v && typeof v === 'object' ? JSON.stringify(v) : String(v);
1475
- const nested = NESTED.has(k);
1476
- const editBtn = nested
1477
- ? `<span class="dim" style="font-size:11px;">use the dedicated tab</span>`
1478
- : `<button class="btn btn-secondary btn-sm" data-action="cfg-edit" data-key="${escHtml(k)}">Edit</button>
1479
- <button class="btn btn-danger btn-sm" data-action="cfg-delete" data-key="${escHtml(k)}">Delete</button>`;
1480
- return `<tr><td><code>${escHtml(k)}</code></td><td>${escHtml(display)}</td><td>${editBtn}</td></tr>`;
1481
- }).join('');
1482
- root.innerHTML = `<table class="tbl">
1483
- <thead><tr><th style="width:25%">Key</th><th>Value</th><th style="width:160px"></th></tr></thead>
1484
- <tbody>${rows}</tbody>
1485
- </table>`;
1486
- root.querySelectorAll('[data-action="cfg-edit"]').forEach((b) => {
1487
- b.addEventListener('click', () => openConfigEditModal(b.dataset.key, cfg[b.dataset.key]));
1488
- });
1489
- root.querySelectorAll('[data-action="cfg-delete"]').forEach((b) => {
1490
- b.addEventListener('click', () => deleteConfigKey(b.dataset.key));
1491
- });
1492
- raw.textContent = JSON.stringify(cfg, null, 2);
1493
- } catch (e) {
1494
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1495
- }
1496
- };
1497
-
1498
- function openConfigEditModal(existingKey = '', existingValue = '') {
1499
- // Stringify for the editor; objects/arrays become JSON, primitives stay
1500
- // raw. Submitter parses JSON when the value looks like JSON, else
1501
- // sends a string verbatim — same behaviour as `lazyclaw config set`.
1502
- let display = '';
1503
- if (typeof existingValue === 'string') display = existingValue;
1504
- else if (existingValue == null) display = '';
1505
- else display = JSON.stringify(existingValue, null, 2);
1506
- openModal({
1507
- title: existingKey ? `Edit config — ${existingKey}` : 'Set config key',
1508
- bodyHtml: `
1509
- <div class="dim" style="margin-bottom:12px;font-size:12px;">
1510
- Mirrors <code>lazyclaw config set &lt;key&gt; &lt;value&gt;</code>. Values that look like
1511
- JSON (start with <code>{</code> / <code>[</code> / <code>"</code> / <code>true</code> / <code>false</code> / a number)
1512
- are parsed; everything else is stored as a plain string. Nested
1513
- stores (<code>customProviders</code>, <code>rates</code>, <code>authProfiles</code>) have their own
1514
- tabs — this form rejects them.
1515
- </div>
1516
- <div class="form-row">
1517
- <label for="cfg-key">Key</label>
1518
- <input id="cfg-key" placeholder="provider · model · api-key · skills · …" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
1519
- </div>
1520
- <div class="form-row">
1521
- <label for="cfg-value">Value</label>
1522
- <textarea id="cfg-value" rows="6">${escHtml(display)}</textarea>
1523
- </div>
1524
- <div id="cfg-status" class="dim" style="font-size:12px;"></div>
1525
- `,
1526
- footHtml: `
1527
- <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
1528
- <button class="btn" onclick="submitConfigEdit()">Save</button>
1529
- `,
1530
- });
1531
- }
1532
-
1533
- async function submitConfigEdit() {
1534
- const key = document.getElementById('cfg-key').value.trim();
1535
- const raw = document.getElementById('cfg-value').value;
1536
- const status = document.getElementById('cfg-status');
1537
- if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
1538
- // Heuristic JSON parse — same surface as the CLI: try parse; if it
1539
- // throws, send the raw string. Numbers / true / false / null / objects /
1540
- // arrays / quoted strings end up correctly typed.
1541
- let value;
1542
- const trimmed = raw.trim();
1543
- if (trimmed === '') value = '';
1544
- else {
1545
- try { value = JSON.parse(trimmed); }
1546
- catch { value = raw; }
1547
- }
1548
- status.style.color = 'var(--dim)';
1549
- status.textContent = 'Saving…';
1550
- try {
1551
- const r = await fetch('/config/' + encodeURIComponent(key), {
1552
- method: 'PUT',
1553
- headers: { 'content-type': 'application/json' },
1554
- body: JSON.stringify({ value }),
1555
- });
1556
- const body = await r.json();
1557
- if (!r.ok) {
1558
- status.style.color = 'var(--err)';
1559
- const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
1560
- status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
1561
- return;
1562
- }
1563
- status.style.color = 'var(--ok)';
1564
- status.textContent = '✓ saved';
1565
- setTimeout(() => { closeModal(); LOADERS.config(); }, 600);
1566
- } catch (e) {
1567
- status.style.color = 'var(--err)';
1568
- status.textContent = '✗ ' + (e.message || String(e));
1569
- }
1570
- }
1571
-
1572
- async function deleteConfigKey(key) {
1573
- if (!confirm(`Delete config key "${key}"?`)) return;
1574
- try {
1575
- const r = await fetch('/config/' + encodeURIComponent(key), { method: 'DELETE' });
1576
- if (!r.ok) {
1577
- const body = await r.json().catch(() => ({}));
1578
- throw new Error(body.error || `${r.status}`);
1579
- }
1580
- LOADERS.config();
1581
- } catch (e) {
1582
- alert('Delete failed: ' + e.message);
1583
- }
1584
- }
1585
-
1586
- // ── Multi-agent loaders (Phase 15) ────────────────────────────
1587
- // Keep these minimal — list view + prompt-driven create. Phase
1588
- // 15.1+ can swap the prompts for inline forms once the data model
1589
- // settles. The point of v0.1 is parity with the CLI, not polish.
1590
-
1591
- function escapeHtml(s) {
1592
- return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
1593
- }
1594
-
1595
- LOADERS.agents = async function loadAgents() {
1596
- const root = document.getElementById('agents-list');
1597
- try {
1598
- const arr = await api('/agents');
1599
- document.getElementById('agents-meta').textContent = `${arr.length} agent(s)`;
1600
- if (arr.length === 0) { root.innerHTML = '<div class="empty">No agents yet — click + New agent to create one.</div>'; return; }
1601
- root.innerHTML = '<table><thead><tr><th>name</th><th>provider/model</th><th>tools</th><th>role (excerpt)</th><th></th></tr></thead><tbody>'
1602
- + arr.map((a) => {
1603
- const provLine = a.model ? `${escapeHtml(a.provider)}/${escapeHtml(a.model)}` : escapeHtml(a.provider);
1604
- const role = a.role ? (a.role.slice(0, 60) + (a.role.length > 60 ? '…' : '')) : '<span class="dim">(none)</span>';
1605
- return `<tr>
1606
- <td><strong>${escapeHtml(a.name)}</strong><br><span class="dim">${escapeHtml(a.displayName || '')}</span></td>
1607
- <td>${provLine}</td>
1608
- <td>${(a.tools || []).map((t) => `<code>${escapeHtml(t)}</code>`).join(' ')}</td>
1609
- <td>${escapeHtml(role)}</td>
1610
- <td><button class="btn btn-secondary" onclick="deleteAgent('${encodeURIComponent(a.name)}')">Delete</button></td>
1611
- </tr>`;
1612
- }).join('')
1613
- + '</tbody></table>';
1614
- } catch (e) {
1615
- root.innerHTML = `<div class="empty">Error: ${escapeHtml(e.message)}</div>`;
1616
- }
1617
- };
1618
-
1619
- async function openAgentModal() {
1620
- const name = (prompt('Agent name (e.g. planner, backend, frontend):') || '').trim();
1621
- if (!name) return;
1622
- const role = prompt('Role / system prompt (optional):') || '';
1623
- const provider = (prompt('Provider (anthropic / openai / gemini / claude-cli):', 'anthropic') || 'anthropic').trim();
1624
- const model = (prompt('Model id (blank = provider default):') || '').trim();
1625
- const toolsRaw = (prompt('Tools (comma-separated):', 'bash,read,write,grep') || '').trim();
1626
- const tools = toolsRaw ? toolsRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
1627
- try {
1628
- await api('/agents', { method: 'POST', body: JSON.stringify({ name, role, provider, model, tools }) });
1629
- LOADERS.agents();
1630
- } catch (e) {
1631
- alert('Create failed: ' + e.message);
1632
- }
1633
- }
1634
-
1635
- async function deleteAgent(encName) {
1636
- const name = decodeURIComponent(encName);
1637
- if (!confirm(`Delete agent "${name}"?`)) return;
1638
- try { await api(`/agents/${encName}`, { method: 'DELETE' }); LOADERS.agents(); }
1639
- catch (e) { alert('Delete failed: ' + e.message); }
1640
- }
1641
-
1642
- LOADERS.teams = async function loadTeams() {
1643
- const root = document.getElementById('teams-list');
1644
- try {
1645
- const arr = await api('/teams');
1646
- document.getElementById('teams-meta').textContent = `${arr.length} team(s)`;
1647
- if (arr.length === 0) { root.innerHTML = '<div class="empty">No teams yet — click + New team to create one.</div>'; return; }
1648
- root.innerHTML = '<table><thead><tr><th>name</th><th>lead</th><th>agents</th><th>slack channel</th><th></th></tr></thead><tbody>'
1649
- + arr.map((t) => `<tr>
1650
- <td><strong>${escapeHtml(t.name)}</strong><br><span class="dim">${escapeHtml(t.displayName || '')}</span></td>
1651
- <td>${escapeHtml(t.lead || '')}</td>
1652
- <td>${(t.agents || []).map((a) => escapeHtml(a)).join(', ')}</td>
1653
- <td>${t.slackChannel ? `<code>${escapeHtml(t.slackChannel)}</code>` : '<span class="dim">(none)</span>'}</td>
1654
- <td><button class="btn btn-secondary" onclick="deleteTeam('${encodeURIComponent(t.name)}')">Delete</button></td>
1655
- </tr>`).join('')
1656
- + '</tbody></table>';
1657
- } catch (e) {
1658
- root.innerHTML = `<div class="empty">Error: ${escapeHtml(e.message)}</div>`;
1659
- }
1660
- };
1661
-
1662
- async function openTeamModal() {
1663
- const name = (prompt('Team name (e.g. shop, growth):') || '').trim();
1664
- if (!name) return;
1665
- const agentsRaw = (prompt('Agents (comma-separated names):') || '').trim();
1666
- if (!agentsRaw) return;
1667
- const agents = agentsRaw.split(',').map((s) => s.trim()).filter(Boolean);
1668
- const lead = (prompt(`Lead (one of ${agents.join(', ')}):`, agents[0]) || agents[0]).trim();
1669
- const slackChannel = (prompt('Slack channel (C… id or #name, optional):') || '').trim();
1670
- try {
1671
- await api('/teams', { method: 'POST', body: JSON.stringify({ name, agents, lead, slackChannel }) });
1672
- LOADERS.teams();
1673
- } catch (e) {
1674
- alert('Create failed: ' + e.message);
1675
- }
1676
- }
1677
-
1678
- async function deleteTeam(encName) {
1679
- const name = decodeURIComponent(encName);
1680
- if (!confirm(`Delete team "${name}"?`)) return;
1681
- try { await api(`/teams/${encName}`, { method: 'DELETE' }); LOADERS.teams(); }
1682
- catch (e) { alert('Delete failed: ' + e.message); }
1683
- }
1684
-
1685
- LOADERS.tasks = async function loadTasks() {
1686
- const root = document.getElementById('tasks-list');
1687
- try {
1688
- const arr = await api('/tasks');
1689
- document.getElementById('tasks-meta').textContent = `${arr.length} task(s) (newest first)`;
1690
- if (arr.length === 0) { root.innerHTML = '<div class="empty">No tasks yet. Run <code>lazyclaw task start --team X --title "..."</code>.</div>'; return; }
1691
- root.innerHTML = '<table><thead><tr><th>id</th><th>title</th><th>team</th><th>lead</th><th>status</th><th>turns</th><th>opened</th><th></th></tr></thead><tbody>'
1692
- + arr.map((t) => `<tr>
1693
- <td><code>${escapeHtml(t.id)}</code></td>
1694
- <td>${escapeHtml(t.title)}</td>
1695
- <td>${escapeHtml(t.team)}</td>
1696
- <td>${escapeHtml(t.lead)}</td>
1697
- <td><span class="status status-${escapeHtml(t.status)}">${escapeHtml(t.status)}</span></td>
1698
- <td>${Array.isArray(t.turns) ? t.turns.length : 0}</td>
1699
- <td><span class="dim">${escapeHtml((t.createdAt || '').slice(0, 19))}</span></td>
1700
- <td>
1701
- ${t.status === 'running' || t.status === 'pending'
1702
- ? `<button class="btn btn-secondary" onclick="closeTask('${encodeURIComponent(t.id)}','done')">Mark done</button>
1703
- <button class="btn btn-secondary" onclick="closeTask('${encodeURIComponent(t.id)}','abandon')">Abandon</button>`
1704
- : ''}
1705
- </td>
1706
- </tr>`).join('')
1707
- + '</tbody></table>';
1708
- } catch (e) {
1709
- root.innerHTML = `<div class="empty">Error: ${escapeHtml(e.message)}</div>`;
1710
- }
1711
- };
1712
-
1713
- async function closeTask(encId, action) {
1714
- const id = decodeURIComponent(encId);
1715
- if (!confirm(`${action === 'done' ? 'Mark done' : 'Abandon'} task ${id}?`)) return;
1716
- try { await api(`/tasks/${encId}/${action}`, { method: 'POST' }); LOADERS.tasks(); }
1717
- catch (e) { alert(`${action} failed: ` + e.message); }
1718
- }
1719
-
1720
- // ── Trainer / Recall / Sandbox / Channels (v5) ───────────────
1721
- LOADERS.trainer = async function loadTrainer() {
1722
- const root = document.getElementById('trainer-card');
1723
- const meta = document.getElementById('trainer-meta');
1724
- root.innerHTML = '<div class="empty">Loading…</div>';
1725
- try {
1726
- const r = await api('/trainer/status');
1727
- meta.textContent = r.lastRunAt ? `last run ${new Date(r.lastRunAt).toLocaleString()}` : 'no runs recorded';
1728
- const pct = (r.budget && r.callsToday != null)
1729
- ? Math.min(100, Math.round((r.callsToday / r.budget) * 100))
1730
- : null;
1731
- root.innerHTML = `<div class="card">
1732
- <div class="row"><div class="name">Provider</div><div class="dim" style="margin-left:auto;">${escHtml(r.provider || '—')}</div></div>
1733
- <div class="row"><div class="name">Model</div><div class="dim" style="margin-left:auto;">${escHtml(r.model || '—')}</div></div>
1734
- <div class="row"><div class="name">Schedule</div><div class="dim" style="margin-left:auto;">${escHtml(r.schedule || 'off')}</div></div>
1735
- <div class="row"><div class="name">Recipe</div><div class="dim" style="margin-left:auto;">${escHtml(r.recipe || 'inherit')}</div></div>
1736
- <div class="row"><div class="name">Calls today</div><div class="dim" style="margin-left:auto;">${r.callsToday ?? 0}${r.budget ? ` / ${r.budget} (${pct}%)` : ''}</div></div>
1737
- </div>
1738
- <div class="toolbar" style="margin-top:10px;">
1739
- <button class="btn" onclick="trainerSync()">Sync now</button>
1740
- <span class="dim" id="trainer-sync-result"></span>
1741
- </div>`;
1742
- } catch (e) {
1743
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1744
- }
1745
- };
1746
- async function trainerSync() {
1747
- const out = document.getElementById('trainer-sync-result');
1748
- out.textContent = '⏳ syncing…';
1749
- try {
1750
- const r = await fetch('/trainer/sync', { method: 'POST' });
1751
- const body = await r.json().catch(() => ({}));
1752
- out.style.color = r.ok ? 'var(--ok)' : 'var(--err)';
1753
- out.textContent = r.ok ? '✓ ' + (body.message || 'queued') : '✗ ' + (body.error || r.statusText);
1754
- } catch (e) {
1755
- out.style.color = 'var(--err)';
1756
- out.textContent = '✗ ' + e.message;
1757
- }
1758
- }
1759
-
1760
- LOADERS.recall = async function loadRecall() {
1761
- const root = document.getElementById('recall-list');
1762
- const meta = document.getElementById('recall-meta');
1763
- const q = (document.getElementById('recall-q').value || '').trim();
1764
- const scope = document.getElementById('recall-scope').value || 'all';
1765
- if (!q) { root.innerHTML = '<div class="empty">Enter a query above.</div>'; meta.textContent = ''; return; }
1766
- root.innerHTML = '<div class="empty">Searching…</div>';
1767
- try {
1768
- const qs = new URLSearchParams({ q });
1769
- if (scope && scope !== 'all') qs.set('scope', scope);
1770
- const r = await api('/recall?' + qs.toString());
1771
- const hits = r.hits || [];
1772
- meta.textContent = `${hits.length} hit${hits.length === 1 ? '' : 's'} · ${r.latencyMs?.toFixed(1) ?? '?'} ms`;
1773
- if (!hits.length) { root.innerHTML = '<div class="empty">No matches.</div>'; return; }
1774
- root.innerHTML = hits.map((h) => {
1775
- const md = h.metadata || {};
1776
- const label = md.session_id || md.skill_name || md.trajectory_id || md.topic || '—';
1777
- return `<div class="card">
1778
- <div class="row" style="border:0;padding:0;">
1779
- <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">${escHtml(h.scope)}</span>
1780
- <div class="name" style="margin-left:8px;">${escHtml(String(label))}</div>
1781
- <div class="dim row-actions">bm25 ${Number(h.bm25 || 0).toFixed(2)}</div>
1782
- </div>
1783
- <div class="dim" style="margin-top:6px;font-size:12px;">${escHtml(h.snippet || '').replace(/&lt;mark&gt;/g,'<mark>').replace(/&lt;\/mark&gt;/g,'</mark>')}</div>
1784
- </div>`;
1785
- }).join('');
1786
- } catch (e) {
1787
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1788
- }
1789
- };
1790
- // Wire Enter-to-search.
1791
- document.getElementById('recall-q').addEventListener('keydown', (e) => {
1792
- if (e.key === 'Enter') { e.preventDefault(); LOADERS.recall(); }
1793
- });
1794
-
1795
- LOADERS.sandbox = async function loadSandbox() {
1796
- const root = document.getElementById('sandbox-list');
1797
- const meta = document.getElementById('sandbox-meta');
1798
- root.innerHTML = '<div class="empty">Loading…</div>';
1799
- try {
1800
- const r = await api('/sandbox');
1801
- const profiles = r.profiles || [];
1802
- meta.textContent = `active: ${r.active || 'local'}`;
1803
- root.innerHTML = profiles.map((p) => {
1804
- const isActive = p.name === r.active;
1805
- return `<div class="card">
1806
- <div class="row" style="border:0;padding:0;">
1807
- <div class="name">${escHtml(p.name)}</div>
1808
- ${isActive ? '<span class="pill ok">active</span>' : ''}
1809
- ${p.configured ? '' : '<span class="pill warn">unconfigured</span>'}
1810
- <div class="dim row-actions">${escHtml(p.summary || '')}</div>
1811
- <button class="btn btn-secondary btn-sm" data-action="test" data-name="${escHtml(p.name)}">Test</button>
1812
- ${isActive ? '' : `<button class="btn btn-sm" data-action="use" data-name="${escHtml(p.name)}">Use</button>`}
1813
- </div>
1814
- <div class="dim" data-test-result="${escHtml(p.name)}" style="margin-top:6px;font-size:11px;"></div>
1815
- </div>`;
1816
- }).join('');
1817
- root.querySelectorAll('button[data-action="test"]').forEach((b) => {
1818
- b.addEventListener('click', () => sandboxTest(b.dataset.name));
1819
- });
1820
- root.querySelectorAll('button[data-action="use"]').forEach((b) => {
1821
- b.addEventListener('click', () => sandboxUse(b.dataset.name));
1822
- });
1823
- } catch (e) {
1824
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1825
- }
1826
- };
1827
- async function sandboxTest(name) {
1828
- const out = document.querySelector(`[data-test-result="${name}"]`);
1829
- if (!out) return;
1830
- out.textContent = '⏳ testing…';
1831
- out.style.color = 'var(--dim)';
1832
- try {
1833
- const r = await fetch('/sandbox/' + encodeURIComponent(name) + '/test', { method: 'POST' });
1834
- const body = await r.json().catch(() => ({}));
1835
- if (body.ok) {
1836
- out.style.color = 'var(--ok)';
1837
- out.textContent = `✓ ok · ${body.durationMs || '?'}ms · ${String(body.stdout || '').slice(0, 80)}`;
1838
- } else {
1839
- out.style.color = 'var(--err)';
1840
- out.textContent = `✗ ${body.error || 'failed'}`;
1841
- }
1842
- } catch (e) {
1843
- out.style.color = 'var(--err)';
1844
- out.textContent = '✗ ' + e.message;
1845
- }
1846
- }
1847
- async function sandboxUse(name) {
1848
- try {
1849
- const r = await fetch('/sandbox/use', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) });
1850
- if (!r.ok) { const e = await r.json().catch(() => ({})); alert('Failed: ' + (e.error || r.statusText)); return; }
1851
- LOADERS.sandbox();
1852
- } catch (e) { alert('Failed: ' + e.message); }
1853
- }
1854
-
1855
- LOADERS.channels = async function loadChannels() {
1856
- const root = document.getElementById('channels-list');
1857
- const meta = document.getElementById('channels-meta');
1858
- root.innerHTML = '<div class="empty">Loading…</div>';
1859
- try {
1860
- const r = await api('/channels');
1861
- const arr = r.channels || [];
1862
- meta.textContent = `${arr.length} channel${arr.length === 1 ? '' : 's'}`;
1863
- if (!arr.length) { root.innerHTML = '<div class="empty">No channels configured. Configure via <code>lazyclaw config set channels.&lt;name&gt; ...</code>.</div>'; return; }
1864
- root.innerHTML = arr.map((c) => `<div class="card">
1865
- <div class="row" style="border:0;padding:0;">
1866
- <div class="name">${escHtml(c.name)}</div>
1867
- ${c.enabled ? '<span class="pill ok">enabled</span>' : '<span class="pill warn">disabled</span>'}
1868
- <div class="dim row-actions">${c.boundAgent ? 'agent: ' + escHtml(c.boundAgent) : '<span class="dim">no binding</span>'}</div>
1869
- </div>
1870
- <div class="dim" style="margin-top:6px;font-size:12px;">
1871
- last inbound: ${c.lastInboundAt ? new Date(c.lastInboundAt).toLocaleString() : '—'}
1872
- </div>
1873
- </div>`).join('');
1874
- } catch (e) {
1875
- root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
1876
- }
1877
- };
1878
-
1879
- // First load = chat tab.
1880
- LOADERS.chat();
1881
-
1882
- // ── Chat send ─────────────────────────────────────────────────
1883
- let chatHistory = []; // [{role, text}]
1884
- function resetChat() {
1885
- chatHistory = [];
1886
- const stream = document.getElementById('chat-stream');
1887
- stream.innerHTML = '<div class="empty">Type below to start.</div>';
1888
- document.getElementById('chat-meta').textContent = '';
1889
- }
1890
- function appendMsg(role, text) {
1891
- const stream = document.getElementById('chat-stream');
1892
- // First message kicks the empty placeholder.
1893
- if (stream.querySelector('.empty')) stream.innerHTML = '';
1894
- const div = document.createElement('div');
1895
- div.className = 'msg ' + role;
1896
- div.textContent = text;
1897
- stream.appendChild(div);
1898
- stream.scrollTop = stream.scrollHeight;
1899
- return div;
1900
- }
1901
- document.getElementById('chat-input').addEventListener('keydown', (e) => {
1902
- if (e.key === 'Enter' && !e.shiftKey) {
1903
- e.preventDefault();
1904
- sendChat();
1905
- }
1906
- });
1907
- async function sendChat() {
1908
- const ta = document.getElementById('chat-input');
1909
- const text = ta.value.trim();
1910
- if (!text) return;
1911
- const assignee = document.getElementById('chat-assignee').value;
1912
- if (!assignee) {
1913
- appendMsg('error', 'No provider selected. Run `lazyclaw onboard` first.');
1914
- return;
1915
- }
1916
- ta.value = '';
1917
- appendMsg('user', text);
1918
- chatHistory.push({ role: 'user', text });
1919
- const meta = document.getElementById('chat-meta');
1920
- meta.textContent = '⏳ thinking…';
1921
- const t0 = Date.now();
1922
- try {
1923
- // Daemon's POST /agent: { prompt, provider, model, ... }
1924
- // Returns { text, provider, model, durationMs, ... }.
1925
- const [provName, modelName] = assignee.includes(':') ? assignee.split(':', 2) : [assignee, ''];
1926
- const body = { prompt: buildAgentPrompt(text), provider: provName };
1927
- if (modelName) body.model = modelName;
1928
- const r = await api('/agent', {
1929
- method: 'POST',
1930
- headers: { 'content-type': 'application/json' },
1931
- body: JSON.stringify(body),
1932
- });
1933
- // Daemon's POST /agent returns { reply, usage?, cost? }. Older
1934
- // drafts used { text } / { output }; accept any of them so a
1935
- // dashboard hitting an older or newer daemon both work.
1936
- const reply = (typeof r.reply === 'string' ? r.reply : '')
1937
- || (typeof r.text === 'string' ? r.text : '')
1938
- || (typeof r.output === 'string' ? r.output : '')
1939
- || '(empty)';
1940
- appendMsg('assistant', reply);
1941
- chatHistory.push({ role: 'assistant', text: reply });
1942
- const dur = ((Date.now() - t0) / 1000).toFixed(1);
1943
- meta.textContent = `${r.provider || provName} · ${r.model || modelName || '(default)'} · ${dur}s`;
1944
- } catch (e) {
1945
- appendMsg('error', '⚠ ' + (e.message || String(e)));
1946
- meta.textContent = '';
1947
- }
1948
- }
1949
- function buildAgentPrompt(latestUserText) {
1950
- // Flat conversation prompt: previous turns + the new user message.
1951
- // The daemon's /agent endpoint is one-shot, so we stuff prior
1952
- // turns into the prompt body. Keeps the dashboard stateless.
1953
- if (chatHistory.length <= 1) return latestUserText;
1954
- const lines = [];
1955
- for (const m of chatHistory.slice(-12, -1)) {
1956
- lines.push((m.role === 'user' ? 'User:' : 'Assistant:') + ' ' + m.text);
1957
- }
1958
- lines.push('User: ' + latestUserText);
1959
- lines.push('Assistant:');
1960
- return lines.join('\n\n');
1961
- }
1962
- </script>
279
+ <script src="/dashboard.js"></script>
1963
280
  </body>
1964
281
  </html>