lazyclaw 3.99.14 → 3.99.16
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 +64 -22
- package/daemon.mjs +202 -1
- package/package.json +1 -1
- package/providers/registry.mjs +16 -4
- package/web/dashboard.html +573 -22
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 = [
|
|
@@ -1731,12 +1736,19 @@ async function _pickProviderInteractive() {
|
|
|
1731
1736
|
while (!family) {
|
|
1732
1737
|
const familyItems = Object.entries(families)
|
|
1733
1738
|
.filter(([, b]) => b.members.length > 0)
|
|
1734
|
-
.map(([id, b]) =>
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1739
|
+
.map(([id, b]) => {
|
|
1740
|
+
// Show member count + a few names instead of the full list — the
|
|
1741
|
+
// API-key family alone now has 12 vendors and joining all of them
|
|
1742
|
+
// produced an unreadable line.
|
|
1743
|
+
const preview = b.members.slice(0, 3).join(' / ');
|
|
1744
|
+
const more = b.members.length > 3 ? ` … (+${b.members.length - 3} more)` : '';
|
|
1745
|
+
return {
|
|
1746
|
+
id,
|
|
1747
|
+
label: b.label,
|
|
1748
|
+
desc: `${b.desc} · ${preview}${more}`,
|
|
1749
|
+
tag: b.tag,
|
|
1750
|
+
};
|
|
1751
|
+
});
|
|
1740
1752
|
const picked = await _arrowMenu({
|
|
1741
1753
|
title: 'LazyClaw setup — Step 1 of 3: pick how you want to auth',
|
|
1742
1754
|
subtitle: 'API: bring your own key · CLI/Local: use what\'s already on this machine · Mock: offline test',
|
|
@@ -1752,14 +1764,21 @@ async function _pickProviderInteractive() {
|
|
|
1752
1764
|
const memberNames = families[family.id].members;
|
|
1753
1765
|
const provItems = memberNames.map((name) => {
|
|
1754
1766
|
const meta = info[name] || {};
|
|
1755
|
-
const models = (meta.suggestedModels || []).slice(0, 4).join(' · ') || '(default)';
|
|
1756
1767
|
const isCustom = !!meta.custom;
|
|
1768
|
+
const isBuiltinCompat = !!meta.builtinOpenAICompat;
|
|
1769
|
+
// Step-2 desc used to preview four suggested model ids per provider.
|
|
1770
|
+
// That made the row read like "gemini · models: gemini-2.5-pro ·
|
|
1771
|
+
// gemini-2.5-flash · gemini-2.0-flash · gemini-2.0-flash-thinking-exp",
|
|
1772
|
+
// which is too dense and partly redundant — step 3 already shows the
|
|
1773
|
+
// full curated list. Keep the row to a vendor label + endpoint hint.
|
|
1774
|
+
let desc = '';
|
|
1775
|
+
if (isCustom) desc = `custom · ${meta.baseUrl || ''}`;
|
|
1776
|
+
else if (isBuiltinCompat) desc = meta.label || meta.baseUrl || '';
|
|
1777
|
+
else if (meta.label && meta.label !== name) desc = meta.label;
|
|
1757
1778
|
return {
|
|
1758
1779
|
id: name,
|
|
1759
1780
|
label: name,
|
|
1760
|
-
desc
|
|
1761
|
-
? `custom · ${meta.baseUrl || ''}`
|
|
1762
|
-
: `models: ${models}`,
|
|
1781
|
+
desc,
|
|
1763
1782
|
tag: isCustom
|
|
1764
1783
|
? '\x1b[38;5;213m[custom]\x1b[0m'
|
|
1765
1784
|
: (meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m'),
|
|
@@ -1970,7 +1989,7 @@ async function _addCustomProviderInteractive() {
|
|
|
1970
1989
|
process.stdout.write(dim(' · Groq https://api.groq.com/openai/v1') + '\n');
|
|
1971
1990
|
process.stdout.write(dim(' · vLLM / LM Studio http://localhost:8000/v1') + '\n\n');
|
|
1972
1991
|
|
|
1973
|
-
const { validateCustomProviderName, registerCustomProviders, fetchOpenAICompatModels } = _registryMod;
|
|
1992
|
+
const { validateCustomProviderName, registerCustomProviders, fetchOpenAICompatModels, isBuiltinOpenAICompatName } = _registryMod;
|
|
1974
1993
|
let name;
|
|
1975
1994
|
while (true) {
|
|
1976
1995
|
const raw = (await _quickPrompt(` ${bold('name')} ${dim('(short id, e.g. "nim", "openrouter"):')} `)).trim();
|
|
@@ -1978,8 +1997,24 @@ async function _addCustomProviderInteractive() {
|
|
|
1978
1997
|
process.stdout.write(dim(' cancelled — back to the picker.\n'));
|
|
1979
1998
|
return null;
|
|
1980
1999
|
}
|
|
1981
|
-
try { name = validateCustomProviderName(raw);
|
|
1982
|
-
catch (e) {
|
|
2000
|
+
try { name = validateCustomProviderName(raw); }
|
|
2001
|
+
catch (e) {
|
|
2002
|
+
process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`);
|
|
2003
|
+
continue;
|
|
2004
|
+
}
|
|
2005
|
+
// OpenAI-compat builtins (nim / openrouter / groq / …) can be overridden
|
|
2006
|
+
// by a custom entry of the same name — both go through
|
|
2007
|
+
// makeOpenAICompatProvider, so the wire format is identical and the
|
|
2008
|
+
// user is just pointing the same alias at a different URL/key. Surface
|
|
2009
|
+
// the override so it isn't a silent surprise.
|
|
2010
|
+
if (typeof isBuiltinOpenAICompatName === 'function' && isBuiltinOpenAICompatName(name)) {
|
|
2011
|
+
process.stdout.write(
|
|
2012
|
+
` \x1b[2mNote: "${name}" is a built-in OpenAI-compatible provider; ` +
|
|
2013
|
+
`your custom entry will override the built-in baseUrl/api-key for this install. ` +
|
|
2014
|
+
`Remove with: lazyclaw providers remove ${name}\x1b[0m\n`
|
|
2015
|
+
);
|
|
2016
|
+
}
|
|
2017
|
+
break;
|
|
1983
2018
|
}
|
|
1984
2019
|
const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
|
|
1985
2020
|
if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
|
|
@@ -2463,6 +2498,7 @@ async function cmdDashboard(flags = {}) {
|
|
|
2463
2498
|
port,
|
|
2464
2499
|
once: false,
|
|
2465
2500
|
readConfig,
|
|
2501
|
+
writeConfig,
|
|
2466
2502
|
sessionsDirGetter: () => cfgDir,
|
|
2467
2503
|
sessionsMod,
|
|
2468
2504
|
version: () => readVersionFromRepo(),
|
|
@@ -2568,6 +2604,12 @@ async function cmdDaemon(flags) {
|
|
|
2568
2604
|
port: Number.isFinite(port) ? port : 0,
|
|
2569
2605
|
once,
|
|
2570
2606
|
readConfig,
|
|
2607
|
+
// `lazyclaw daemon` exposes mutation endpoints (POST /providers,
|
|
2608
|
+
// PUT /rates/<key>, etc.) only when an auth token is configured
|
|
2609
|
+
// — without one the daemon is loopback-only but still untrusted
|
|
2610
|
+
// (any process on the box can hit it). dashboard subcommand sets
|
|
2611
|
+
// writeConfig unconditionally because it always runs as the user.
|
|
2612
|
+
writeConfig: authToken ? writeConfig : undefined,
|
|
2571
2613
|
sessionsDirGetter: () => cfgDir,
|
|
2572
2614
|
sessionsMod,
|
|
2573
2615
|
version: () => readVersionFromRepo(),
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "3.99.
|
|
3
|
+
"version": "3.99.16",
|
|
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/providers/registry.mjs
CHANGED
|
@@ -413,15 +413,27 @@ export function parseProviderModel(s) {
|
|
|
413
413
|
* @param {string|undefined|null} key
|
|
414
414
|
* @returns {string}
|
|
415
415
|
*/
|
|
416
|
-
// Reserved provider names —
|
|
417
|
-
//
|
|
416
|
+
// Reserved provider names — names whose factory is bespoke (not the
|
|
417
|
+
// generic OpenAI-compat one) so a custom registration of the same name
|
|
418
|
+
// would silently break the wire format. The OpenAI-compat builtins are
|
|
419
|
+
// deliberately NOT listed: a user can register `nim` / `openrouter` /
|
|
420
|
+
// etc. as a custom entry to override the baseUrl / api-key / headers,
|
|
421
|
+
// because both the built-in and the custom go through
|
|
422
|
+
// `makeOpenAICompatProvider` — overriding is well-defined.
|
|
418
423
|
const RESERVED_PROVIDER_NAMES = new Set([
|
|
419
424
|
'mock', 'claude-cli', 'anthropic', 'openai', 'gemini', 'ollama',
|
|
420
|
-
// OpenAI-compatible builtins (kept in lockstep with OPENAI_COMPAT_BUILTINS).
|
|
421
|
-
...Object.keys(OPENAI_COMPAT_BUILTINS),
|
|
422
425
|
'__add_custom__', '__custom_model__', '__fetch_models__',
|
|
423
426
|
]);
|
|
424
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Whether the supplied name belongs to one of the OpenAI-compatible
|
|
430
|
+
* builtins. Used by the custom-add interactive flow so it can warn the
|
|
431
|
+
* user that their custom entry will shadow the built-in registration.
|
|
432
|
+
*/
|
|
433
|
+
export function isBuiltinOpenAICompatName(name) {
|
|
434
|
+
return Object.prototype.hasOwnProperty.call(OPENAI_COMPAT_BUILTINS, String(name || '').trim().toLowerCase());
|
|
435
|
+
}
|
|
436
|
+
|
|
425
437
|
/**
|
|
426
438
|
* Validate a custom provider name. Allowed: lowercase alnum + dash + dot.
|
|
427
439
|
* Returns the trimmed name on success; throws on collision / bad format.
|
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);
|
|
@@ -457,20 +558,76 @@
|
|
|
457
558
|
root.innerHTML = '';
|
|
458
559
|
arr.forEach((s) => {
|
|
459
560
|
const div = document.createElement('div');
|
|
460
|
-
div.className = 'card row';
|
|
561
|
+
div.className = 'card row clickable';
|
|
461
562
|
const id = s.id || s.sessionId || s.name || JSON.stringify(s);
|
|
462
563
|
const turns = s.turns ?? s.turnCount ?? '';
|
|
463
564
|
const updated = s.updatedAt || s.mtime || '';
|
|
464
|
-
div.innerHTML = `<div class="name">${id}</div>
|
|
565
|
+
div.innerHTML = `<div class="name">${escHtml(id)}</div>
|
|
465
566
|
<div class="dim">${turns ? turns + ' turns' : ''}</div>
|
|
466
|
-
<div class="dim
|
|
567
|
+
<div class="dim row-actions">${escHtml(updated)}</div>
|
|
568
|
+
<button class="btn btn-secondary btn-sm" data-action="view">View</button>
|
|
569
|
+
<button class="btn btn-secondary btn-sm" data-action="export">Export</button>
|
|
570
|
+
<button class="btn btn-danger btn-sm" data-action="delete">Delete</button>`;
|
|
571
|
+
div.addEventListener('click', (e) => {
|
|
572
|
+
const action = e.target.closest('button')?.dataset.action;
|
|
573
|
+
if (action === 'export') return openSessionExport(id);
|
|
574
|
+
if (action === 'delete') return deleteSession(id);
|
|
575
|
+
return openSessionDetail(id);
|
|
576
|
+
});
|
|
467
577
|
root.appendChild(div);
|
|
468
578
|
});
|
|
469
579
|
} catch (e) {
|
|
470
|
-
root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
|
|
580
|
+
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
471
581
|
}
|
|
472
582
|
};
|
|
473
583
|
|
|
584
|
+
async function openSessionDetail(id) {
|
|
585
|
+
openModal({ title: `Session — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
586
|
+
try {
|
|
587
|
+
const r = await api('/sessions/' + encodeURIComponent(id));
|
|
588
|
+
const turns = r.turns || r.entries || r;
|
|
589
|
+
if (!Array.isArray(turns) || turns.length === 0) {
|
|
590
|
+
document.getElementById('modal-body').innerHTML = '<div class="empty">Empty session.</div>';
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const html = turns.map((t) => {
|
|
594
|
+
const role = (t.role || 'note').toLowerCase();
|
|
595
|
+
const content = String(t.content ?? t.text ?? '');
|
|
596
|
+
const ts = t.ts || t.timestamp || '';
|
|
597
|
+
return `<div class="turn ${escHtml(role)}">
|
|
598
|
+
<span class="role-tag">${escHtml(role)}${ts ? ' · ' + escHtml(ts) : ''}</span>${escHtml(content)}
|
|
599
|
+
</div>`;
|
|
600
|
+
}).join('');
|
|
601
|
+
document.getElementById('modal-body').innerHTML = html;
|
|
602
|
+
} catch (e) {
|
|
603
|
+
document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function openSessionExport(id) {
|
|
608
|
+
openModal({ title: `Export — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
609
|
+
try {
|
|
610
|
+
const r = await fetch('/sessions/' + encodeURIComponent(id) + '/export?format=md');
|
|
611
|
+
const text = await r.text();
|
|
612
|
+
document.getElementById('modal-body').innerHTML = `<pre>${escHtml(text)}</pre>`;
|
|
613
|
+
document.getElementById('modal-foot').innerHTML = `
|
|
614
|
+
<button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy markdown',1200)">Copy markdown</button>
|
|
615
|
+
<button class="btn" onclick="closeModal()">Close</button>`;
|
|
616
|
+
} catch (e) {
|
|
617
|
+
document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function deleteSession(id) {
|
|
622
|
+
if (!confirm(`Delete session "${id}"?\nTurn log will be permanently removed.`)) return;
|
|
623
|
+
try {
|
|
624
|
+
await fetch('/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
625
|
+
LOADERS.sessions();
|
|
626
|
+
} catch (e) {
|
|
627
|
+
alert('Delete failed: ' + e.message);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
474
631
|
LOADERS.skills = async function loadSkills() {
|
|
475
632
|
const root = document.getElementById('skills-list');
|
|
476
633
|
root.innerHTML = '<div class="empty">Loading…</div>';
|
|
@@ -484,25 +641,60 @@
|
|
|
484
641
|
root.innerHTML = '';
|
|
485
642
|
arr.forEach((s) => {
|
|
486
643
|
const div = document.createElement('div');
|
|
487
|
-
div.className = 'card';
|
|
644
|
+
div.className = 'card clickable';
|
|
488
645
|
div.innerHTML = `<div class="row" style="border:0;padding:0;">
|
|
489
|
-
<div class="name">${s.name}</div>
|
|
490
|
-
<div class="dim
|
|
646
|
+
<div class="name">${escHtml(s.name)}</div>
|
|
647
|
+
<div class="dim row-actions">${(s.bytes ?? '')} bytes</div>
|
|
648
|
+
<button class="btn btn-secondary btn-sm" data-action="view">View</button>
|
|
649
|
+
<button class="btn btn-danger btn-sm" data-action="delete">Delete</button>
|
|
491
650
|
</div>
|
|
492
|
-
<div class="dim" style="margin-top:6px;">${s.summary || ''}</div>`;
|
|
651
|
+
<div class="dim" style="margin-top:6px;">${escHtml(s.summary || '')}</div>`;
|
|
652
|
+
div.addEventListener('click', (e) => {
|
|
653
|
+
const action = e.target.closest('button')?.dataset.action;
|
|
654
|
+
if (action === 'delete') return deleteSkill(s.name);
|
|
655
|
+
return openSkillDetail(s.name);
|
|
656
|
+
});
|
|
493
657
|
root.appendChild(div);
|
|
494
658
|
});
|
|
495
659
|
} catch (e) {
|
|
496
|
-
root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
|
|
660
|
+
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
497
661
|
}
|
|
498
662
|
};
|
|
499
663
|
|
|
664
|
+
async function openSkillDetail(name) {
|
|
665
|
+
openModal({ title: `Skill — ${name}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
666
|
+
try {
|
|
667
|
+
// GET /skills/<name> returns the markdown body as text/markdown.
|
|
668
|
+
const r = await fetch('/skills/' + encodeURIComponent(name));
|
|
669
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
670
|
+
const text = await r.text();
|
|
671
|
+
document.getElementById('modal-body').innerHTML = `<pre class="markdown">${escHtml(text)}</pre>`;
|
|
672
|
+
document.getElementById('modal-foot').innerHTML = `
|
|
673
|
+
<button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy',1200)">Copy</button>
|
|
674
|
+
<button class="btn" onclick="closeModal()">Close</button>`;
|
|
675
|
+
} catch (e) {
|
|
676
|
+
document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function deleteSkill(name) {
|
|
681
|
+
if (!confirm(`Remove skill "${name}"?`)) return;
|
|
682
|
+
try {
|
|
683
|
+
await fetch('/skills/' + encodeURIComponent(name), { method: 'DELETE' });
|
|
684
|
+
LOADERS.skills();
|
|
685
|
+
} catch (e) {
|
|
686
|
+
alert('Delete failed: ' + e.message);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
500
690
|
LOADERS.providers = async function loadProviders() {
|
|
501
691
|
const root = document.getElementById('providers-list');
|
|
692
|
+
const meta = document.getElementById('providers-meta');
|
|
502
693
|
root.innerHTML = '<div class="empty">Loading…</div>';
|
|
503
694
|
try {
|
|
504
695
|
const r = await api('/providers');
|
|
505
696
|
const arr = r.providers || r;
|
|
697
|
+
meta.textContent = `${arr.length} registered`;
|
|
506
698
|
root.innerHTML = '';
|
|
507
699
|
arr.forEach((p) => {
|
|
508
700
|
const div = document.createElement('div');
|
|
@@ -510,20 +702,132 @@
|
|
|
510
702
|
const tag = p.requiresApiKey
|
|
511
703
|
? '<span class="pill warn">api key</span>'
|
|
512
704
|
: '<span class="pill ok">no key</span>';
|
|
705
|
+
const customTag = p.custom ? ' <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">custom</span>' : '';
|
|
706
|
+
const builtinCompat = p.builtinOpenAICompat ? ' <span class="pill" style="background:rgba(74,222,128,0.12);color:var(--ok);">openai-compat</span>' : '';
|
|
513
707
|
const models = (p.suggestedModels || []).slice(0, 6).join(' · ') || '<span class="dim">(default)</span>';
|
|
708
|
+
const removeBtn = p.custom
|
|
709
|
+
? `<button class="btn btn-danger btn-sm" data-action="remove">Remove</button>`
|
|
710
|
+
: '';
|
|
514
711
|
div.innerHTML = `<div class="row" style="border:0;padding:0;">
|
|
515
|
-
<div class="name">${p.name}</div>${tag}
|
|
516
|
-
<div class="dim
|
|
712
|
+
<div class="name">${escHtml(p.name)}</div>${tag}${customTag}${builtinCompat}
|
|
713
|
+
<div class="dim row-actions">${escHtml(p.endpoint || '')}</div>
|
|
714
|
+
<button class="btn btn-secondary btn-sm" data-action="test">Test</button>
|
|
715
|
+
${removeBtn}
|
|
517
716
|
</div>
|
|
518
|
-
<div class="dim" style="margin-top:6px;">${p.docs || ''}</div>
|
|
519
|
-
<div style="margin-top:8px;font-size:12px;">${models}</div
|
|
717
|
+
<div class="dim" style="margin-top:6px;">${escHtml(p.docs || '')}</div>
|
|
718
|
+
<div style="margin-top:8px;font-size:12px;">${models}</div>
|
|
719
|
+
<div class="dim" data-test-result style="margin-top:6px;font-size:11px;"></div>`;
|
|
720
|
+
div.addEventListener('click', async (e) => {
|
|
721
|
+
const btn = e.target.closest('button');
|
|
722
|
+
if (!btn) return;
|
|
723
|
+
if (btn.dataset.action === 'test') return testProvider(p.name, div);
|
|
724
|
+
if (btn.dataset.action === 'remove') return removeProvider(p.name);
|
|
725
|
+
});
|
|
520
726
|
root.appendChild(div);
|
|
521
727
|
});
|
|
522
728
|
} catch (e) {
|
|
523
|
-
root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
|
|
729
|
+
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
524
730
|
}
|
|
525
731
|
};
|
|
526
732
|
|
|
733
|
+
async function testProvider(name, cardEl) {
|
|
734
|
+
const out = cardEl.querySelector('[data-test-result]');
|
|
735
|
+
out.textContent = '⏳ probing…';
|
|
736
|
+
out.style.color = 'var(--dim)';
|
|
737
|
+
try {
|
|
738
|
+
const r = await fetch('/providers/' + encodeURIComponent(name) + '/test');
|
|
739
|
+
const body = await r.json();
|
|
740
|
+
if (body.ok) {
|
|
741
|
+
out.style.color = 'var(--ok)';
|
|
742
|
+
const reply = (body.reply || '').replace(/\s+/g, ' ').slice(0, 120);
|
|
743
|
+
out.textContent = `✓ ok · ${body.model} · ${body.durationMs}ms${reply ? ' · ' + reply : ''}`;
|
|
744
|
+
} else {
|
|
745
|
+
out.style.color = 'var(--err)';
|
|
746
|
+
out.textContent = `✗ ${body.error || 'failed'} · ${body.code || r.status}`;
|
|
747
|
+
}
|
|
748
|
+
} catch (e) {
|
|
749
|
+
out.style.color = 'var(--err)';
|
|
750
|
+
out.textContent = '✗ ' + (e.message || String(e));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function removeProvider(name) {
|
|
755
|
+
if (!confirm(`Remove custom provider "${name}"?`)) return;
|
|
756
|
+
try {
|
|
757
|
+
const r = await fetch('/providers/' + encodeURIComponent(name), { method: 'DELETE' });
|
|
758
|
+
const body = await r.json();
|
|
759
|
+
if (!r.ok) throw new Error(body.error || `${r.status}`);
|
|
760
|
+
LOADERS.providers();
|
|
761
|
+
} catch (e) {
|
|
762
|
+
alert('Remove failed: ' + e.message);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function openAddProviderModal() {
|
|
767
|
+
openModal({
|
|
768
|
+
title: 'Add custom OpenAI-compat provider',
|
|
769
|
+
bodyHtml: `
|
|
770
|
+
<div class="dim" style="margin-bottom:14px;font-size:12px;">
|
|
771
|
+
Works with any service that speaks the OpenAI v1 wire format
|
|
772
|
+
(vLLM · LM Studio · private gateways · self-hosted DeepInfra).
|
|
773
|
+
Built-in aliases (<code>nim</code>, <code>openrouter</code>, <code>groq</code>, …)
|
|
774
|
+
can be overridden by registering a custom entry of the same name.
|
|
775
|
+
</div>
|
|
776
|
+
<div class="form-row">
|
|
777
|
+
<label for="add-prov-name">Name (short id, e.g. "nim", "openrouter")</label>
|
|
778
|
+
<input id="add-prov-name" autofocus placeholder="e.g. my-vllm" />
|
|
779
|
+
</div>
|
|
780
|
+
<div class="form-row">
|
|
781
|
+
<label for="add-prov-baseurl">Base URL (must end in /v1)</label>
|
|
782
|
+
<input id="add-prov-baseurl" placeholder="https://integrate.api.nvidia.com/v1" />
|
|
783
|
+
</div>
|
|
784
|
+
<div class="form-row">
|
|
785
|
+
<label for="add-prov-apikey">API key (blank for auth-less endpoints)</label>
|
|
786
|
+
<input id="add-prov-apikey" type="password" placeholder="nvapi-…" />
|
|
787
|
+
</div>
|
|
788
|
+
<div class="form-row">
|
|
789
|
+
<label for="add-prov-model">Default model id (optional)</label>
|
|
790
|
+
<input id="add-prov-model" placeholder="meta/llama-3.1-405b-instruct" />
|
|
791
|
+
</div>
|
|
792
|
+
<div id="add-prov-status" class="dim" style="font-size:12px;"></div>
|
|
793
|
+
`,
|
|
794
|
+
footHtml: `
|
|
795
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
796
|
+
<button class="btn" onclick="submitAddProvider()">Save</button>
|
|
797
|
+
`,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function submitAddProvider() {
|
|
802
|
+
const name = document.getElementById('add-prov-name').value.trim();
|
|
803
|
+
const baseUrl = document.getElementById('add-prov-baseurl').value.trim();
|
|
804
|
+
const apiKey = document.getElementById('add-prov-apikey').value.trim();
|
|
805
|
+
const defaultModel = document.getElementById('add-prov-model').value.trim();
|
|
806
|
+
const status = document.getElementById('add-prov-status');
|
|
807
|
+
status.style.color = 'var(--dim)';
|
|
808
|
+
status.textContent = 'Saving…';
|
|
809
|
+
try {
|
|
810
|
+
const r = await fetch('/providers', {
|
|
811
|
+
method: 'POST',
|
|
812
|
+
headers: { 'content-type': 'application/json' },
|
|
813
|
+
body: JSON.stringify({ name, baseUrl, apiKey: apiKey || undefined, defaultModel: defaultModel || undefined }),
|
|
814
|
+
});
|
|
815
|
+
const body = await r.json();
|
|
816
|
+
if (!r.ok) {
|
|
817
|
+
status.style.color = 'var(--err)';
|
|
818
|
+
status.textContent = '✗ ' + (body.error || `${r.status} ${r.statusText}`);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
status.style.color = 'var(--ok)';
|
|
822
|
+
const overrideNote = body.overridesBuiltin ? ' (overrides built-in)' : '';
|
|
823
|
+
status.textContent = `✓ saved — ${body.name} → ${body.baseUrl}${overrideNote}`;
|
|
824
|
+
setTimeout(() => { closeModal(); LOADERS.providers(); }, 700);
|
|
825
|
+
} catch (e) {
|
|
826
|
+
status.style.color = 'var(--err)';
|
|
827
|
+
status.textContent = '✗ ' + (e.message || String(e));
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
527
831
|
LOADERS.status = async function loadStatus() {
|
|
528
832
|
const root = document.getElementById('status-card');
|
|
529
833
|
root.innerHTML = '<div class="empty">Loading…</div>';
|
|
@@ -584,23 +888,79 @@
|
|
|
584
888
|
if (sm.resumable) tags.push('<span class="pill warn">resumable</span>');
|
|
585
889
|
if (sm.done) tags.push('<span class="pill ok">done</span>');
|
|
586
890
|
const total = sm.total ?? '';
|
|
587
|
-
return `<tr>
|
|
891
|
+
return `<tr class="clickable" data-wf-id="${escHtml(s.sessionId)}">
|
|
588
892
|
<td><code>${escHtml(s.sessionId)}</code></td>
|
|
589
893
|
<td>${tags.join(' ') || '<span class="dim">—</span>'}</td>
|
|
590
894
|
<td class="num">${sm.done ?? 0} / ${total}</td>
|
|
591
895
|
<td class="num">${sm.failed ?? 0}</td>
|
|
592
896
|
<td class="dim">${escHtml(s.updatedAt || s.startedAt || '')}</td>
|
|
897
|
+
<td><button class="btn btn-danger btn-sm" data-action="wf-delete">Delete</button></td>
|
|
593
898
|
</tr>`;
|
|
594
899
|
}).join('');
|
|
595
900
|
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>
|
|
901
|
+
<thead><tr><th>Session</th><th>State</th><th>Done / Total</th><th>Failed</th><th>Updated</th><th></th></tr></thead>
|
|
597
902
|
<tbody>${rows}</tbody>
|
|
598
903
|
</table>`;
|
|
904
|
+
list.querySelectorAll('tr[data-wf-id]').forEach((tr) => {
|
|
905
|
+
tr.addEventListener('click', (e) => {
|
|
906
|
+
const id = tr.getAttribute('data-wf-id');
|
|
907
|
+
const action = e.target.closest('button')?.dataset.action;
|
|
908
|
+
if (action === 'wf-delete') return deleteWorkflow(id);
|
|
909
|
+
return openWorkflowDetail(id);
|
|
910
|
+
});
|
|
911
|
+
});
|
|
599
912
|
} catch (e) {
|
|
600
913
|
list.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
601
914
|
}
|
|
602
915
|
};
|
|
603
916
|
|
|
917
|
+
async function openWorkflowDetail(id) {
|
|
918
|
+
openModal({ title: `Workflow — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
|
|
919
|
+
try {
|
|
920
|
+
const r = await api('/workflows/' + encodeURIComponent(id));
|
|
921
|
+
const sm = r.summary || {};
|
|
922
|
+
const nodes = r.state?.nodeResults || r.nodeResults || {};
|
|
923
|
+
const nodeRows = Object.entries(nodes).map(([nid, n]) => {
|
|
924
|
+
const status = (n.status || '').toLowerCase();
|
|
925
|
+
const pillClass = status === 'failed' ? 'err' : (status === 'done' ? 'ok' : (status === 'running' ? 'warn' : ''));
|
|
926
|
+
const dur = n.durationMs != null ? fmtDuration(n.durationMs) : '—';
|
|
927
|
+
const out = String(n.output ?? n.error ?? '');
|
|
928
|
+
const truncated = out.length > 240 ? out.slice(0, 240) + '…' : out;
|
|
929
|
+
return `<tr>
|
|
930
|
+
<td><code>${escHtml(nid)}</code></td>
|
|
931
|
+
<td>${pillClass ? `<span class="pill ${pillClass}">${escHtml(status)}</span>` : escHtml(status || '—')}</td>
|
|
932
|
+
<td class="num">${dur}</td>
|
|
933
|
+
<td class="dim">${escHtml(truncated)}</td>
|
|
934
|
+
</tr>`;
|
|
935
|
+
}).join('');
|
|
936
|
+
const summaryHtml = `<div class="grid" style="margin-bottom:14px;">
|
|
937
|
+
<div class="stat"><div class="label">Total</div><div class="value">${sm.total ?? '—'}</div></div>
|
|
938
|
+
<div class="stat"><div class="label">Done</div><div class="value">${sm.done ?? 0}</div></div>
|
|
939
|
+
<div class="stat"><div class="label">Failed</div><div class="value" style="color:${sm.failed ? 'var(--err)' : 'inherit'}">${sm.failed ?? 0}</div></div>
|
|
940
|
+
<div class="stat"><div class="label">Running</div><div class="value">${sm.running ?? 0}</div></div>
|
|
941
|
+
</div>`;
|
|
942
|
+
const tableHtml = nodeRows
|
|
943
|
+
? `<table class="tbl">
|
|
944
|
+
<thead><tr><th>Node</th><th>Status</th><th>Duration</th><th>Output / Error</th></tr></thead>
|
|
945
|
+
<tbody>${nodeRows}</tbody>
|
|
946
|
+
</table>`
|
|
947
|
+
: '<div class="empty">No node results yet.</div>';
|
|
948
|
+
document.getElementById('modal-body').innerHTML = summaryHtml + tableHtml;
|
|
949
|
+
} catch (e) {
|
|
950
|
+
document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async function deleteWorkflow(id) {
|
|
955
|
+
if (!confirm(`Delete workflow session "${id}"?\nState file will be permanently removed.`)) return;
|
|
956
|
+
try {
|
|
957
|
+
await fetch('/workflows/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
958
|
+
LOADERS.workflows();
|
|
959
|
+
} catch (e) {
|
|
960
|
+
alert('Delete failed: ' + e.message);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
604
964
|
// ── Rates ────────────────────────────────────────────────────
|
|
605
965
|
document.getElementById('rates-filter').addEventListener('input', debounce(() => LOADERS.rates(), 250));
|
|
606
966
|
|
|
@@ -638,24 +998,115 @@
|
|
|
638
998
|
}
|
|
639
999
|
const rows = entries.map(([key, card]) => {
|
|
640
1000
|
const c = card || {};
|
|
641
|
-
return `<tr>
|
|
1001
|
+
return `<tr data-rate-key="${escHtml(key)}">
|
|
642
1002
|
<td><code>${escHtml(key)}</code></td>
|
|
643
1003
|
<td class="num">${c.in ?? '—'}</td>
|
|
644
1004
|
<td class="num">${c.out ?? '—'}</td>
|
|
645
1005
|
<td class="num">${c['cache-read'] ?? '—'}</td>
|
|
646
1006
|
<td class="num">${c['cache-create'] ?? '—'}</td>
|
|
647
1007
|
<td class="dim">${escHtml(c.currency || 'USD')} / 1M tok</td>
|
|
1008
|
+
<td>
|
|
1009
|
+
<button class="btn btn-secondary btn-sm" data-action="rate-edit">Edit</button>
|
|
1010
|
+
<button class="btn btn-danger btn-sm" data-action="rate-delete">Delete</button>
|
|
1011
|
+
</td>
|
|
648
1012
|
</tr>`;
|
|
649
1013
|
}).join('');
|
|
650
1014
|
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>
|
|
1015
|
+
<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
1016
|
<tbody>${rows}</tbody>
|
|
653
1017
|
</table>`;
|
|
1018
|
+
root.querySelectorAll('tr[data-rate-key]').forEach((tr) => {
|
|
1019
|
+
const key = tr.getAttribute('data-rate-key');
|
|
1020
|
+
const card = (rates || {})[key] || {};
|
|
1021
|
+
tr.querySelector('[data-action="rate-edit"]')?.addEventListener('click', () => openRateCardModal(key, card));
|
|
1022
|
+
tr.querySelector('[data-action="rate-delete"]')?.addEventListener('click', () => deleteRateCard(key));
|
|
1023
|
+
});
|
|
654
1024
|
} catch (e) {
|
|
655
1025
|
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
656
1026
|
}
|
|
657
1027
|
};
|
|
658
1028
|
|
|
1029
|
+
function openRateCardModal(existingKey = '', existingCard = {}) {
|
|
1030
|
+
const c = existingCard || {};
|
|
1031
|
+
openModal({
|
|
1032
|
+
title: existingKey ? `Edit rate card — ${existingKey}` : 'Add rate card',
|
|
1033
|
+
bodyHtml: `
|
|
1034
|
+
<div class="dim" style="margin-bottom:12px;font-size:12px;">
|
|
1035
|
+
Cost per 1M tokens (input / output / optional cache pricing).
|
|
1036
|
+
Same shape as <code>lazyclaw rates set</code>. Saving the same
|
|
1037
|
+
key overwrites the existing card.
|
|
1038
|
+
</div>
|
|
1039
|
+
<div class="form-row">
|
|
1040
|
+
<label for="rate-key">Provider / model key</label>
|
|
1041
|
+
<input id="rate-key" placeholder="anthropic/claude-opus-4-7" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
|
|
1042
|
+
</div>
|
|
1043
|
+
<div class="grid" style="grid-template-columns:1fr 1fr;gap:10px;margin-bottom:0;">
|
|
1044
|
+
<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>
|
|
1045
|
+
<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>
|
|
1046
|
+
<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>
|
|
1047
|
+
<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>
|
|
1048
|
+
<div class="form-row"><label for="rate-currency">Currency</label><input id="rate-currency" value="${escHtml(c.currency || 'USD')}"/></div>
|
|
1049
|
+
</div>
|
|
1050
|
+
<div id="rate-status" class="dim" style="font-size:12px;margin-top:8px;"></div>
|
|
1051
|
+
`,
|
|
1052
|
+
footHtml: `
|
|
1053
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
1054
|
+
<button class="btn" onclick="submitRateCard()">Save</button>
|
|
1055
|
+
`,
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function submitRateCard() {
|
|
1060
|
+
const key = document.getElementById('rate-key').value.trim();
|
|
1061
|
+
const status = document.getElementById('rate-status');
|
|
1062
|
+
if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
|
|
1063
|
+
const card = {
|
|
1064
|
+
in: parseFloat(document.getElementById('rate-in').value) || 0,
|
|
1065
|
+
out: parseFloat(document.getElementById('rate-out').value) || 0,
|
|
1066
|
+
currency: document.getElementById('rate-currency').value.trim() || 'USD',
|
|
1067
|
+
};
|
|
1068
|
+
const cr = parseFloat(document.getElementById('rate-cache-read').value);
|
|
1069
|
+
const cc = parseFloat(document.getElementById('rate-cache-create').value);
|
|
1070
|
+
if (Number.isFinite(cr)) card['cache-read'] = cr;
|
|
1071
|
+
if (Number.isFinite(cc)) card['cache-create'] = cc;
|
|
1072
|
+
status.style.color = 'var(--dim)';
|
|
1073
|
+
status.textContent = 'Saving…';
|
|
1074
|
+
try {
|
|
1075
|
+
const r = await fetch('/rates/' + encodeURIComponent(key), {
|
|
1076
|
+
method: 'PUT',
|
|
1077
|
+
headers: { 'content-type': 'application/json' },
|
|
1078
|
+
body: JSON.stringify(card),
|
|
1079
|
+
});
|
|
1080
|
+
const body = await r.json();
|
|
1081
|
+
if (!r.ok) {
|
|
1082
|
+
status.style.color = 'var(--err)';
|
|
1083
|
+
const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
|
|
1084
|
+
status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
status.style.color = 'var(--ok)';
|
|
1088
|
+
status.textContent = `✓ saved`;
|
|
1089
|
+
setTimeout(() => { closeModal(); LOADERS.rates(); }, 600);
|
|
1090
|
+
} catch (e) {
|
|
1091
|
+
status.style.color = 'var(--err)';
|
|
1092
|
+
status.textContent = '✗ ' + (e.message || String(e));
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async function deleteRateCard(key) {
|
|
1097
|
+
if (!confirm(`Delete rate card "${key}"?`)) return;
|
|
1098
|
+
try {
|
|
1099
|
+
const r = await fetch('/rates/' + encodeURIComponent(key), { method: 'DELETE' });
|
|
1100
|
+
if (!r.ok) {
|
|
1101
|
+
const body = await r.json().catch(() => ({}));
|
|
1102
|
+
throw new Error(body.error || `${r.status}`);
|
|
1103
|
+
}
|
|
1104
|
+
LOADERS.rates();
|
|
1105
|
+
} catch (e) {
|
|
1106
|
+
alert('Delete failed: ' + e.message);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
659
1110
|
// ── Metrics ──────────────────────────────────────────────────
|
|
660
1111
|
LOADERS.metrics = async function loadMetrics() {
|
|
661
1112
|
const cards = document.getElementById('metrics-cards');
|
|
@@ -748,21 +1199,121 @@
|
|
|
748
1199
|
root.innerHTML = '<div class="empty">No config yet. Run <code>lazyclaw onboard</code>.</div>';
|
|
749
1200
|
return;
|
|
750
1201
|
}
|
|
1202
|
+
const NESTED = new Set(['customProviders', 'rates', 'authProfiles', 'authActiveProfile']);
|
|
751
1203
|
const rows = keys.sort().map((k) => {
|
|
752
1204
|
const v = cfg[k];
|
|
753
1205
|
const display = v && typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
754
|
-
|
|
1206
|
+
const nested = NESTED.has(k);
|
|
1207
|
+
const editBtn = nested
|
|
1208
|
+
? `<span class="dim" style="font-size:11px;">use the dedicated tab</span>`
|
|
1209
|
+
: `<button class="btn btn-secondary btn-sm" data-action="cfg-edit" data-key="${escHtml(k)}">Edit</button>
|
|
1210
|
+
<button class="btn btn-danger btn-sm" data-action="cfg-delete" data-key="${escHtml(k)}">Delete</button>`;
|
|
1211
|
+
return `<tr><td><code>${escHtml(k)}</code></td><td>${escHtml(display)}</td><td>${editBtn}</td></tr>`;
|
|
755
1212
|
}).join('');
|
|
756
1213
|
root.innerHTML = `<table class="tbl">
|
|
757
|
-
<thead><tr><th style="width:
|
|
1214
|
+
<thead><tr><th style="width:25%">Key</th><th>Value</th><th style="width:160px"></th></tr></thead>
|
|
758
1215
|
<tbody>${rows}</tbody>
|
|
759
1216
|
</table>`;
|
|
1217
|
+
root.querySelectorAll('[data-action="cfg-edit"]').forEach((b) => {
|
|
1218
|
+
b.addEventListener('click', () => openConfigEditModal(b.dataset.key, cfg[b.dataset.key]));
|
|
1219
|
+
});
|
|
1220
|
+
root.querySelectorAll('[data-action="cfg-delete"]').forEach((b) => {
|
|
1221
|
+
b.addEventListener('click', () => deleteConfigKey(b.dataset.key));
|
|
1222
|
+
});
|
|
760
1223
|
raw.textContent = JSON.stringify(cfg, null, 2);
|
|
761
1224
|
} catch (e) {
|
|
762
1225
|
root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
|
|
763
1226
|
}
|
|
764
1227
|
};
|
|
765
1228
|
|
|
1229
|
+
function openConfigEditModal(existingKey = '', existingValue = '') {
|
|
1230
|
+
// Stringify for the editor; objects/arrays become JSON, primitives stay
|
|
1231
|
+
// raw. Submitter parses JSON when the value looks like JSON, else
|
|
1232
|
+
// sends a string verbatim — same behaviour as `lazyclaw config set`.
|
|
1233
|
+
let display = '';
|
|
1234
|
+
if (typeof existingValue === 'string') display = existingValue;
|
|
1235
|
+
else if (existingValue == null) display = '';
|
|
1236
|
+
else display = JSON.stringify(existingValue, null, 2);
|
|
1237
|
+
openModal({
|
|
1238
|
+
title: existingKey ? `Edit config — ${existingKey}` : 'Set config key',
|
|
1239
|
+
bodyHtml: `
|
|
1240
|
+
<div class="dim" style="margin-bottom:12px;font-size:12px;">
|
|
1241
|
+
Mirrors <code>lazyclaw config set <key> <value></code>. Values that look like
|
|
1242
|
+
JSON (start with <code>{</code> / <code>[</code> / <code>"</code> / <code>true</code> / <code>false</code> / a number)
|
|
1243
|
+
are parsed; everything else is stored as a plain string. Nested
|
|
1244
|
+
stores (<code>customProviders</code>, <code>rates</code>, <code>authProfiles</code>) have their own
|
|
1245
|
+
tabs — this form rejects them.
|
|
1246
|
+
</div>
|
|
1247
|
+
<div class="form-row">
|
|
1248
|
+
<label for="cfg-key">Key</label>
|
|
1249
|
+
<input id="cfg-key" placeholder="provider · model · api-key · skills · …" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
|
|
1250
|
+
</div>
|
|
1251
|
+
<div class="form-row">
|
|
1252
|
+
<label for="cfg-value">Value</label>
|
|
1253
|
+
<textarea id="cfg-value" rows="6">${escHtml(display)}</textarea>
|
|
1254
|
+
</div>
|
|
1255
|
+
<div id="cfg-status" class="dim" style="font-size:12px;"></div>
|
|
1256
|
+
`,
|
|
1257
|
+
footHtml: `
|
|
1258
|
+
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
1259
|
+
<button class="btn" onclick="submitConfigEdit()">Save</button>
|
|
1260
|
+
`,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
async function submitConfigEdit() {
|
|
1265
|
+
const key = document.getElementById('cfg-key').value.trim();
|
|
1266
|
+
const raw = document.getElementById('cfg-value').value;
|
|
1267
|
+
const status = document.getElementById('cfg-status');
|
|
1268
|
+
if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
|
|
1269
|
+
// Heuristic JSON parse — same surface as the CLI: try parse; if it
|
|
1270
|
+
// throws, send the raw string. Numbers / true / false / null / objects /
|
|
1271
|
+
// arrays / quoted strings end up correctly typed.
|
|
1272
|
+
let value;
|
|
1273
|
+
const trimmed = raw.trim();
|
|
1274
|
+
if (trimmed === '') value = '';
|
|
1275
|
+
else {
|
|
1276
|
+
try { value = JSON.parse(trimmed); }
|
|
1277
|
+
catch { value = raw; }
|
|
1278
|
+
}
|
|
1279
|
+
status.style.color = 'var(--dim)';
|
|
1280
|
+
status.textContent = 'Saving…';
|
|
1281
|
+
try {
|
|
1282
|
+
const r = await fetch('/config/' + encodeURIComponent(key), {
|
|
1283
|
+
method: 'PUT',
|
|
1284
|
+
headers: { 'content-type': 'application/json' },
|
|
1285
|
+
body: JSON.stringify({ value }),
|
|
1286
|
+
});
|
|
1287
|
+
const body = await r.json();
|
|
1288
|
+
if (!r.ok) {
|
|
1289
|
+
status.style.color = 'var(--err)';
|
|
1290
|
+
const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
|
|
1291
|
+
status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
status.style.color = 'var(--ok)';
|
|
1295
|
+
status.textContent = '✓ saved';
|
|
1296
|
+
setTimeout(() => { closeModal(); LOADERS.config(); }, 600);
|
|
1297
|
+
} catch (e) {
|
|
1298
|
+
status.style.color = 'var(--err)';
|
|
1299
|
+
status.textContent = '✗ ' + (e.message || String(e));
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
async function deleteConfigKey(key) {
|
|
1304
|
+
if (!confirm(`Delete config key "${key}"?`)) return;
|
|
1305
|
+
try {
|
|
1306
|
+
const r = await fetch('/config/' + encodeURIComponent(key), { method: 'DELETE' });
|
|
1307
|
+
if (!r.ok) {
|
|
1308
|
+
const body = await r.json().catch(() => ({}));
|
|
1309
|
+
throw new Error(body.error || `${r.status}`);
|
|
1310
|
+
}
|
|
1311
|
+
LOADERS.config();
|
|
1312
|
+
} catch (e) {
|
|
1313
|
+
alert('Delete failed: ' + e.message);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
766
1317
|
// First load = chat tab.
|
|
767
1318
|
LOADERS.chat();
|
|
768
1319
|
|