lazyclaw 3.99.15 → 3.99.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +117 -26
- package/daemon.mjs +214 -2
- package/package.json +1 -1
- package/web/dashboard.html +604 -23
package/cli.mjs
CHANGED
|
@@ -1455,7 +1455,10 @@ function _attachGhostAutocomplete(rl) {
|
|
|
1455
1455
|
function _renderBanner(version) {
|
|
1456
1456
|
const W = 30;
|
|
1457
1457
|
const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
|
|
1458
|
-
|
|
1458
|
+
// 24-bit color so the mascot reads in the same warm orange as the
|
|
1459
|
+
// dist/index.html SVG (#d97757). Falls back gracefully on terminals
|
|
1460
|
+
// that ignore truecolor — the glyphs are visible regardless.
|
|
1461
|
+
const orange = (s) => `\x1b[38;2;217;119;87m${s}\x1b[0m`;
|
|
1459
1462
|
// Inner content of each banner row — DO NOT pad here, the wrapper
|
|
1460
1463
|
// does it. Backslashes are JS-escaped so each `\\` renders as one
|
|
1461
1464
|
// literal `\` in the output.
|
|
@@ -1466,16 +1469,18 @@ function _renderBanner(version) {
|
|
|
1466
1469
|
' |_\\__,_/__\\_, |_|',
|
|
1467
1470
|
' LazyClaw |__/ ' + String(version || '?.?.?').padEnd(10).slice(0, 10),
|
|
1468
1471
|
];
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
//
|
|
1472
|
+
// Pixel-art mascot mirrored from the lazyclaude SPA's #claudeMascot
|
|
1473
|
+
// SVG (orange rectangles → block characters). Squashed to 5 rows so
|
|
1474
|
+
// it lines up with `inner` in the banner. Eye sockets are left blank
|
|
1475
|
+
// (the SVG fills them with #000); a hollow gap reads as eyes against
|
|
1476
|
+
// the orange body in any monospace font.
|
|
1472
1477
|
const mascot = [
|
|
1473
1478
|
'',
|
|
1474
|
-
'',
|
|
1475
|
-
'
|
|
1476
|
-
'
|
|
1477
|
-
|
|
1478
|
-
'',
|
|
1479
|
+
orange(' ██ ██'),
|
|
1480
|
+
orange(' ██████████████'),
|
|
1481
|
+
orange(' ██ ') + '██' + orange(' ') + '██' + orange(' ██'),
|
|
1482
|
+
orange(' ██████████████'),
|
|
1483
|
+
orange(' ██ ██'),
|
|
1479
1484
|
'',
|
|
1480
1485
|
];
|
|
1481
1486
|
const banner = [
|
|
@@ -2483,16 +2488,50 @@ async function cmdChat(flags = {}) {
|
|
|
2483
2488
|
// `dashboard` is the discoverable name and it auto-opens the browser
|
|
2484
2489
|
// (which the bare daemon doesn't, since most daemon callers are
|
|
2485
2490
|
// scripts).
|
|
2491
|
+
// Best-effort port-occupant kill — macOS / Linux only. Returns true when
|
|
2492
|
+
// at least one PID was signalled. Used by cmdDashboard so a leftover
|
|
2493
|
+
// listener from a previous run doesn't crash the launch with EADDRINUSE.
|
|
2494
|
+
// Mirrors the Python server's auto-kill behaviour described in CLAUDE.md.
|
|
2495
|
+
async function _killPortOccupant(port) {
|
|
2496
|
+
if (process.platform === 'win32') return false;
|
|
2497
|
+
const { spawn } = await import('node:child_process');
|
|
2498
|
+
return new Promise((resolve) => {
|
|
2499
|
+
let lsof;
|
|
2500
|
+
try {
|
|
2501
|
+
lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
2502
|
+
} catch (_) { return resolve(false); }
|
|
2503
|
+
let buf = '';
|
|
2504
|
+
lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
|
|
2505
|
+
lsof.on('error', () => resolve(false));
|
|
2506
|
+
lsof.on('close', () => {
|
|
2507
|
+
const pids = buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite);
|
|
2508
|
+
if (!pids.length) return resolve(false);
|
|
2509
|
+
// SIGTERM first so node has a chance to clean up; SIGKILL the
|
|
2510
|
+
// holdouts after a short grace window.
|
|
2511
|
+
for (const pid of pids) {
|
|
2512
|
+
try { process.kill(pid, 'SIGTERM'); } catch (_) { /* gone already */ }
|
|
2513
|
+
}
|
|
2514
|
+
setTimeout(() => {
|
|
2515
|
+
for (const pid of pids) {
|
|
2516
|
+
try { process.kill(pid, 'SIGKILL'); } catch (_) { /* gone */ }
|
|
2517
|
+
}
|
|
2518
|
+
resolve(true);
|
|
2519
|
+
}, 200);
|
|
2520
|
+
});
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2486
2524
|
async function cmdDashboard(flags = {}) {
|
|
2487
2525
|
await ensureRegistry();
|
|
2488
2526
|
const sessionsMod = await import('./sessions.mjs');
|
|
2489
2527
|
const { startDaemon } = await import('./daemon.mjs');
|
|
2490
2528
|
const port = flags.port !== undefined ? parseInt(flags.port, 10) : 19600;
|
|
2491
2529
|
const cfgDir = path.dirname(configPath());
|
|
2492
|
-
const
|
|
2530
|
+
const daemonOpts = {
|
|
2493
2531
|
port,
|
|
2494
2532
|
once: false,
|
|
2495
2533
|
readConfig,
|
|
2534
|
+
writeConfig,
|
|
2496
2535
|
sessionsDirGetter: () => cfgDir,
|
|
2497
2536
|
sessionsMod,
|
|
2498
2537
|
version: () => readVersionFromRepo(),
|
|
@@ -2506,7 +2545,34 @@ async function cmdDashboard(flags = {}) {
|
|
|
2506
2545
|
responseCache: null,
|
|
2507
2546
|
logger: null,
|
|
2508
2547
|
costCap: null,
|
|
2509
|
-
}
|
|
2548
|
+
};
|
|
2549
|
+
let d;
|
|
2550
|
+
try {
|
|
2551
|
+
d = await startDaemon(daemonOpts);
|
|
2552
|
+
} catch (err) {
|
|
2553
|
+
if (err?.code !== 'EADDRINUSE') throw err;
|
|
2554
|
+
// Port is held by a leftover dashboard / daemon. Try to free it
|
|
2555
|
+
// (lsof + kill on macOS/Linux); on failure, fall back to a random
|
|
2556
|
+
// port so the user always gets a working dashboard rather than a
|
|
2557
|
+
// crash trace.
|
|
2558
|
+
const portInUse = port;
|
|
2559
|
+
process.stderr.write(` ⚠ port ${portInUse} is in use — likely a previous dashboard didn't shut down.\n`);
|
|
2560
|
+
const killed = await _killPortOccupant(portInUse);
|
|
2561
|
+
if (killed) {
|
|
2562
|
+
process.stderr.write(` ✓ freed port ${portInUse} (killed prior listener) — retrying…\n`);
|
|
2563
|
+
// Short pause so the OS releases the port before we re-listen.
|
|
2564
|
+
await new Promise(r => setTimeout(r, 250));
|
|
2565
|
+
try { d = await startDaemon(daemonOpts); }
|
|
2566
|
+
catch (err2) {
|
|
2567
|
+
if (err2?.code !== 'EADDRINUSE') throw err2;
|
|
2568
|
+
process.stderr.write(` ⚠ still in use — falling back to a random port.\n`);
|
|
2569
|
+
d = await startDaemon({ ...daemonOpts, port: 0 });
|
|
2570
|
+
}
|
|
2571
|
+
} else {
|
|
2572
|
+
process.stderr.write(` ⚠ couldn't free port ${portInUse} automatically — falling back to a random port.\n`);
|
|
2573
|
+
d = await startDaemon({ ...daemonOpts, port: 0 });
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2510
2576
|
const url = `http://127.0.0.1:${d.port}/dashboard`;
|
|
2511
2577
|
process.stdout.write(`🦞 LazyClaw dashboard listening at ${url}\n`);
|
|
2512
2578
|
if (!flags['no-open']) {
|
|
@@ -2594,21 +2660,46 @@ async function cmdDaemon(flags) {
|
|
|
2594
2660
|
|| process.env.LAZYCLAW_WORKFLOW_STATE_DIR
|
|
2595
2661
|
|| '.workflow-state';
|
|
2596
2662
|
const cfgDir = path.dirname(configPath());
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2663
|
+
let d;
|
|
2664
|
+
try {
|
|
2665
|
+
d = await startDaemon({
|
|
2666
|
+
port: Number.isFinite(port) ? port : 0,
|
|
2667
|
+
once,
|
|
2668
|
+
readConfig,
|
|
2669
|
+
// `lazyclaw daemon` exposes mutation endpoints (POST /providers,
|
|
2670
|
+
// PUT /rates/<key>, etc.) only when an auth token is configured
|
|
2671
|
+
// — without one the daemon is loopback-only but still untrusted
|
|
2672
|
+
// (any process on the box can hit it). dashboard subcommand sets
|
|
2673
|
+
// writeConfig unconditionally because it always runs as the user.
|
|
2674
|
+
writeConfig: authToken ? writeConfig : undefined,
|
|
2675
|
+
sessionsDirGetter: () => cfgDir,
|
|
2676
|
+
sessionsMod,
|
|
2677
|
+
version: () => readVersionFromRepo(),
|
|
2678
|
+
workflowStateDir: () => workflowStateDirValue,
|
|
2679
|
+
authToken: authToken || undefined,
|
|
2680
|
+
allowedOrigins,
|
|
2681
|
+
rateLimit,
|
|
2682
|
+
responseCache,
|
|
2683
|
+
logger,
|
|
2684
|
+
costCap: costCapOrNull,
|
|
2685
|
+
});
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
// `lazyclaw daemon` exits cleanly on EADDRINUSE with a readable
|
|
2688
|
+
// message instead of the historical unhandled-error stack trace.
|
|
2689
|
+
// Unlike `lazyclaw dashboard`, daemon doesn't auto-kill the prior
|
|
2690
|
+
// listener — bare daemon callers are usually scripts that expect
|
|
2691
|
+
// exact port semantics, so we surface the failure and let them
|
|
2692
|
+
// choose (re-run with --port 0 for random, or kill the holdout).
|
|
2693
|
+
if (err?.code === 'EADDRINUSE') {
|
|
2694
|
+
process.stderr.write(
|
|
2695
|
+
`lazyclaw daemon: port ${port} is in use.\n` +
|
|
2696
|
+
` Re-run with --port 0 for a random port, or free the port:\n` +
|
|
2697
|
+
` lsof -ti tcp:${port} | xargs kill -9\n`
|
|
2698
|
+
);
|
|
2699
|
+
process.exit(2);
|
|
2700
|
+
}
|
|
2701
|
+
throw err;
|
|
2702
|
+
}
|
|
2612
2703
|
// Print the bound port immediately so test/script callers can pick it up
|
|
2613
2704
|
// even when we asked for port 0. Indicate auth presence (not the token)
|
|
2614
2705
|
// and the allowed-origin count (not the values, just whether browser
|
package/daemon.mjs
CHANGED
|
@@ -245,6 +245,7 @@ function isOriginAllowed(req, allowedOrigins) {
|
|
|
245
245
|
/**
|
|
246
246
|
* @param {{
|
|
247
247
|
* readConfig: () => Record<string, unknown>,
|
|
248
|
+
* writeConfig?: (cfg: Record<string, unknown>) => void,
|
|
248
249
|
* sessionsDirGetter: () => string,
|
|
249
250
|
* sessionsMod: typeof import('./sessions.mjs'),
|
|
250
251
|
* version: () => string,
|
|
@@ -256,6 +257,12 @@ function isOriginAllowed(req, allowedOrigins) {
|
|
|
256
257
|
* logger?: ReturnType<typeof createLogger> | null,
|
|
257
258
|
* costCap?: Record<string, number> | null,
|
|
258
259
|
* }} ctx
|
|
260
|
+
*
|
|
261
|
+
* `writeConfig` is optional; when omitted the mutation endpoints (POST
|
|
262
|
+
* /providers, DELETE /providers/<name>, PUT/DELETE /rates/<key>, PUT
|
|
263
|
+
* /config/<key>) reject with 405 Method Not Allowed. The CLI's
|
|
264
|
+
* `cmdDashboard` always supplies it; bare `lazyclaw daemon --once` callers
|
|
265
|
+
* can opt out by leaving it undefined.
|
|
259
266
|
*/
|
|
260
267
|
export function makeHandler(ctx) {
|
|
261
268
|
const authToken = ctx.authToken || null;
|
|
@@ -373,10 +380,12 @@ export function makeHandler(ctx) {
|
|
|
373
380
|
const route = `${req.method} ${url.pathname}`;
|
|
374
381
|
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
375
382
|
const providerMatch = url.pathname.match(/^\/providers\/([^/]+)$/);
|
|
383
|
+
const providerTestMatch = url.pathname.match(/^\/providers\/([^/]+)\/test$/);
|
|
376
384
|
const sessionExportMatch = url.pathname.match(/^\/sessions\/([^/]+)\/export$/);
|
|
377
385
|
const skillMatch = url.pathname.match(/^\/skills\/([^/]+)$/);
|
|
378
386
|
const workflowMatch = url.pathname.match(/^\/workflows\/([^/]+)$/);
|
|
379
387
|
const configKeyMatch = url.pathname.match(/^\/config\/([^/]+)$/);
|
|
388
|
+
const ratesKeyMatch = url.pathname.match(/^\/rates\/([^/]+)$/);
|
|
380
389
|
switch (true) {
|
|
381
390
|
case route === 'GET /' || route === 'GET /dashboard': {
|
|
382
391
|
// Serve the lazyclaw-only web dashboard (a single static
|
|
@@ -487,9 +496,25 @@ export function makeHandler(ctx) {
|
|
|
487
496
|
}
|
|
488
497
|
case route === 'GET /providers': {
|
|
489
498
|
// ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
|
|
499
|
+
// The dashboard reads `custom` / `builtinOpenAICompat` / `endpoint`
|
|
500
|
+
// / `docs` to render the right pills + tooltips; CLI callers only
|
|
501
|
+
// need `name` / `requiresApiKey` / `suggestedModels` and ignore
|
|
502
|
+
// the extras (additive change, no migration).
|
|
490
503
|
let out = Object.keys(PROVIDERS).map(name => {
|
|
491
504
|
const meta = PROVIDER_INFO[name] || { name };
|
|
492
|
-
return {
|
|
505
|
+
return {
|
|
506
|
+
name,
|
|
507
|
+
requiresApiKey: !!meta.requiresApiKey,
|
|
508
|
+
defaultModel: meta.defaultModel || null,
|
|
509
|
+
suggestedModels: meta.suggestedModels || [],
|
|
510
|
+
endpoint: meta.endpoint || null,
|
|
511
|
+
docs: meta.docs || null,
|
|
512
|
+
custom: !!meta.custom,
|
|
513
|
+
builtinOpenAICompat: !!meta.builtinOpenAICompat,
|
|
514
|
+
baseUrl: meta.baseUrl || null,
|
|
515
|
+
envKey: meta.envKey || null,
|
|
516
|
+
keyPrefix: meta.keyPrefix || null,
|
|
517
|
+
};
|
|
493
518
|
});
|
|
494
519
|
const filter = url.searchParams.get('filter');
|
|
495
520
|
if (filter) {
|
|
@@ -569,6 +594,100 @@ export function makeHandler(ctx) {
|
|
|
569
594
|
results,
|
|
570
595
|
});
|
|
571
596
|
}
|
|
597
|
+
case req.method === 'GET' && !!providerTestMatch: {
|
|
598
|
+
// GET /providers/<name>/test — single-provider 1-token reachability
|
|
599
|
+
// probe. Same shape as one entry of GET /providers/test, but the
|
|
600
|
+
// endpoint stops on the first failure and exposes the reply body
|
|
601
|
+
// (truncated) so the dashboard can show a real signal of life.
|
|
602
|
+
const name = providerTestMatch[1];
|
|
603
|
+
const provider = PROVIDERS[name];
|
|
604
|
+
if (!provider) return writeJson(res, 404, { error: `unknown provider: ${name}` });
|
|
605
|
+
const cfg = ctx.readConfig();
|
|
606
|
+
const apiKey = cfg['api-key'] || '';
|
|
607
|
+
const meta = PROVIDER_INFO[name] || {};
|
|
608
|
+
const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
|
|
609
|
+
const prompt = url.searchParams.get('prompt') || 'ping';
|
|
610
|
+
const t0 = Date.now();
|
|
611
|
+
try {
|
|
612
|
+
let reply = '';
|
|
613
|
+
const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
|
|
614
|
+
for await (const chunk of stream) {
|
|
615
|
+
if (typeof chunk === 'string') reply += chunk;
|
|
616
|
+
}
|
|
617
|
+
return writeJson(res, reply.length > 0 ? 200 : 503, {
|
|
618
|
+
ok: reply.length > 0,
|
|
619
|
+
name, model,
|
|
620
|
+
durationMs: Date.now() - t0,
|
|
621
|
+
replyLength: reply.length,
|
|
622
|
+
reply: reply.slice(0, 500),
|
|
623
|
+
});
|
|
624
|
+
} catch (err) {
|
|
625
|
+
return writeJson(res, 503, {
|
|
626
|
+
ok: false, name, model,
|
|
627
|
+
durationMs: Date.now() - t0,
|
|
628
|
+
error: err?.message || String(err),
|
|
629
|
+
code: err?.code || null,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
case route === 'POST /providers': {
|
|
634
|
+
// Register or overwrite a custom OpenAI-compatible provider.
|
|
635
|
+
// Body: { name, baseUrl, apiKey?, defaultModel? }. Persists into
|
|
636
|
+
// cfg.customProviders[] and hot-registers via the registry's
|
|
637
|
+
// registerCustomProviders() so the new entry is callable in this
|
|
638
|
+
// same process. 405 when the daemon was started without
|
|
639
|
+
// writeConfig (read-only mode). The same name as a built-in
|
|
640
|
+
// OpenAI-compat alias is allowed and overrides the built-in.
|
|
641
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
642
|
+
return writeJson(res, 405, { error: 'mutation disabled — daemon was started without writeConfig' });
|
|
643
|
+
}
|
|
644
|
+
let body;
|
|
645
|
+
try { body = await readJson(req); }
|
|
646
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
647
|
+
const reg = await import('./providers/registry.mjs');
|
|
648
|
+
let name;
|
|
649
|
+
try { name = reg.validateCustomProviderName(body.name); }
|
|
650
|
+
catch (e) { return writeJson(res, 400, { error: e.message }); }
|
|
651
|
+
if (!body.baseUrl || typeof body.baseUrl !== 'string' || !/^https?:\/\//i.test(body.baseUrl)) {
|
|
652
|
+
return writeJson(res, 400, { error: 'baseUrl must be a string starting with http:// or https://' });
|
|
653
|
+
}
|
|
654
|
+
const cfg = ctx.readConfig();
|
|
655
|
+
cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
|
|
656
|
+
const idx = cfg.customProviders.findIndex((p) => p && p.name === name);
|
|
657
|
+
const entry = {
|
|
658
|
+
name,
|
|
659
|
+
baseUrl: String(body.baseUrl).replace(/\/+$/, ''),
|
|
660
|
+
apiKey: body.apiKey || undefined,
|
|
661
|
+
defaultModel: body.defaultModel || undefined,
|
|
662
|
+
};
|
|
663
|
+
if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
|
|
664
|
+
else cfg.customProviders.push(entry);
|
|
665
|
+
ctx.writeConfig(cfg);
|
|
666
|
+
try { reg.registerCustomProviders(cfg); } catch { /* keep going */ }
|
|
667
|
+
const overridesBuiltin = typeof reg.isBuiltinOpenAICompatName === 'function'
|
|
668
|
+
? reg.isBuiltinOpenAICompatName(name)
|
|
669
|
+
: false;
|
|
670
|
+
return writeJson(res, 200, { ok: true, name, baseUrl: entry.baseUrl, overridesBuiltin });
|
|
671
|
+
}
|
|
672
|
+
case req.method === 'DELETE' && !!providerMatch && providerMatch[1] !== 'test': {
|
|
673
|
+
// DELETE /providers/<name> — drop a custom registration. Idempotent:
|
|
674
|
+
// 200 with `removed: false` when the name wasn't a custom entry.
|
|
675
|
+
// Built-in providers can't be deleted; their PROVIDERS row is
|
|
676
|
+
// restored on next process boot if the user previously overrode it.
|
|
677
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
678
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
679
|
+
}
|
|
680
|
+
const name = providerMatch[1];
|
|
681
|
+
const cfg = ctx.readConfig();
|
|
682
|
+
if (!Array.isArray(cfg.customProviders) || cfg.customProviders.length === 0) {
|
|
683
|
+
return writeJson(res, 200, { ok: true, name, removed: false });
|
|
684
|
+
}
|
|
685
|
+
const before = cfg.customProviders.length;
|
|
686
|
+
cfg.customProviders = cfg.customProviders.filter((p) => !(p && p.name === name));
|
|
687
|
+
const removed = cfg.customProviders.length < before;
|
|
688
|
+
if (removed) ctx.writeConfig(cfg);
|
|
689
|
+
return writeJson(res, 200, { ok: true, name, removed });
|
|
690
|
+
}
|
|
572
691
|
case route === 'GET /rates': {
|
|
573
692
|
// Read-only view of cfg.rates so a dashboard's cost panel
|
|
574
693
|
// can render the configured cards without shelling to the
|
|
@@ -608,6 +727,42 @@ export function makeHandler(ctx) {
|
|
|
608
727
|
// fields without shelling to the CLI.
|
|
609
728
|
return writeJson(res, 200, RATE_CARD_SHAPE);
|
|
610
729
|
}
|
|
730
|
+
case req.method === 'PUT' && !!ratesKeyMatch && ratesKeyMatch[1] !== 'validate' && ratesKeyMatch[1] !== 'shape': {
|
|
731
|
+
// PUT /rates/<key> — set a rate card. Body is the card object
|
|
732
|
+
// ({ in, out, "cache-read"?, "cache-create"?, currency? }). The
|
|
733
|
+
// payload is merged into cfg.rates and validated as a whole;
|
|
734
|
+
// 422 on validation failure. 405 when writeConfig is unset.
|
|
735
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
736
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
737
|
+
}
|
|
738
|
+
const key = decodeURIComponent(ratesKeyMatch[1]);
|
|
739
|
+
let body;
|
|
740
|
+
try { body = await readJson(req); }
|
|
741
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
742
|
+
if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be a JSON object' });
|
|
743
|
+
const cfg = ctx.readConfig();
|
|
744
|
+
cfg.rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
|
|
745
|
+
cfg.rates[key] = body;
|
|
746
|
+
const v = validateRates(cfg.rates, PROVIDERS);
|
|
747
|
+
if (!v.ok) return writeJson(res, 422, v);
|
|
748
|
+
ctx.writeConfig(cfg);
|
|
749
|
+
return writeJson(res, 200, { ok: true, key, card: cfg.rates[key] });
|
|
750
|
+
}
|
|
751
|
+
case req.method === 'DELETE' && !!ratesKeyMatch && ratesKeyMatch[1] !== 'validate' && ratesKeyMatch[1] !== 'shape': {
|
|
752
|
+
// DELETE /rates/<key> — idempotent: 200 with `removed: false`
|
|
753
|
+
// when the card didn't exist.
|
|
754
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
755
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
756
|
+
}
|
|
757
|
+
const key = decodeURIComponent(ratesKeyMatch[1]);
|
|
758
|
+
const cfg = ctx.readConfig();
|
|
759
|
+
if (!cfg.rates || typeof cfg.rates !== 'object' || !(key in cfg.rates)) {
|
|
760
|
+
return writeJson(res, 200, { ok: true, key, removed: false });
|
|
761
|
+
}
|
|
762
|
+
delete cfg.rates[key];
|
|
763
|
+
ctx.writeConfig(cfg);
|
|
764
|
+
return writeJson(res, 200, { ok: true, key, removed: true });
|
|
765
|
+
}
|
|
611
766
|
case route === 'GET /status': {
|
|
612
767
|
const cfg = ctx.readConfig();
|
|
613
768
|
return writeJson(res, 200, {
|
|
@@ -654,6 +809,52 @@ export function makeHandler(ctx) {
|
|
|
654
809
|
const value = key === 'api-key' ? maskApiKey(raw) : raw;
|
|
655
810
|
return writeJson(res, 200, { key, value });
|
|
656
811
|
}
|
|
812
|
+
case req.method === 'PUT' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
|
|
813
|
+
// PUT /config/<key> body: { value: <any> }
|
|
814
|
+
// Mirror of `lazyclaw config set <key> <value>`. Re-validates the
|
|
815
|
+
// whole config after the write so we never persist a broken state.
|
|
816
|
+
// Nested cargo (customProviders / rates / authProfiles) goes
|
|
817
|
+
// through its own dedicated endpoint — guarded here so a
|
|
818
|
+
// dashboard PUT can't bypass schema validation.
|
|
819
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
820
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
821
|
+
}
|
|
822
|
+
const key = configKeyMatch[1];
|
|
823
|
+
if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
|
|
824
|
+
return writeJson(res, 400, {
|
|
825
|
+
error: `use the dedicated endpoint for "${key}" — POST /providers · PUT /rates/<key> · authProfiles via CLI`,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
let body;
|
|
829
|
+
try { body = await readJson(req); }
|
|
830
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
831
|
+
const value = body && Object.prototype.hasOwnProperty.call(body, 'value') ? body.value : undefined;
|
|
832
|
+
const cfg = ctx.readConfig();
|
|
833
|
+
if (value === null || value === undefined) delete cfg[key];
|
|
834
|
+
else cfg[key] = value;
|
|
835
|
+
const v = validateConfig(cfg, PROVIDERS);
|
|
836
|
+
if (!v.ok) return writeJson(res, 422, v);
|
|
837
|
+
ctx.writeConfig(cfg);
|
|
838
|
+
const stored = key === 'api-key' && typeof cfg[key] === 'string' ? maskApiKey(cfg[key]) : cfg[key];
|
|
839
|
+
return writeJson(res, 200, { ok: true, key, value: stored });
|
|
840
|
+
}
|
|
841
|
+
case req.method === 'DELETE' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
|
|
842
|
+
// DELETE /config/<key> — same as `lazyclaw config delete`.
|
|
843
|
+
// Idempotent: 200 with `removed: false` when the key wasn't
|
|
844
|
+
// present.
|
|
845
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
846
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
847
|
+
}
|
|
848
|
+
const key = configKeyMatch[1];
|
|
849
|
+
if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
|
|
850
|
+
return writeJson(res, 400, { error: `delete via the dedicated endpoint for "${key}"` });
|
|
851
|
+
}
|
|
852
|
+
const cfg = ctx.readConfig();
|
|
853
|
+
if (!(key in cfg)) return writeJson(res, 200, { ok: true, key, removed: false });
|
|
854
|
+
delete cfg[key];
|
|
855
|
+
ctx.writeConfig(cfg);
|
|
856
|
+
return writeJson(res, 200, { ok: true, key, removed: true });
|
|
857
|
+
}
|
|
657
858
|
case route === 'GET /doctor': {
|
|
658
859
|
// Mirror the CLI doctor output — same field set so any tool that
|
|
659
860
|
// already knows how to read CLI doctor JSON can hit this endpoint.
|
|
@@ -1465,8 +1666,19 @@ export async function startDaemon(opts) {
|
|
|
1465
1666
|
setImmediate(() => server.close());
|
|
1466
1667
|
}
|
|
1467
1668
|
});
|
|
1468
|
-
return new Promise((resolve) => {
|
|
1669
|
+
return new Promise((resolve, reject) => {
|
|
1670
|
+
// EADDRINUSE (and other listen-time errors) used to crash the
|
|
1671
|
+
// process — listen() emits 'error' before the success callback
|
|
1672
|
+
// fires, and we never wired that channel. Capture it once so
|
|
1673
|
+
// callers (cmdDashboard / cmdDaemon) can choose to kill the
|
|
1674
|
+
// occupant or fall back to a random port.
|
|
1675
|
+
const onError = (err) => {
|
|
1676
|
+
server.off('error', onError);
|
|
1677
|
+
reject(err);
|
|
1678
|
+
};
|
|
1679
|
+
server.once('error', onError);
|
|
1469
1680
|
server.listen(opts.port ?? 0, '127.0.0.1', () => {
|
|
1681
|
+
server.off('error', onError);
|
|
1470
1682
|
const addr = server.address();
|
|
1471
1683
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
1472
1684
|
resolve({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "3.99.
|
|
3
|
+
"version": "3.99.17",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/web/dashboard.html
CHANGED
|
@@ -41,8 +41,25 @@
|
|
|
41
41
|
align-items: center;
|
|
42
42
|
gap: 14px;
|
|
43
43
|
}
|
|
44
|
-
.logo { font-weight: 700; font-size: 16px; color: var(--accent); }
|
|
44
|
+
.logo { font-weight: 700; font-size: 16px; color: var(--accent); display: flex; align-items: center; gap: 10px; }
|
|
45
|
+
.logo .mascot { width: 36px; height: 32px; flex: none; }
|
|
45
46
|
.ver { color: var(--dim); font-size: 11px; }
|
|
47
|
+
/* lazyclaude pixel-mascot — copied verbatim from dist/index.html so the
|
|
48
|
+
lazyclaw web dashboard wears the same character as the larger SPA. */
|
|
49
|
+
.mascot .mj-body { animation: mj-jump 1s ease-in-out infinite; transform-origin: center bottom; }
|
|
50
|
+
.mascot .mj-shadow { animation: mj-sh 1s ease-in-out infinite; }
|
|
51
|
+
.mascot .mj-la { animation: mj-wl 1s ease-in-out infinite; transform-origin: right center; }
|
|
52
|
+
.mascot .mj-ra { animation: mj-wr 1s ease-in-out infinite; transform-origin: left center; }
|
|
53
|
+
.mascot .mj-le { animation: mj-ear 1s ease-in-out infinite; transform-origin: center bottom; }
|
|
54
|
+
.mascot .mj-re { animation: mj-ear 1s ease-in-out infinite .1s; transform-origin: center bottom; }
|
|
55
|
+
@keyframes mj-jump { 0%, 100% { transform: translateY(0) scaleY(1) scaleX(1); } 30% { transform: translateY(-10px) scaleY(1.1) scaleX(.95); } 50% { transform: translateY(-12px) scaleY(1.05) scaleX(.98); } 80% { transform: translateY(-3px) scaleY(.95) scaleX(1.05); } }
|
|
56
|
+
@keyframes mj-sh { 0%, 100% { transform: scaleX(1); opacity: .25; } 50% { transform: scaleX(.4); opacity: .08; } }
|
|
57
|
+
@keyframes mj-wl { 0%, 100% { transform: rotate(0); } 50% { transform: rotate(-25deg); } }
|
|
58
|
+
@keyframes mj-wr { 0%, 100% { transform: rotate(0); } 50% { transform: rotate(25deg); } }
|
|
59
|
+
@keyframes mj-ear { 0%, 100% { transform: scaleY(1); } 40% { transform: scaleY(1.2); } 60% { transform: scaleY(.85); } }
|
|
60
|
+
@media (prefers-reduced-motion: reduce) {
|
|
61
|
+
.mascot .mj-body, .mascot .mj-shadow, .mascot .mj-la, .mascot .mj-ra, .mascot .mj-le, .mascot .mj-re { animation: none; }
|
|
62
|
+
}
|
|
46
63
|
nav.tabs {
|
|
47
64
|
display: flex;
|
|
48
65
|
gap: 2px;
|
|
@@ -221,6 +238,35 @@
|
|
|
221
238
|
.banner.err { border-color: rgba(239, 68, 68, 0.4); background: rgba(239, 68, 68, 0.06); }
|
|
222
239
|
.banner ul { margin: 6px 0 0 18px; padding: 0; }
|
|
223
240
|
.banner li { font-size: 12px; }
|
|
241
|
+
/* Modal — used by session/workflow/skill detail views, the rate-card
|
|
242
|
+
editor, and the custom-provider form. One stacking layer; only one
|
|
243
|
+
open at a time. */
|
|
244
|
+
.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; }
|
|
245
|
+
.modal-backdrop.open { display: flex; }
|
|
246
|
+
.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); }
|
|
247
|
+
.modal-head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
|
248
|
+
.modal-head h3 { margin: 0; font-size: 16px; font-weight: 600; flex: 1; }
|
|
249
|
+
.modal-close { background: none; border: 0; color: var(--dim); cursor: pointer; font-size: 20px; line-height: 1; padding: 4px 8px; }
|
|
250
|
+
.modal-close:hover { color: var(--text); }
|
|
251
|
+
.modal-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
|
|
252
|
+
.modal-foot { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; }
|
|
253
|
+
.clickable { cursor: pointer; }
|
|
254
|
+
.clickable:hover { background: rgba(217, 119, 87, 0.05); }
|
|
255
|
+
.turn { padding: 8px 12px; border-radius: 6px; margin-bottom: 8px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; }
|
|
256
|
+
.turn.user { background: rgba(217, 119, 87, 0.10); border: 1px solid rgba(217, 119, 87, 0.25); }
|
|
257
|
+
.turn.assistant { background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
|
|
258
|
+
.turn.system { background: rgba(245, 158, 11, 0.06); border: 1px solid rgba(245, 158, 11, 0.20); }
|
|
259
|
+
.turn .role-tag { display: block; color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
|
|
260
|
+
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
|
261
|
+
.form-row label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
262
|
+
.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; }
|
|
263
|
+
.form-row textarea { resize: vertical; min-height: 80px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
|
264
|
+
.row-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
|
|
265
|
+
button.btn-sm { font-size: 12px; padding: 4px 10px; }
|
|
266
|
+
button.btn-danger { background: rgba(239,68,68,0.15); color: #ffb4b4; border: 1px solid rgba(239,68,68,0.3); }
|
|
267
|
+
button.btn-danger:hover { background: rgba(239,68,68,0.25); }
|
|
268
|
+
.markdown { font-size: 13px; line-height: 1.55; }
|
|
269
|
+
.markdown pre { font-size: 12px; }
|
|
224
270
|
@media (max-width: 480px) {
|
|
225
271
|
main { padding: 14px; }
|
|
226
272
|
.grid { grid-template-columns: 1fr; }
|
|
@@ -239,7 +285,24 @@
|
|
|
239
285
|
</head>
|
|
240
286
|
<body>
|
|
241
287
|
<header>
|
|
242
|
-
<div class="logo"
|
|
288
|
+
<div class="logo">
|
|
289
|
+
<svg class="mascot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 90" aria-hidden="true">
|
|
290
|
+
<ellipse class="mj-shadow" cx="50" cy="82" rx="22" ry="5" fill="#000"/>
|
|
291
|
+
<g class="mj-body">
|
|
292
|
+
<rect class="mj-le" x="22" y="10" width="8" height="14" fill="#d97757"/>
|
|
293
|
+
<rect class="mj-re" x="70" y="10" width="8" height="14" fill="#d97757"/>
|
|
294
|
+
<rect x="18" y="24" width="64" height="4" fill="#d97757"/>
|
|
295
|
+
<rect x="14" y="28" width="72" height="32" fill="#d97757"/>
|
|
296
|
+
<rect x="30" y="34" width="8" height="10" fill="#000"/>
|
|
297
|
+
<rect x="62" y="34" width="8" height="10" fill="#000"/>
|
|
298
|
+
<rect class="mj-la" x="2" y="36" width="12" height="8" fill="#d97757"/>
|
|
299
|
+
<rect class="mj-ra" x="86" y="36" width="12" height="8" fill="#d97757"/>
|
|
300
|
+
<rect x="24" y="60" width="12" height="14" fill="#d97757"/>
|
|
301
|
+
<rect x="64" y="60" width="12" height="14" fill="#d97757"/>
|
|
302
|
+
</g>
|
|
303
|
+
</svg>
|
|
304
|
+
<span>LazyClaw</span>
|
|
305
|
+
</div>
|
|
243
306
|
<div class="ver" id="version">…</div>
|
|
244
307
|
</header>
|
|
245
308
|
|
|
@@ -283,6 +346,11 @@
|
|
|
283
346
|
|
|
284
347
|
<section id="tab-providers">
|
|
285
348
|
<h2>Providers</h2>
|
|
349
|
+
<div class="toolbar">
|
|
350
|
+
<button class="btn" onclick="openAddProviderModal()">+ Add custom OpenAI-compat</button>
|
|
351
|
+
<button class="btn btn-secondary" onclick="LOADERS.providers()">Refresh</button>
|
|
352
|
+
<span class="dim" id="providers-meta"></span>
|
|
353
|
+
</div>
|
|
286
354
|
<div id="providers-list"><div class="empty">Loading…</div></div>
|
|
287
355
|
</section>
|
|
288
356
|
|
|
@@ -312,6 +380,7 @@
|
|
|
312
380
|
<section id="tab-rates">
|
|
313
381
|
<h2>Rates</h2>
|
|
314
382
|
<div class="toolbar">
|
|
383
|
+
<button class="btn" onclick="openRateCardModal()">+ Add / edit rate card</button>
|
|
315
384
|
<input type="search" id="rates-filter" placeholder="filter by provider/model">
|
|
316
385
|
<button class="btn btn-secondary" onclick="LOADERS.rates()">Refresh</button>
|
|
317
386
|
<span class="dim" id="rates-meta"></span>
|
|
@@ -342,6 +411,7 @@
|
|
|
342
411
|
<section id="tab-config">
|
|
343
412
|
<h2>Config</h2>
|
|
344
413
|
<div class="toolbar">
|
|
414
|
+
<button class="btn" onclick="openConfigEditModal()">+ Set key</button>
|
|
345
415
|
<button class="btn btn-secondary" onclick="LOADERS.config()">Refresh</button>
|
|
346
416
|
<span class="dim" id="config-meta"></span>
|
|
347
417
|
</div>
|
|
@@ -359,6 +429,18 @@
|
|
|
359
429
|
<span id="footer-url"></span>
|
|
360
430
|
</footer>
|
|
361
431
|
|
|
432
|
+
<!-- Shared modal — opened by openModal({title, bodyHtml, footHtml}). -->
|
|
433
|
+
<div id="modal-backdrop" class="modal-backdrop" onclick="if(event.target===this)closeModal()">
|
|
434
|
+
<div class="modal" role="dialog" aria-modal="true">
|
|
435
|
+
<div class="modal-head">
|
|
436
|
+
<h3 id="modal-title"></h3>
|
|
437
|
+
<button class="modal-close" onclick="closeModal()" aria-label="Close">×</button>
|
|
438
|
+
</div>
|
|
439
|
+
<div class="modal-body" id="modal-body"></div>
|
|
440
|
+
<div class="modal-foot" id="modal-foot"></div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
362
444
|
<script>
|
|
363
445
|
// Tab switching ────────────────────────────────────────────────
|
|
364
446
|
const tabs = document.querySelectorAll('nav.tabs button');
|
|
@@ -394,6 +476,25 @@
|
|
|
394
476
|
function escHtml(s) {
|
|
395
477
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
396
478
|
}
|
|
479
|
+
// ── Shared modal ────────────────────────────────────────────────
|
|
480
|
+
// openModal({ title, bodyHtml, footHtml }) renders into the markup
|
|
481
|
+
// declared at the bottom of <body> and shows the backdrop. ESC and
|
|
482
|
+
// backdrop click close. Only one modal is open at a time — calling
|
|
483
|
+
// openModal while another is already open replaces its contents.
|
|
484
|
+
function openModal({ title, bodyHtml, footHtml }) {
|
|
485
|
+
document.getElementById('modal-title').textContent = title || '';
|
|
486
|
+
document.getElementById('modal-body').innerHTML = bodyHtml || '';
|
|
487
|
+
document.getElementById('modal-foot').innerHTML = footHtml || '';
|
|
488
|
+
document.getElementById('modal-backdrop').classList.add('open');
|
|
489
|
+
}
|
|
490
|
+
function closeModal() {
|
|
491
|
+
document.getElementById('modal-backdrop').classList.remove('open');
|
|
492
|
+
document.getElementById('modal-body').innerHTML = '';
|
|
493
|
+
document.getElementById('modal-foot').innerHTML = '';
|
|
494
|
+
}
|
|
495
|
+
document.addEventListener('keydown', (e) => {
|
|
496
|
+
if (e.key === 'Escape' && document.getElementById('modal-backdrop').classList.contains('open')) closeModal();
|
|
497
|
+
});
|
|
397
498
|
function fmtDuration(ms) {
|
|
398
499
|
if (!Number.isFinite(ms) || ms < 0) return '—';
|
|
399
500
|
const s = Math.floor(ms / 1000);
|
|
@@ -422,9 +523,27 @@
|
|
|
422
523
|
LOADERS.chat = async function loadChat() {
|
|
423
524
|
try {
|
|
424
525
|
const r = await api('/providers');
|
|
526
|
+
// GET /providers returns a bare array; older drafts wrapped it
|
|
527
|
+
// as { providers: [...] }. Accept both so the dashboard works
|
|
528
|
+
// against any daemon version users might happen to be running.
|
|
529
|
+
const arr = Array.isArray(r) ? r : (r.providers || []);
|
|
425
530
|
const sel = document.getElementById('chat-assignee');
|
|
426
531
|
sel.innerHTML = '';
|
|
427
|
-
|
|
532
|
+
if (arr.length === 0) {
|
|
533
|
+
const opt = document.createElement('option');
|
|
534
|
+
opt.value = ''; opt.textContent = '(no providers — run lazyclaw onboard)';
|
|
535
|
+
sel.appendChild(opt);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// Preselect the configured default when possible so the user
|
|
539
|
+
// doesn't have to scroll through the list before sending the
|
|
540
|
+
// first message.
|
|
541
|
+
let defaultStatus = null;
|
|
542
|
+
try { defaultStatus = await api('/status'); } catch { /* keep going */ }
|
|
543
|
+
const defaultProv = defaultStatus?.provider || null;
|
|
544
|
+
const defaultModel = defaultStatus?.model || null;
|
|
545
|
+
const defaultValue = defaultProv && defaultModel ? `${defaultProv}:${defaultModel}` : defaultProv;
|
|
546
|
+
for (const p of arr) {
|
|
428
547
|
const ms = (p.suggestedModels || []);
|
|
429
548
|
if (!ms.length) {
|
|
430
549
|
const opt = document.createElement('option');
|
|
@@ -439,6 +558,18 @@
|
|
|
439
558
|
sel.appendChild(opt);
|
|
440
559
|
}
|
|
441
560
|
}
|
|
561
|
+
if (defaultValue) {
|
|
562
|
+
// Try exact match first (provider:model); fall back to any
|
|
563
|
+
// option starting with `<provider>:` if the configured model
|
|
564
|
+
// isn't in the suggested list.
|
|
565
|
+
const exact = Array.from(sel.options).find((o) => o.value === defaultValue);
|
|
566
|
+
if (exact) sel.value = defaultValue;
|
|
567
|
+
else {
|
|
568
|
+
const prefix = (defaultProv || '') + ':';
|
|
569
|
+
const byProv = Array.from(sel.options).find((o) => o.value.startsWith(prefix) || o.value === defaultProv);
|
|
570
|
+
if (byProv) sel.value = byProv.value;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
442
573
|
} catch (e) {
|
|
443
574
|
document.getElementById('chat-meta').textContent = '⚠ ' + e.message;
|
|
444
575
|
}
|
|
@@ -457,20 +588,76 @@
|
|
|
457
588
|
root.innerHTML = '';
|
|
458
589
|
arr.forEach((s) => {
|
|
459
590
|
const div = document.createElement('div');
|
|
460
|
-
div.className = 'card row';
|
|
591
|
+
div.className = 'card row clickable';
|
|
461
592
|
const id = s.id || s.sessionId || s.name || JSON.stringify(s);
|
|
462
593
|
const turns = s.turns ?? s.turnCount ?? '';
|
|
463
594
|
const updated = s.updatedAt || s.mtime || '';
|
|
464
|
-
div.innerHTML = `<div class="name">${id}</div>
|
|
595
|
+
div.innerHTML = `<div class="name">${escHtml(id)}</div>
|
|
465
596
|
<div class="dim">${turns ? turns + ' turns' : ''}</div>
|
|
466
|
-
<div class="dim
|
|
597
|
+
<div class="dim row-actions">${escHtml(updated)}</div>
|
|
598
|
+
<button class="btn btn-secondary btn-sm" data-action="view">View</button>
|
|
599
|
+
<button class="btn btn-secondary btn-sm" data-action="export">Export</button>
|
|
600
|
+
<button class="btn btn-danger btn-sm" data-action="delete">Delete</button>`;
|
|
601
|
+
div.addEventListener('click', (e) => {
|
|
602
|
+
const action = e.target.closest('button')?.dataset.action;
|
|
603
|
+
if (action === 'export') return openSessionExport(id);
|
|
604
|
+
if (action === 'delete') return deleteSession(id);
|
|
605
|
+
return openSessionDetail(id);
|
|
606
|
+
});
|
|
467
607
|
root.appendChild(div);
|
|
468
608
|
});
|
|
469
609
|
} catch (e) {
|
|
470
|
-
root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
|
|
610
|
+
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
471
611
|
}
|
|
472
612
|
};
|
|
473
613
|
|
|
614
|
+
async function openSessionDetail(id) {
|
|
615
|
+
openModal({ title: `Session — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
616
|
+
try {
|
|
617
|
+
const r = await api('/sessions/' + encodeURIComponent(id));
|
|
618
|
+
const turns = r.turns || r.entries || r;
|
|
619
|
+
if (!Array.isArray(turns) || turns.length === 0) {
|
|
620
|
+
document.getElementById('modal-body').innerHTML = '<div class="empty">Empty session.</div>';
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const html = turns.map((t) => {
|
|
624
|
+
const role = (t.role || 'note').toLowerCase();
|
|
625
|
+
const content = String(t.content ?? t.text ?? '');
|
|
626
|
+
const ts = t.ts || t.timestamp || '';
|
|
627
|
+
return `<div class="turn ${escHtml(role)}">
|
|
628
|
+
<span class="role-tag">${escHtml(role)}${ts ? ' · ' + escHtml(ts) : ''}</span>${escHtml(content)}
|
|
629
|
+
</div>`;
|
|
630
|
+
}).join('');
|
|
631
|
+
document.getElementById('modal-body').innerHTML = html;
|
|
632
|
+
} catch (e) {
|
|
633
|
+
document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function openSessionExport(id) {
|
|
638
|
+
openModal({ title: `Export — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
639
|
+
try {
|
|
640
|
+
const r = await fetch('/sessions/' + encodeURIComponent(id) + '/export?format=md');
|
|
641
|
+
const text = await r.text();
|
|
642
|
+
document.getElementById('modal-body').innerHTML = `<pre>${escHtml(text)}</pre>`;
|
|
643
|
+
document.getElementById('modal-foot').innerHTML = `
|
|
644
|
+
<button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy markdown',1200)">Copy markdown</button>
|
|
645
|
+
<button class="btn" onclick="closeModal()">Close</button>`;
|
|
646
|
+
} catch (e) {
|
|
647
|
+
document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function deleteSession(id) {
|
|
652
|
+
if (!confirm(`Delete session "${id}"?\nTurn log will be permanently removed.`)) return;
|
|
653
|
+
try {
|
|
654
|
+
await fetch('/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
655
|
+
LOADERS.sessions();
|
|
656
|
+
} catch (e) {
|
|
657
|
+
alert('Delete failed: ' + e.message);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
474
661
|
LOADERS.skills = async function loadSkills() {
|
|
475
662
|
const root = document.getElementById('skills-list');
|
|
476
663
|
root.innerHTML = '<div class="empty">Loading…</div>';
|
|
@@ -484,25 +671,60 @@
|
|
|
484
671
|
root.innerHTML = '';
|
|
485
672
|
arr.forEach((s) => {
|
|
486
673
|
const div = document.createElement('div');
|
|
487
|
-
div.className = 'card';
|
|
674
|
+
div.className = 'card clickable';
|
|
488
675
|
div.innerHTML = `<div class="row" style="border:0;padding:0;">
|
|
489
|
-
<div class="name">${s.name}</div>
|
|
490
|
-
<div class="dim
|
|
676
|
+
<div class="name">${escHtml(s.name)}</div>
|
|
677
|
+
<div class="dim row-actions">${(s.bytes ?? '')} bytes</div>
|
|
678
|
+
<button class="btn btn-secondary btn-sm" data-action="view">View</button>
|
|
679
|
+
<button class="btn btn-danger btn-sm" data-action="delete">Delete</button>
|
|
491
680
|
</div>
|
|
492
|
-
<div class="dim" style="margin-top:6px;">${s.summary || ''}</div>`;
|
|
681
|
+
<div class="dim" style="margin-top:6px;">${escHtml(s.summary || '')}</div>`;
|
|
682
|
+
div.addEventListener('click', (e) => {
|
|
683
|
+
const action = e.target.closest('button')?.dataset.action;
|
|
684
|
+
if (action === 'delete') return deleteSkill(s.name);
|
|
685
|
+
return openSkillDetail(s.name);
|
|
686
|
+
});
|
|
493
687
|
root.appendChild(div);
|
|
494
688
|
});
|
|
495
689
|
} catch (e) {
|
|
496
|
-
root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
|
|
690
|
+
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
497
691
|
}
|
|
498
692
|
};
|
|
499
693
|
|
|
694
|
+
async function openSkillDetail(name) {
|
|
695
|
+
openModal({ title: `Skill — ${name}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
696
|
+
try {
|
|
697
|
+
// GET /skills/<name> returns the markdown body as text/markdown.
|
|
698
|
+
const r = await fetch('/skills/' + encodeURIComponent(name));
|
|
699
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
700
|
+
const text = await r.text();
|
|
701
|
+
document.getElementById('modal-body').innerHTML = `<pre class="markdown">${escHtml(text)}</pre>`;
|
|
702
|
+
document.getElementById('modal-foot').innerHTML = `
|
|
703
|
+
<button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy',1200)">Copy</button>
|
|
704
|
+
<button class="btn" onclick="closeModal()">Close</button>`;
|
|
705
|
+
} catch (e) {
|
|
706
|
+
document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function deleteSkill(name) {
|
|
711
|
+
if (!confirm(`Remove skill "${name}"?`)) return;
|
|
712
|
+
try {
|
|
713
|
+
await fetch('/skills/' + encodeURIComponent(name), { method: 'DELETE' });
|
|
714
|
+
LOADERS.skills();
|
|
715
|
+
} catch (e) {
|
|
716
|
+
alert('Delete failed: ' + e.message);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
500
720
|
LOADERS.providers = async function loadProviders() {
|
|
501
721
|
const root = document.getElementById('providers-list');
|
|
722
|
+
const meta = document.getElementById('providers-meta');
|
|
502
723
|
root.innerHTML = '<div class="empty">Loading…</div>';
|
|
503
724
|
try {
|
|
504
725
|
const r = await api('/providers');
|
|
505
726
|
const arr = r.providers || r;
|
|
727
|
+
meta.textContent = `${arr.length} registered`;
|
|
506
728
|
root.innerHTML = '';
|
|
507
729
|
arr.forEach((p) => {
|
|
508
730
|
const div = document.createElement('div');
|
|
@@ -510,20 +732,132 @@
|
|
|
510
732
|
const tag = p.requiresApiKey
|
|
511
733
|
? '<span class="pill warn">api key</span>'
|
|
512
734
|
: '<span class="pill ok">no key</span>';
|
|
735
|
+
const customTag = p.custom ? ' <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">custom</span>' : '';
|
|
736
|
+
const builtinCompat = p.builtinOpenAICompat ? ' <span class="pill" style="background:rgba(74,222,128,0.12);color:var(--ok);">openai-compat</span>' : '';
|
|
513
737
|
const models = (p.suggestedModels || []).slice(0, 6).join(' · ') || '<span class="dim">(default)</span>';
|
|
738
|
+
const removeBtn = p.custom
|
|
739
|
+
? `<button class="btn btn-danger btn-sm" data-action="remove">Remove</button>`
|
|
740
|
+
: '';
|
|
514
741
|
div.innerHTML = `<div class="row" style="border:0;padding:0;">
|
|
515
|
-
<div class="name">${p.name}</div>${tag}
|
|
516
|
-
<div class="dim
|
|
742
|
+
<div class="name">${escHtml(p.name)}</div>${tag}${customTag}${builtinCompat}
|
|
743
|
+
<div class="dim row-actions">${escHtml(p.endpoint || '')}</div>
|
|
744
|
+
<button class="btn btn-secondary btn-sm" data-action="test">Test</button>
|
|
745
|
+
${removeBtn}
|
|
517
746
|
</div>
|
|
518
|
-
<div class="dim" style="margin-top:6px;">${p.docs || ''}</div>
|
|
519
|
-
<div style="margin-top:8px;font-size:12px;">${models}</div
|
|
747
|
+
<div class="dim" style="margin-top:6px;">${escHtml(p.docs || '')}</div>
|
|
748
|
+
<div style="margin-top:8px;font-size:12px;">${models}</div>
|
|
749
|
+
<div class="dim" data-test-result style="margin-top:6px;font-size:11px;"></div>`;
|
|
750
|
+
div.addEventListener('click', async (e) => {
|
|
751
|
+
const btn = e.target.closest('button');
|
|
752
|
+
if (!btn) return;
|
|
753
|
+
if (btn.dataset.action === 'test') return testProvider(p.name, div);
|
|
754
|
+
if (btn.dataset.action === 'remove') return removeProvider(p.name);
|
|
755
|
+
});
|
|
520
756
|
root.appendChild(div);
|
|
521
757
|
});
|
|
522
758
|
} catch (e) {
|
|
523
|
-
root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
|
|
759
|
+
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
524
760
|
}
|
|
525
761
|
};
|
|
526
762
|
|
|
763
|
+
async function testProvider(name, cardEl) {
|
|
764
|
+
const out = cardEl.querySelector('[data-test-result]');
|
|
765
|
+
out.textContent = '⏳ probing…';
|
|
766
|
+
out.style.color = 'var(--dim)';
|
|
767
|
+
try {
|
|
768
|
+
const r = await fetch('/providers/' + encodeURIComponent(name) + '/test');
|
|
769
|
+
const body = await r.json();
|
|
770
|
+
if (body.ok) {
|
|
771
|
+
out.style.color = 'var(--ok)';
|
|
772
|
+
const reply = (body.reply || '').replace(/\s+/g, ' ').slice(0, 120);
|
|
773
|
+
out.textContent = `✓ ok · ${body.model} · ${body.durationMs}ms${reply ? ' · ' + reply : ''}`;
|
|
774
|
+
} else {
|
|
775
|
+
out.style.color = 'var(--err)';
|
|
776
|
+
out.textContent = `✗ ${body.error || 'failed'} · ${body.code || r.status}`;
|
|
777
|
+
}
|
|
778
|
+
} catch (e) {
|
|
779
|
+
out.style.color = 'var(--err)';
|
|
780
|
+
out.textContent = '✗ ' + (e.message || String(e));
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function removeProvider(name) {
|
|
785
|
+
if (!confirm(`Remove custom provider "${name}"?`)) return;
|
|
786
|
+
try {
|
|
787
|
+
const r = await fetch('/providers/' + encodeURIComponent(name), { method: 'DELETE' });
|
|
788
|
+
const body = await r.json();
|
|
789
|
+
if (!r.ok) throw new Error(body.error || `${r.status}`);
|
|
790
|
+
LOADERS.providers();
|
|
791
|
+
} catch (e) {
|
|
792
|
+
alert('Remove failed: ' + e.message);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function openAddProviderModal() {
|
|
797
|
+
openModal({
|
|
798
|
+
title: 'Add custom OpenAI-compat provider',
|
|
799
|
+
bodyHtml: `
|
|
800
|
+
<div class="dim" style="margin-bottom:14px;font-size:12px;">
|
|
801
|
+
Works with any service that speaks the OpenAI v1 wire format
|
|
802
|
+
(vLLM · LM Studio · private gateways · self-hosted DeepInfra).
|
|
803
|
+
Built-in aliases (<code>nim</code>, <code>openrouter</code>, <code>groq</code>, …)
|
|
804
|
+
can be overridden by registering a custom entry of the same name.
|
|
805
|
+
</div>
|
|
806
|
+
<div class="form-row">
|
|
807
|
+
<label for="add-prov-name">Name (short id, e.g. "nim", "openrouter")</label>
|
|
808
|
+
<input id="add-prov-name" autofocus placeholder="e.g. my-vllm" />
|
|
809
|
+
</div>
|
|
810
|
+
<div class="form-row">
|
|
811
|
+
<label for="add-prov-baseurl">Base URL (must end in /v1)</label>
|
|
812
|
+
<input id="add-prov-baseurl" placeholder="https://integrate.api.nvidia.com/v1" />
|
|
813
|
+
</div>
|
|
814
|
+
<div class="form-row">
|
|
815
|
+
<label for="add-prov-apikey">API key (blank for auth-less endpoints)</label>
|
|
816
|
+
<input id="add-prov-apikey" type="password" placeholder="nvapi-…" />
|
|
817
|
+
</div>
|
|
818
|
+
<div class="form-row">
|
|
819
|
+
<label for="add-prov-model">Default model id (optional)</label>
|
|
820
|
+
<input id="add-prov-model" placeholder="meta/llama-3.1-405b-instruct" />
|
|
821
|
+
</div>
|
|
822
|
+
<div id="add-prov-status" class="dim" style="font-size:12px;"></div>
|
|
823
|
+
`,
|
|
824
|
+
footHtml: `
|
|
825
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
826
|
+
<button class="btn" onclick="submitAddProvider()">Save</button>
|
|
827
|
+
`,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function submitAddProvider() {
|
|
832
|
+
const name = document.getElementById('add-prov-name').value.trim();
|
|
833
|
+
const baseUrl = document.getElementById('add-prov-baseurl').value.trim();
|
|
834
|
+
const apiKey = document.getElementById('add-prov-apikey').value.trim();
|
|
835
|
+
const defaultModel = document.getElementById('add-prov-model').value.trim();
|
|
836
|
+
const status = document.getElementById('add-prov-status');
|
|
837
|
+
status.style.color = 'var(--dim)';
|
|
838
|
+
status.textContent = 'Saving…';
|
|
839
|
+
try {
|
|
840
|
+
const r = await fetch('/providers', {
|
|
841
|
+
method: 'POST',
|
|
842
|
+
headers: { 'content-type': 'application/json' },
|
|
843
|
+
body: JSON.stringify({ name, baseUrl, apiKey: apiKey || undefined, defaultModel: defaultModel || undefined }),
|
|
844
|
+
});
|
|
845
|
+
const body = await r.json();
|
|
846
|
+
if (!r.ok) {
|
|
847
|
+
status.style.color = 'var(--err)';
|
|
848
|
+
status.textContent = '✗ ' + (body.error || `${r.status} ${r.statusText}`);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
status.style.color = 'var(--ok)';
|
|
852
|
+
const overrideNote = body.overridesBuiltin ? ' (overrides built-in)' : '';
|
|
853
|
+
status.textContent = `✓ saved — ${body.name} → ${body.baseUrl}${overrideNote}`;
|
|
854
|
+
setTimeout(() => { closeModal(); LOADERS.providers(); }, 700);
|
|
855
|
+
} catch (e) {
|
|
856
|
+
status.style.color = 'var(--err)';
|
|
857
|
+
status.textContent = '✗ ' + (e.message || String(e));
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
527
861
|
LOADERS.status = async function loadStatus() {
|
|
528
862
|
const root = document.getElementById('status-card');
|
|
529
863
|
root.innerHTML = '<div class="empty">Loading…</div>';
|
|
@@ -584,23 +918,79 @@
|
|
|
584
918
|
if (sm.resumable) tags.push('<span class="pill warn">resumable</span>');
|
|
585
919
|
if (sm.done) tags.push('<span class="pill ok">done</span>');
|
|
586
920
|
const total = sm.total ?? '';
|
|
587
|
-
return `<tr>
|
|
921
|
+
return `<tr class="clickable" data-wf-id="${escHtml(s.sessionId)}">
|
|
588
922
|
<td><code>${escHtml(s.sessionId)}</code></td>
|
|
589
923
|
<td>${tags.join(' ') || '<span class="dim">—</span>'}</td>
|
|
590
924
|
<td class="num">${sm.done ?? 0} / ${total}</td>
|
|
591
925
|
<td class="num">${sm.failed ?? 0}</td>
|
|
592
926
|
<td class="dim">${escHtml(s.updatedAt || s.startedAt || '')}</td>
|
|
927
|
+
<td><button class="btn btn-danger btn-sm" data-action="wf-delete">Delete</button></td>
|
|
593
928
|
</tr>`;
|
|
594
929
|
}).join('');
|
|
595
930
|
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>
|
|
931
|
+
<thead><tr><th>Session</th><th>State</th><th>Done / Total</th><th>Failed</th><th>Updated</th><th></th></tr></thead>
|
|
597
932
|
<tbody>${rows}</tbody>
|
|
598
933
|
</table>`;
|
|
934
|
+
list.querySelectorAll('tr[data-wf-id]').forEach((tr) => {
|
|
935
|
+
tr.addEventListener('click', (e) => {
|
|
936
|
+
const id = tr.getAttribute('data-wf-id');
|
|
937
|
+
const action = e.target.closest('button')?.dataset.action;
|
|
938
|
+
if (action === 'wf-delete') return deleteWorkflow(id);
|
|
939
|
+
return openWorkflowDetail(id);
|
|
940
|
+
});
|
|
941
|
+
});
|
|
599
942
|
} catch (e) {
|
|
600
943
|
list.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
601
944
|
}
|
|
602
945
|
};
|
|
603
946
|
|
|
947
|
+
async function openWorkflowDetail(id) {
|
|
948
|
+
openModal({ title: `Workflow — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
949
|
+
try {
|
|
950
|
+
const r = await api('/workflows/' + encodeURIComponent(id));
|
|
951
|
+
const sm = r.summary || {};
|
|
952
|
+
const nodes = r.state?.nodeResults || r.nodeResults || {};
|
|
953
|
+
const nodeRows = Object.entries(nodes).map(([nid, n]) => {
|
|
954
|
+
const status = (n.status || '').toLowerCase();
|
|
955
|
+
const pillClass = status === 'failed' ? 'err' : (status === 'done' ? 'ok' : (status === 'running' ? 'warn' : ''));
|
|
956
|
+
const dur = n.durationMs != null ? fmtDuration(n.durationMs) : '—';
|
|
957
|
+
const out = String(n.output ?? n.error ?? '');
|
|
958
|
+
const truncated = out.length > 240 ? out.slice(0, 240) + '…' : out;
|
|
959
|
+
return `<tr>
|
|
960
|
+
<td><code>${escHtml(nid)}</code></td>
|
|
961
|
+
<td>${pillClass ? `<span class="pill ${pillClass}">${escHtml(status)}</span>` : escHtml(status || '—')}</td>
|
|
962
|
+
<td class="num">${dur}</td>
|
|
963
|
+
<td class="dim">${escHtml(truncated)}</td>
|
|
964
|
+
</tr>`;
|
|
965
|
+
}).join('');
|
|
966
|
+
const summaryHtml = `<div class="grid" style="margin-bottom:14px;">
|
|
967
|
+
<div class="stat"><div class="label">Total</div><div class="value">${sm.total ?? '—'}</div></div>
|
|
968
|
+
<div class="stat"><div class="label">Done</div><div class="value">${sm.done ?? 0}</div></div>
|
|
969
|
+
<div class="stat"><div class="label">Failed</div><div class="value" style="color:${sm.failed ? 'var(--err)' : 'inherit'}">${sm.failed ?? 0}</div></div>
|
|
970
|
+
<div class="stat"><div class="label">Running</div><div class="value">${sm.running ?? 0}</div></div>
|
|
971
|
+
</div>`;
|
|
972
|
+
const tableHtml = nodeRows
|
|
973
|
+
? `<table class="tbl">
|
|
974
|
+
<thead><tr><th>Node</th><th>Status</th><th>Duration</th><th>Output / Error</th></tr></thead>
|
|
975
|
+
<tbody>${nodeRows}</tbody>
|
|
976
|
+
</table>`
|
|
977
|
+
: '<div class="empty">No node results yet.</div>';
|
|
978
|
+
document.getElementById('modal-body').innerHTML = summaryHtml + tableHtml;
|
|
979
|
+
} catch (e) {
|
|
980
|
+
document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function deleteWorkflow(id) {
|
|
985
|
+
if (!confirm(`Delete workflow session "${id}"?\nState file will be permanently removed.`)) return;
|
|
986
|
+
try {
|
|
987
|
+
await fetch('/workflows/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
988
|
+
LOADERS.workflows();
|
|
989
|
+
} catch (e) {
|
|
990
|
+
alert('Delete failed: ' + e.message);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
604
994
|
// ── Rates ────────────────────────────────────────────────────
|
|
605
995
|
document.getElementById('rates-filter').addEventListener('input', debounce(() => LOADERS.rates(), 250));
|
|
606
996
|
|
|
@@ -638,24 +1028,115 @@
|
|
|
638
1028
|
}
|
|
639
1029
|
const rows = entries.map(([key, card]) => {
|
|
640
1030
|
const c = card || {};
|
|
641
|
-
return `<tr>
|
|
1031
|
+
return `<tr data-rate-key="${escHtml(key)}">
|
|
642
1032
|
<td><code>${escHtml(key)}</code></td>
|
|
643
1033
|
<td class="num">${c.in ?? '—'}</td>
|
|
644
1034
|
<td class="num">${c.out ?? '—'}</td>
|
|
645
1035
|
<td class="num">${c['cache-read'] ?? '—'}</td>
|
|
646
1036
|
<td class="num">${c['cache-create'] ?? '—'}</td>
|
|
647
1037
|
<td class="dim">${escHtml(c.currency || 'USD')} / 1M tok</td>
|
|
1038
|
+
<td>
|
|
1039
|
+
<button class="btn btn-secondary btn-sm" data-action="rate-edit">Edit</button>
|
|
1040
|
+
<button class="btn btn-danger btn-sm" data-action="rate-delete">Delete</button>
|
|
1041
|
+
</td>
|
|
648
1042
|
</tr>`;
|
|
649
1043
|
}).join('');
|
|
650
1044
|
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>
|
|
1045
|
+
<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>
|
|
652
1046
|
<tbody>${rows}</tbody>
|
|
653
1047
|
</table>`;
|
|
1048
|
+
root.querySelectorAll('tr[data-rate-key]').forEach((tr) => {
|
|
1049
|
+
const key = tr.getAttribute('data-rate-key');
|
|
1050
|
+
const card = (rates || {})[key] || {};
|
|
1051
|
+
tr.querySelector('[data-action="rate-edit"]')?.addEventListener('click', () => openRateCardModal(key, card));
|
|
1052
|
+
tr.querySelector('[data-action="rate-delete"]')?.addEventListener('click', () => deleteRateCard(key));
|
|
1053
|
+
});
|
|
654
1054
|
} catch (e) {
|
|
655
1055
|
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
656
1056
|
}
|
|
657
1057
|
};
|
|
658
1058
|
|
|
1059
|
+
function openRateCardModal(existingKey = '', existingCard = {}) {
|
|
1060
|
+
const c = existingCard || {};
|
|
1061
|
+
openModal({
|
|
1062
|
+
title: existingKey ? `Edit rate card — ${existingKey}` : 'Add rate card',
|
|
1063
|
+
bodyHtml: `
|
|
1064
|
+
<div class="dim" style="margin-bottom:12px;font-size:12px;">
|
|
1065
|
+
Cost per 1M tokens (input / output / optional cache pricing).
|
|
1066
|
+
Same shape as <code>lazyclaw rates set</code>. Saving the same
|
|
1067
|
+
key overwrites the existing card.
|
|
1068
|
+
</div>
|
|
1069
|
+
<div class="form-row">
|
|
1070
|
+
<label for="rate-key">Provider / model key</label>
|
|
1071
|
+
<input id="rate-key" placeholder="anthropic/claude-opus-4-7" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
|
|
1072
|
+
</div>
|
|
1073
|
+
<div class="grid" style="grid-template-columns:1fr 1fr;gap:10px;margin-bottom:0;">
|
|
1074
|
+
<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>
|
|
1075
|
+
<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>
|
|
1076
|
+
<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>
|
|
1077
|
+
<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>
|
|
1078
|
+
<div class="form-row"><label for="rate-currency">Currency</label><input id="rate-currency" value="${escHtml(c.currency || 'USD')}"/></div>
|
|
1079
|
+
</div>
|
|
1080
|
+
<div id="rate-status" class="dim" style="font-size:12px;margin-top:8px;"></div>
|
|
1081
|
+
`,
|
|
1082
|
+
footHtml: `
|
|
1083
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
1084
|
+
<button class="btn" onclick="submitRateCard()">Save</button>
|
|
1085
|
+
`,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function submitRateCard() {
|
|
1090
|
+
const key = document.getElementById('rate-key').value.trim();
|
|
1091
|
+
const status = document.getElementById('rate-status');
|
|
1092
|
+
if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
|
|
1093
|
+
const card = {
|
|
1094
|
+
in: parseFloat(document.getElementById('rate-in').value) || 0,
|
|
1095
|
+
out: parseFloat(document.getElementById('rate-out').value) || 0,
|
|
1096
|
+
currency: document.getElementById('rate-currency').value.trim() || 'USD',
|
|
1097
|
+
};
|
|
1098
|
+
const cr = parseFloat(document.getElementById('rate-cache-read').value);
|
|
1099
|
+
const cc = parseFloat(document.getElementById('rate-cache-create').value);
|
|
1100
|
+
if (Number.isFinite(cr)) card['cache-read'] = cr;
|
|
1101
|
+
if (Number.isFinite(cc)) card['cache-create'] = cc;
|
|
1102
|
+
status.style.color = 'var(--dim)';
|
|
1103
|
+
status.textContent = 'Saving…';
|
|
1104
|
+
try {
|
|
1105
|
+
const r = await fetch('/rates/' + encodeURIComponent(key), {
|
|
1106
|
+
method: 'PUT',
|
|
1107
|
+
headers: { 'content-type': 'application/json' },
|
|
1108
|
+
body: JSON.stringify(card),
|
|
1109
|
+
});
|
|
1110
|
+
const body = await r.json();
|
|
1111
|
+
if (!r.ok) {
|
|
1112
|
+
status.style.color = 'var(--err)';
|
|
1113
|
+
const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
|
|
1114
|
+
status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
status.style.color = 'var(--ok)';
|
|
1118
|
+
status.textContent = `✓ saved`;
|
|
1119
|
+
setTimeout(() => { closeModal(); LOADERS.rates(); }, 600);
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
status.style.color = 'var(--err)';
|
|
1122
|
+
status.textContent = '✗ ' + (e.message || String(e));
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async function deleteRateCard(key) {
|
|
1127
|
+
if (!confirm(`Delete rate card "${key}"?`)) return;
|
|
1128
|
+
try {
|
|
1129
|
+
const r = await fetch('/rates/' + encodeURIComponent(key), { method: 'DELETE' });
|
|
1130
|
+
if (!r.ok) {
|
|
1131
|
+
const body = await r.json().catch(() => ({}));
|
|
1132
|
+
throw new Error(body.error || `${r.status}`);
|
|
1133
|
+
}
|
|
1134
|
+
LOADERS.rates();
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
alert('Delete failed: ' + e.message);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
659
1140
|
// ── Metrics ──────────────────────────────────────────────────
|
|
660
1141
|
LOADERS.metrics = async function loadMetrics() {
|
|
661
1142
|
const cards = document.getElementById('metrics-cards');
|
|
@@ -748,21 +1229,121 @@
|
|
|
748
1229
|
root.innerHTML = '<div class="empty">No config yet. Run <code>lazyclaw onboard</code>.</div>';
|
|
749
1230
|
return;
|
|
750
1231
|
}
|
|
1232
|
+
const NESTED = new Set(['customProviders', 'rates', 'authProfiles', 'authActiveProfile']);
|
|
751
1233
|
const rows = keys.sort().map((k) => {
|
|
752
1234
|
const v = cfg[k];
|
|
753
1235
|
const display = v && typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
754
|
-
|
|
1236
|
+
const nested = NESTED.has(k);
|
|
1237
|
+
const editBtn = nested
|
|
1238
|
+
? `<span class="dim" style="font-size:11px;">use the dedicated tab</span>`
|
|
1239
|
+
: `<button class="btn btn-secondary btn-sm" data-action="cfg-edit" data-key="${escHtml(k)}">Edit</button>
|
|
1240
|
+
<button class="btn btn-danger btn-sm" data-action="cfg-delete" data-key="${escHtml(k)}">Delete</button>`;
|
|
1241
|
+
return `<tr><td><code>${escHtml(k)}</code></td><td>${escHtml(display)}</td><td>${editBtn}</td></tr>`;
|
|
755
1242
|
}).join('');
|
|
756
1243
|
root.innerHTML = `<table class="tbl">
|
|
757
|
-
<thead><tr><th style="width:
|
|
1244
|
+
<thead><tr><th style="width:25%">Key</th><th>Value</th><th style="width:160px"></th></tr></thead>
|
|
758
1245
|
<tbody>${rows}</tbody>
|
|
759
1246
|
</table>`;
|
|
1247
|
+
root.querySelectorAll('[data-action="cfg-edit"]').forEach((b) => {
|
|
1248
|
+
b.addEventListener('click', () => openConfigEditModal(b.dataset.key, cfg[b.dataset.key]));
|
|
1249
|
+
});
|
|
1250
|
+
root.querySelectorAll('[data-action="cfg-delete"]').forEach((b) => {
|
|
1251
|
+
b.addEventListener('click', () => deleteConfigKey(b.dataset.key));
|
|
1252
|
+
});
|
|
760
1253
|
raw.textContent = JSON.stringify(cfg, null, 2);
|
|
761
1254
|
} catch (e) {
|
|
762
1255
|
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
763
1256
|
}
|
|
764
1257
|
};
|
|
765
1258
|
|
|
1259
|
+
function openConfigEditModal(existingKey = '', existingValue = '') {
|
|
1260
|
+
// Stringify for the editor; objects/arrays become JSON, primitives stay
|
|
1261
|
+
// raw. Submitter parses JSON when the value looks like JSON, else
|
|
1262
|
+
// sends a string verbatim — same behaviour as `lazyclaw config set`.
|
|
1263
|
+
let display = '';
|
|
1264
|
+
if (typeof existingValue === 'string') display = existingValue;
|
|
1265
|
+
else if (existingValue == null) display = '';
|
|
1266
|
+
else display = JSON.stringify(existingValue, null, 2);
|
|
1267
|
+
openModal({
|
|
1268
|
+
title: existingKey ? `Edit config — ${existingKey}` : 'Set config key',
|
|
1269
|
+
bodyHtml: `
|
|
1270
|
+
<div class="dim" style="margin-bottom:12px;font-size:12px;">
|
|
1271
|
+
Mirrors <code>lazyclaw config set <key> <value></code>. Values that look like
|
|
1272
|
+
JSON (start with <code>{</code> / <code>[</code> / <code>"</code> / <code>true</code> / <code>false</code> / a number)
|
|
1273
|
+
are parsed; everything else is stored as a plain string. Nested
|
|
1274
|
+
stores (<code>customProviders</code>, <code>rates</code>, <code>authProfiles</code>) have their own
|
|
1275
|
+
tabs — this form rejects them.
|
|
1276
|
+
</div>
|
|
1277
|
+
<div class="form-row">
|
|
1278
|
+
<label for="cfg-key">Key</label>
|
|
1279
|
+
<input id="cfg-key" placeholder="provider · model · api-key · skills · …" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
|
|
1280
|
+
</div>
|
|
1281
|
+
<div class="form-row">
|
|
1282
|
+
<label for="cfg-value">Value</label>
|
|
1283
|
+
<textarea id="cfg-value" rows="6">${escHtml(display)}</textarea>
|
|
1284
|
+
</div>
|
|
1285
|
+
<div id="cfg-status" class="dim" style="font-size:12px;"></div>
|
|
1286
|
+
`,
|
|
1287
|
+
footHtml: `
|
|
1288
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
1289
|
+
<button class="btn" onclick="submitConfigEdit()">Save</button>
|
|
1290
|
+
`,
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async function submitConfigEdit() {
|
|
1295
|
+
const key = document.getElementById('cfg-key').value.trim();
|
|
1296
|
+
const raw = document.getElementById('cfg-value').value;
|
|
1297
|
+
const status = document.getElementById('cfg-status');
|
|
1298
|
+
if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
|
|
1299
|
+
// Heuristic JSON parse — same surface as the CLI: try parse; if it
|
|
1300
|
+
// throws, send the raw string. Numbers / true / false / null / objects /
|
|
1301
|
+
// arrays / quoted strings end up correctly typed.
|
|
1302
|
+
let value;
|
|
1303
|
+
const trimmed = raw.trim();
|
|
1304
|
+
if (trimmed === '') value = '';
|
|
1305
|
+
else {
|
|
1306
|
+
try { value = JSON.parse(trimmed); }
|
|
1307
|
+
catch { value = raw; }
|
|
1308
|
+
}
|
|
1309
|
+
status.style.color = 'var(--dim)';
|
|
1310
|
+
status.textContent = 'Saving…';
|
|
1311
|
+
try {
|
|
1312
|
+
const r = await fetch('/config/' + encodeURIComponent(key), {
|
|
1313
|
+
method: 'PUT',
|
|
1314
|
+
headers: { 'content-type': 'application/json' },
|
|
1315
|
+
body: JSON.stringify({ value }),
|
|
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.config(); }, 600);
|
|
1327
|
+
} catch (e) {
|
|
1328
|
+
status.style.color = 'var(--err)';
|
|
1329
|
+
status.textContent = '✗ ' + (e.message || String(e));
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
async function deleteConfigKey(key) {
|
|
1334
|
+
if (!confirm(`Delete config key "${key}"?`)) return;
|
|
1335
|
+
try {
|
|
1336
|
+
const r = await fetch('/config/' + 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.config();
|
|
1342
|
+
} catch (e) {
|
|
1343
|
+
alert('Delete failed: ' + e.message);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
766
1347
|
// First load = chat tab.
|
|
767
1348
|
LOADERS.chat();
|
|
768
1349
|
|