vibespot 1.5.1 → 1.6.0
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/README.md +14 -0
- package/assets/prompts.bundle.json +53 -0
- package/dist/index.js +419 -419
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
- package/ui/chat.js +106 -0
- package/ui/index.html +1 -0
- package/ui/settings.js +262 -6
- package/ui/styles.css +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibespot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "AI-powered HubSpot CMS landing page builder — vibe coding & React converter",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"build": "tsup",
|
|
20
20
|
"test": "vitest run",
|
|
21
21
|
"test:watch": "vitest",
|
|
22
|
+
"eval": "tsx test/eval/run-eval.ts",
|
|
23
|
+
"benchmark": "tsx test/eval/benchmark.ts",
|
|
24
|
+
"prompts:pull": "tsx scripts/sync-prompts.ts",
|
|
25
|
+
"prompts:seed": "tsx scripts/sync-prompts.ts --from-local",
|
|
22
26
|
"prepublishOnly": "npm run build",
|
|
23
27
|
"contact-monitor": "tsx scripts/contact-monitor.ts",
|
|
24
28
|
"docker:publish": "scripts/docker-publish.sh",
|
package/ui/chat.js
CHANGED
|
@@ -29,6 +29,10 @@ let changedModulesInRun = new Set();
|
|
|
29
29
|
let highlightOnNextModulesUpdated = false;
|
|
30
30
|
let changedListClearTimer = null;
|
|
31
31
|
|
|
32
|
+
// VIB-1770 — the assistant bubble of the just-finished generation, so the
|
|
33
|
+
// trailing `generation_cost` event can append its estimated cost line to it.
|
|
34
|
+
let lastGenerationBubbleEl = null;
|
|
35
|
+
|
|
32
36
|
const messagesEl = document.getElementById("chat-messages");
|
|
33
37
|
const inputEl = document.getElementById("chat-input");
|
|
34
38
|
const sendBtn = document.getElementById("chat-send");
|
|
@@ -605,6 +609,9 @@ function handleWsMessage(msg) {
|
|
|
605
609
|
if (chatHeaderTitle) chatHeaderTitle.textContent = msg.themeName || "Chat";
|
|
606
610
|
if (chatHeaderContext) chatHeaderContext.textContent = msg.engine || "";
|
|
607
611
|
|
|
612
|
+
// Hydrate the per-project generation cost chip (VIB-1770)
|
|
613
|
+
updateProjectCostChip(msg.costTotal);
|
|
614
|
+
|
|
608
615
|
// Restore chat history from server
|
|
609
616
|
if (msg.messages && msg.messages.length > 0) {
|
|
610
617
|
for (const m of msg.messages) {
|
|
@@ -744,6 +751,9 @@ function handleWsMessage(msg) {
|
|
|
744
751
|
case "pipeline_partial":
|
|
745
752
|
handlePipelinePartial(msg);
|
|
746
753
|
break;
|
|
754
|
+
case "generation_cost":
|
|
755
|
+
handleGenerationCost(msg);
|
|
756
|
+
break;
|
|
747
757
|
case "agentic_prompt":
|
|
748
758
|
handleAgenticPrompt();
|
|
749
759
|
break;
|
|
@@ -1270,6 +1280,10 @@ function handlePipelineComplete(msg) {
|
|
|
1270
1280
|
}
|
|
1271
1281
|
bubble.appendChild(stats);
|
|
1272
1282
|
|
|
1283
|
+
// Remember this bubble so the trailing `generation_cost` event (sent right
|
|
1284
|
+
// after completion) can append its cost line to the same message (VIB-1770).
|
|
1285
|
+
lastGenerationBubbleEl = bubble;
|
|
1286
|
+
|
|
1273
1287
|
resetPipelineState();
|
|
1274
1288
|
}
|
|
1275
1289
|
|
|
@@ -1298,6 +1312,8 @@ function handlePipelinePartial(msg) {
|
|
|
1298
1312
|
stats.textContent = `${msg.succeeded.length} modules succeeded, ${msg.failed.length} failed in ${duration}`;
|
|
1299
1313
|
bubble.appendChild(stats);
|
|
1300
1314
|
|
|
1315
|
+
lastGenerationBubbleEl = bubble;
|
|
1316
|
+
|
|
1301
1317
|
resetPipelineState();
|
|
1302
1318
|
}
|
|
1303
1319
|
|
|
@@ -1312,6 +1328,89 @@ function resetPipelineState() {
|
|
|
1312
1328
|
renderChatSuggestions();
|
|
1313
1329
|
}
|
|
1314
1330
|
|
|
1331
|
+
// ---------------------------------------------------------------------------
|
|
1332
|
+
// Generation cost (VIB-1770) — per-page estimate + per-project running total
|
|
1333
|
+
// ---------------------------------------------------------------------------
|
|
1334
|
+
|
|
1335
|
+
// Format an estimated USD amount. Sub-dollar costs show 4 decimals so a
|
|
1336
|
+
// fraction of a cent is still legible; larger amounts round to cents.
|
|
1337
|
+
function formatUsd(usd) {
|
|
1338
|
+
const n = Number(usd) || 0;
|
|
1339
|
+
if (n > 0 && n < 0.01) return "$" + n.toFixed(4);
|
|
1340
|
+
if (n < 1) return "$" + n.toFixed(3);
|
|
1341
|
+
return "$" + n.toFixed(2);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function formatTokenCount(tokens) {
|
|
1345
|
+
const n = Number(tokens) || 0;
|
|
1346
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
|
|
1347
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
|
|
1348
|
+
return String(n);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Build the per-page cost summary text from a PageCost object.
|
|
1352
|
+
// `costComplete === false` means some model wasn't in the price table, so the
|
|
1353
|
+
// dollar figure is a lower bound — prefix "≥" and explain it via the title.
|
|
1354
|
+
function buildCostText(cost) {
|
|
1355
|
+
if (!cost || !cost.calls) return null;
|
|
1356
|
+
const tokens = formatTokenCount(cost.totalTokens) + " tokens";
|
|
1357
|
+
if (cost.costUsd > 0 || cost.costComplete) {
|
|
1358
|
+
const prefix = cost.costComplete ? "" : "≥ ";
|
|
1359
|
+
return "Est. " + prefix + formatUsd(cost.costUsd) + " · " + tokens;
|
|
1360
|
+
}
|
|
1361
|
+
// No priced calls at all — show tokens only.
|
|
1362
|
+
return tokens;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Append (or replace) the cost line on a finished generation's bubble.
|
|
1366
|
+
function renderCostLineOnBubble(bubble, cost) {
|
|
1367
|
+
if (!bubble) return;
|
|
1368
|
+
const text = buildCostText(cost);
|
|
1369
|
+
if (!text) return;
|
|
1370
|
+
let line = bubble.querySelector(".pipeline-cost");
|
|
1371
|
+
if (!line) {
|
|
1372
|
+
line = document.createElement("div");
|
|
1373
|
+
line.className = "pipeline-cost";
|
|
1374
|
+
bubble.appendChild(line);
|
|
1375
|
+
}
|
|
1376
|
+
line.textContent = text;
|
|
1377
|
+
if (cost.costComplete === false) {
|
|
1378
|
+
line.title = "Some model calls had no price data — this is a lower-bound estimate.";
|
|
1379
|
+
} else {
|
|
1380
|
+
line.title = "Estimated cost of this generation. Local estimate from public model prices; not a bill.";
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Live `generation_cost` event — arrives right after pipeline completion.
|
|
1385
|
+
function handleGenerationCost(msg) {
|
|
1386
|
+
if (msg.cost) renderCostLineOnBubble(lastGenerationBubbleEl, msg.cost);
|
|
1387
|
+
lastGenerationBubbleEl = null;
|
|
1388
|
+
updateProjectCostChip(msg.projectTotal);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Update the persistent per-project cost chip in the chat header. Hidden when
|
|
1392
|
+
// there's no cost yet (new project / CLI engine).
|
|
1393
|
+
function updateProjectCostChip(total) {
|
|
1394
|
+
const chip = document.getElementById("chat-cost-total");
|
|
1395
|
+
if (!chip) return;
|
|
1396
|
+
if (!total || !total.generations) {
|
|
1397
|
+
chip.classList.add("hidden");
|
|
1398
|
+
chip.textContent = "";
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
const prefix = total.costComplete === false ? "≥ " : "";
|
|
1402
|
+
chip.textContent = "Σ " + prefix + formatUsd(total.costUsd);
|
|
1403
|
+
chip.title =
|
|
1404
|
+
"Estimated total for this project: " +
|
|
1405
|
+
formatUsd(total.costUsd) +
|
|
1406
|
+
" over " +
|
|
1407
|
+
total.generations +
|
|
1408
|
+
" generation" + (total.generations === 1 ? "" : "s") +
|
|
1409
|
+
" · " + formatTokenCount(total.totalTokens) + " tokens" +
|
|
1410
|
+
(total.costComplete === false ? " (lower bound)" : "");
|
|
1411
|
+
chip.classList.remove("hidden");
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1315
1414
|
// ---------------------------------------------------------------------------
|
|
1316
1415
|
// Smart chat suggestions — contextual next-step chips after generation
|
|
1317
1416
|
// ---------------------------------------------------------------------------
|
|
@@ -2110,6 +2209,12 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
|
|
|
2110
2209
|
}
|
|
2111
2210
|
const statsClass = pipeline.stats.modulesFailed > 0 ? "pipeline-stats pipeline-stats--partial" : "pipeline-stats";
|
|
2112
2211
|
|
|
2212
|
+
// Per-page cost line, if this generation persisted one (VIB-1770)
|
|
2213
|
+
const costText = buildCostText(pipeline.cost);
|
|
2214
|
+
const costHtml = costText
|
|
2215
|
+
? `<div class="pipeline-cost">${escapeHtml(costText)}</div>`
|
|
2216
|
+
: "";
|
|
2217
|
+
|
|
2113
2218
|
div.innerHTML = `
|
|
2114
2219
|
<div class="chat-msg__avatar chat-msg__avatar--ai">AI</div>
|
|
2115
2220
|
<div class="chat-msg__content">
|
|
@@ -2119,6 +2224,7 @@ function appendRestoredAssistantMessage(text, timestamp, pipeline) {
|
|
|
2119
2224
|
${decisionHtml}
|
|
2120
2225
|
${modulesHtml}
|
|
2121
2226
|
<div class="${statsClass}">${statsText}</div>
|
|
2227
|
+
${costHtml}
|
|
2122
2228
|
</div>
|
|
2123
2229
|
</div>`;
|
|
2124
2230
|
} else {
|
package/ui/index.html
CHANGED
|
@@ -484,6 +484,7 @@
|
|
|
484
484
|
<div class="chat__header" id="chat-header">
|
|
485
485
|
<span class="chat__header-title" id="chat-header-title">Chat</span>
|
|
486
486
|
<span class="chat__header-context" id="chat-header-context"></span>
|
|
487
|
+
<span class="chat__cost-total hidden" id="chat-cost-total" title="Estimated total generation cost for this project"></span>
|
|
487
488
|
</div>
|
|
488
489
|
<div class="chat__messages" id="chat-messages" role="log" aria-live="polite">
|
|
489
490
|
<div class="chat__welcome" id="chat-welcome">
|
package/ui/settings.js
CHANGED
|
@@ -10,6 +10,98 @@ let settingsData = null;
|
|
|
10
10
|
let activeTab = "ai";
|
|
11
11
|
const activePolls = {};
|
|
12
12
|
|
|
13
|
+
// VIB-1835: /api/settings/status is now config-only and renders instantly. The
|
|
14
|
+
// expensive bits — live model lists and CLI/auth detection — are fetched on
|
|
15
|
+
// demand and cached here, layered over the fast /status response on each render.
|
|
16
|
+
let liveModels = null; // from /api/settings/models
|
|
17
|
+
let scannedTools = {}; // tool entries from /api/settings/tools
|
|
18
|
+
let scannedEngines = null; // availableEngines refined by an AI scan
|
|
19
|
+
const scannedGroups = { ai: false, platform: false };
|
|
20
|
+
let bgScanStarted = false; // one-time background scan guard (per open)
|
|
21
|
+
|
|
22
|
+
function resetScanState() {
|
|
23
|
+
liveModels = null;
|
|
24
|
+
scannedTools = {};
|
|
25
|
+
scannedEngines = null;
|
|
26
|
+
scannedGroups.ai = false;
|
|
27
|
+
scannedGroups.platform = false;
|
|
28
|
+
bgScanStarted = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Layer the on-demand caches over the fast /status payload so a re-render after
|
|
32
|
+
// any action keeps already-fetched models / scanned tool state.
|
|
33
|
+
function applyScanCaches(data) {
|
|
34
|
+
if (!data || !data.environment) return;
|
|
35
|
+
if (liveModels) data.models = liveModels;
|
|
36
|
+
if (Object.keys(scannedTools).length) {
|
|
37
|
+
data.environment.tools = { ...data.environment.tools, ...scannedTools };
|
|
38
|
+
}
|
|
39
|
+
if (scannedEngines) data.environment.availableEngines = scannedEngines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetchModels(refresh) {
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch("/api/settings/models" + (refresh ? "?refresh=1" : ""));
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
if (data && data.models) {
|
|
47
|
+
liveModels = data.models;
|
|
48
|
+
if (settingsData) settingsData.models = liveModels;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
} catch { /* keep STATIC_MODELS */ }
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function fetchTools(group, refresh) {
|
|
56
|
+
try {
|
|
57
|
+
const qs = `?group=${group}` + (refresh ? "&refresh=1" : "");
|
|
58
|
+
const res = await fetch("/api/settings/tools" + qs);
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
if (data && data.tools) {
|
|
61
|
+
scannedTools = { ...scannedTools, ...data.tools };
|
|
62
|
+
if (Array.isArray(data.availableEngines)) scannedEngines = data.availableEngines;
|
|
63
|
+
if (group === "ai" || group === "all") scannedGroups.ai = true;
|
|
64
|
+
if (group === "platform" || group === "all") scannedGroups.platform = true;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
} catch { /* leave "not scanned" */ }
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Hybrid default (Boris-approved): after the instant render, kick off one
|
|
72
|
+
// non-blocking scan of models + all tools, then re-render in place. Every later
|
|
73
|
+
// open repeats this once; explicit Refresh/Scan/Check buttons are always live.
|
|
74
|
+
function maybeStartBackgroundScan() {
|
|
75
|
+
if (bgScanStarted) return;
|
|
76
|
+
bgScanStarted = true;
|
|
77
|
+
Promise.all([fetchModels(false), fetchTools("all", false)]).then((results) => {
|
|
78
|
+
// Don't yank a re-render out from under someone mid-typing.
|
|
79
|
+
if (results.some(Boolean) && settingsData && !settingsHasFocusedInput()) {
|
|
80
|
+
renderSettings(settingsData);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function settingsHasFocusedInput() {
|
|
86
|
+
const body = document.getElementById("settings-body");
|
|
87
|
+
const ae = document.activeElement;
|
|
88
|
+
return !!(body && ae && body.contains(ae) && (ae.tagName === "INPUT" || ae.tagName === "SELECT"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Small button that runs an on-demand scan with a spinner, then re-renders the
|
|
92
|
+
// active tab (which recreates this button in its resolved state).
|
|
93
|
+
function makeScanButton(label, onScan, extraClass) {
|
|
94
|
+
const btn = el("button", "settings__btn settings__btn--small" + (extraClass ? " " + extraClass : ""));
|
|
95
|
+
btn.textContent = label;
|
|
96
|
+
btn.addEventListener("click", async () => {
|
|
97
|
+
btn.disabled = true;
|
|
98
|
+
btn.innerHTML = '<span class="settings__spinner"></span> ' + escSettings(label) + "…";
|
|
99
|
+
await onScan();
|
|
100
|
+
if (settingsData) renderSettings(settingsData);
|
|
101
|
+
});
|
|
102
|
+
return btn;
|
|
103
|
+
}
|
|
104
|
+
|
|
13
105
|
const ENGINE_LABELS = {
|
|
14
106
|
"claude-code": "Claude Code",
|
|
15
107
|
"anthropic-api": "Anthropic API",
|
|
@@ -34,6 +126,7 @@ function openSettings(tab) {
|
|
|
34
126
|
}
|
|
35
127
|
const overlay = document.getElementById("settings-overlay");
|
|
36
128
|
if (overlay) overlay.classList.remove("hidden");
|
|
129
|
+
resetScanState();
|
|
37
130
|
refreshSettings();
|
|
38
131
|
}
|
|
39
132
|
|
|
@@ -70,14 +163,17 @@ async function refreshSettings() {
|
|
|
70
163
|
const body = document.getElementById("settings-body");
|
|
71
164
|
body.innerHTML = `<div class="settings__loading"><div class="settings__spinner-lg"></div><span>Loading environment...</span></div>`;
|
|
72
165
|
|
|
166
|
+
// /status is config-only and returns in single-digit ms; this is just a relaxed
|
|
167
|
+
// safety net for an unreachable server, not the old 3s budget that timed out.
|
|
73
168
|
const controller = new AbortController();
|
|
74
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
169
|
+
const timeout = setTimeout(() => controller.abort(), 12000);
|
|
75
170
|
|
|
76
171
|
try {
|
|
77
172
|
const res = await fetch("/api/settings/status", { signal: controller.signal });
|
|
78
173
|
clearTimeout(timeout);
|
|
79
174
|
settingsData = await res.json();
|
|
80
175
|
renderSettings(settingsData);
|
|
176
|
+
maybeStartBackgroundScan();
|
|
81
177
|
} catch (err) {
|
|
82
178
|
clearTimeout(timeout);
|
|
83
179
|
const aborted = err && err.name === "AbortError";
|
|
@@ -108,6 +204,7 @@ function renderSettingsError(body, timedOut) {
|
|
|
108
204
|
}
|
|
109
205
|
|
|
110
206
|
function renderSettings(data) {
|
|
207
|
+
applyScanCaches(data);
|
|
111
208
|
const body = document.getElementById("settings-body");
|
|
112
209
|
body.innerHTML = "";
|
|
113
210
|
|
|
@@ -230,6 +327,9 @@ function renderAITab(body, data) {
|
|
|
230
327
|
});
|
|
231
328
|
|
|
232
329
|
modelRow.appendChild(modelSelect);
|
|
330
|
+
// Dropdowns populate instantly from STATIC_MODELS; Refresh pulls the live
|
|
331
|
+
// provider catalog on demand (VIB-1835).
|
|
332
|
+
modelRow.appendChild(makeScanButton("Refresh", () => fetchModels(true)));
|
|
233
333
|
section.appendChild(modelRow);
|
|
234
334
|
}
|
|
235
335
|
|
|
@@ -376,7 +476,14 @@ function renderAITab(body, data) {
|
|
|
376
476
|
// CLI Tools section with toggles
|
|
377
477
|
const cliSection = el("section", "settings__section");
|
|
378
478
|
cliSection.appendChild(sectionTitle("CLI Tools"));
|
|
379
|
-
cliSection.appendChild(desc("Enable CLI tools you have installed. Install status is
|
|
479
|
+
cliSection.appendChild(desc("Enable the CLI tools you have installed. Install and auth status is detected on demand — scan to check or refresh it."));
|
|
480
|
+
|
|
481
|
+
const cliScanRow = el("div", "settings__card-row");
|
|
482
|
+
const cliScanHint = el("span", "settings__card-meta");
|
|
483
|
+
cliScanHint.textContent = scannedGroups.ai ? "Status scanned" : "Not scanned yet";
|
|
484
|
+
cliScanRow.appendChild(cliScanHint);
|
|
485
|
+
cliScanRow.appendChild(makeScanButton(scannedGroups.ai ? "Rescan" : "Scan AI tools", () => fetchTools("ai", true)));
|
|
486
|
+
cliSection.appendChild(cliScanRow);
|
|
380
487
|
|
|
381
488
|
const cliTools = [
|
|
382
489
|
{ key: "claudeCode", id: "claude-code", name: "Claude Code", installId: "claude", url: "https://claude.ai/code" },
|
|
@@ -397,7 +504,12 @@ function renderAITab(body, data) {
|
|
|
397
504
|
label.textContent = tool.name;
|
|
398
505
|
labelWrap.appendChild(label);
|
|
399
506
|
|
|
400
|
-
if (enabled &&
|
|
507
|
+
if (enabled && !scannedGroups.ai) {
|
|
508
|
+
const sub = el("div", "settings__toggle-label-sub");
|
|
509
|
+
sub.textContent = "Not scanned";
|
|
510
|
+
sub.style.color = "var(--text-muted)";
|
|
511
|
+
labelWrap.appendChild(sub);
|
|
512
|
+
} else if (enabled && info.found) {
|
|
401
513
|
const sub = el("div", "settings__toggle-label-sub");
|
|
402
514
|
sub.textContent = `v${info.version}` + (info.authenticated ? " \u2014 authenticated" : " \u2014 not authenticated");
|
|
403
515
|
sub.style.color = info.authenticated ? "var(--success)" : "var(--warning)";
|
|
@@ -425,8 +537,9 @@ function renderAITab(body, data) {
|
|
|
425
537
|
|
|
426
538
|
row.appendChild(toggleRow);
|
|
427
539
|
|
|
428
|
-
// If enabled but not installed, show install button
|
|
429
|
-
|
|
540
|
+
// If enabled but not installed, show install button (only once we've scanned
|
|
541
|
+
// — before that, info.found is just an unscanned placeholder).
|
|
542
|
+
if (enabled && scannedGroups.ai && !info.found) {
|
|
430
543
|
const installRow = el("div", "settings__card-row");
|
|
431
544
|
const installBtn = el("button", "settings__btn settings__btn--primary");
|
|
432
545
|
installBtn.textContent = "Install";
|
|
@@ -444,7 +557,7 @@ function renderAITab(body, data) {
|
|
|
444
557
|
}
|
|
445
558
|
|
|
446
559
|
// If enabled, installed, but not authenticated — show sign in
|
|
447
|
-
if (enabled && info.found && !info.authenticated) {
|
|
560
|
+
if (enabled && scannedGroups.ai && info.found && !info.authenticated) {
|
|
448
561
|
const authRow = el("div", "settings__card-row");
|
|
449
562
|
const authBtn = el("button", "settings__btn settings__btn--primary");
|
|
450
563
|
authBtn.textContent = "Sign in";
|
|
@@ -456,6 +569,105 @@ function renderAITab(body, data) {
|
|
|
456
569
|
cliSection.appendChild(row);
|
|
457
570
|
}
|
|
458
571
|
body.appendChild(cliSection);
|
|
572
|
+
|
|
573
|
+
// Observability section — opt-in Langfuse keys, base URL, and enable toggle
|
|
574
|
+
body.appendChild(renderObservabilitySection(env, config));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// Observability — Langfuse keys + base URL + enable toggle
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
function renderObservabilitySection(env, config) {
|
|
582
|
+
const section = el("section", "settings__section");
|
|
583
|
+
section.appendChild(sectionTitle("Observability"));
|
|
584
|
+
section.appendChild(desc("Langfuse captures token usage, estimated cost, and traces for every API model call. Off by default — turn it on with the toggle and set both keys to start sending traces. Keys are stored locally in ~/.vibespot/config.json and sent only to your Langfuse instance."));
|
|
585
|
+
|
|
586
|
+
const pub = env.apiKeys.langfusePublic || { configured: false, masked: "" };
|
|
587
|
+
const sec = env.apiKeys.langfuseSecret || { configured: false, masked: "" };
|
|
588
|
+
const hasKeys = pub.configured && sec.configured;
|
|
589
|
+
const enabledFlag = config.langfuseEnabled; // undefined | true | false
|
|
590
|
+
const isOn = enabledFlag === true; // off by default; explicit opt-in required
|
|
591
|
+
|
|
592
|
+
// Enable toggle
|
|
593
|
+
const toggleRow = el("div", "settings__toggle-row");
|
|
594
|
+
const labelWrap = el("div", "");
|
|
595
|
+
const label = el("div", "settings__toggle-label");
|
|
596
|
+
label.textContent = "Send traces to Langfuse";
|
|
597
|
+
labelWrap.appendChild(label);
|
|
598
|
+
|
|
599
|
+
const sub = el("div", "settings__toggle-label-sub");
|
|
600
|
+
if (!isOn) {
|
|
601
|
+
sub.textContent = "Off — no traces or usage sent";
|
|
602
|
+
sub.style.color = "var(--text-muted)";
|
|
603
|
+
} else if (hasKeys) {
|
|
604
|
+
sub.textContent = "Active — traces, token usage & cost sent to Langfuse";
|
|
605
|
+
sub.style.color = "var(--success)";
|
|
606
|
+
} else {
|
|
607
|
+
sub.textContent = "Enabled — add a public + secret key below to start sending traces";
|
|
608
|
+
sub.style.color = "var(--warning)";
|
|
609
|
+
}
|
|
610
|
+
labelWrap.appendChild(sub);
|
|
611
|
+
toggleRow.appendChild(labelWrap);
|
|
612
|
+
|
|
613
|
+
const toggle = el("button", "settings__toggle" + (isOn ? " active" : ""));
|
|
614
|
+
toggle.addEventListener("click", async () => {
|
|
615
|
+
await fetch("/api/settings", {
|
|
616
|
+
method: "POST",
|
|
617
|
+
headers: { "Content-Type": "application/json" },
|
|
618
|
+
body: JSON.stringify({ langfuseEnabled: !isOn }),
|
|
619
|
+
});
|
|
620
|
+
refreshSettings();
|
|
621
|
+
});
|
|
622
|
+
toggleRow.appendChild(toggle);
|
|
623
|
+
section.appendChild(toggleRow);
|
|
624
|
+
|
|
625
|
+
// Keys — masked display, persisted via the shared /api/settings/apikey route
|
|
626
|
+
section.appendChild(createApiKeyCard("langfuse-public", "Public Key", "pk-lf-...", pub));
|
|
627
|
+
section.appendChild(createApiKeyCard("langfuse-secret", "Secret Key", "sk-lf-...", sec));
|
|
628
|
+
|
|
629
|
+
// Base URL
|
|
630
|
+
const urlCard = el("div", "settings__card");
|
|
631
|
+
const urlRow = el("div", "settings__card-row");
|
|
632
|
+
urlRow.style.gap = "8px";
|
|
633
|
+
const urlLabel = el("span", "settings__card-label");
|
|
634
|
+
urlLabel.textContent = "Base URL";
|
|
635
|
+
urlRow.appendChild(urlLabel);
|
|
636
|
+
|
|
637
|
+
const urlInput = el("input", "settings__apikey-input");
|
|
638
|
+
urlInput.type = "text";
|
|
639
|
+
urlInput.placeholder = "https://cloud.langfuse.com";
|
|
640
|
+
urlInput.value = config.langfuseBaseUrl || "";
|
|
641
|
+
urlRow.appendChild(urlInput);
|
|
642
|
+
|
|
643
|
+
const urlSaveBtn = el("button", "settings__btn settings__btn--primary");
|
|
644
|
+
urlSaveBtn.textContent = "Save";
|
|
645
|
+
const saveBaseUrl = async () => {
|
|
646
|
+
urlSaveBtn.disabled = true;
|
|
647
|
+
urlSaveBtn.textContent = "Saving...";
|
|
648
|
+
await fetch("/api/settings", {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: { "Content-Type": "application/json" },
|
|
651
|
+
body: JSON.stringify({ langfuseBaseUrl: urlInput.value.trim() }),
|
|
652
|
+
});
|
|
653
|
+
refreshSettings();
|
|
654
|
+
};
|
|
655
|
+
urlSaveBtn.addEventListener("click", saveBaseUrl);
|
|
656
|
+
urlInput.addEventListener("keydown", (e) => {
|
|
657
|
+
if (e.key === "Enter") { e.preventDefault(); saveBaseUrl(); }
|
|
658
|
+
});
|
|
659
|
+
urlRow.appendChild(urlSaveBtn);
|
|
660
|
+
urlCard.appendChild(urlRow);
|
|
661
|
+
|
|
662
|
+
const urlHint = el("div", "settings__toggle-label-sub");
|
|
663
|
+
urlHint.textContent = "cloud.langfuse.com (EU) · us.cloud.langfuse.com (US) · or your self-host URL. Leave blank for EU cloud.";
|
|
664
|
+
urlHint.style.color = "var(--text-muted)";
|
|
665
|
+
urlHint.style.marginTop = "6px";
|
|
666
|
+
urlCard.appendChild(urlHint);
|
|
667
|
+
|
|
668
|
+
section.appendChild(urlCard);
|
|
669
|
+
|
|
670
|
+
return section;
|
|
459
671
|
}
|
|
460
672
|
|
|
461
673
|
// ---------------------------------------------------------------------------
|
|
@@ -771,6 +983,28 @@ function renderHubSpotTab(body, data) {
|
|
|
771
983
|
// CLI mode — show CLI status and accounts from hs accounts list
|
|
772
984
|
acctSection.appendChild(desc("HubSpot CLI accounts are managed by the hs command. Use \u201chs auth\u201d to add accounts."));
|
|
773
985
|
|
|
986
|
+
// The hs CLI + accounts probe is a subprocess, so it runs on demand rather
|
|
987
|
+
// than on every settings open (VIB-1835).
|
|
988
|
+
const hsCheckRow = el("div", "settings__card-row");
|
|
989
|
+
const hsCheckHint = el("span", "settings__card-meta");
|
|
990
|
+
hsCheckHint.textContent = scannedGroups.platform ? "Status checked" : "Not checked yet";
|
|
991
|
+
hsCheckRow.appendChild(hsCheckHint);
|
|
992
|
+
hsCheckRow.appendChild(makeScanButton(scannedGroups.platform ? "Recheck" : "Check", () => fetchTools("platform", true)));
|
|
993
|
+
acctSection.appendChild(hsCheckRow);
|
|
994
|
+
|
|
995
|
+
if (!scannedGroups.platform) {
|
|
996
|
+
const pending = el("div", "settings__card");
|
|
997
|
+
const prow = el("div", "settings__card-row");
|
|
998
|
+
prow.appendChild(dot("muted"));
|
|
999
|
+
const plabel = el("span", "settings__card-label");
|
|
1000
|
+
plabel.textContent = "HubSpot CLI — not scanned";
|
|
1001
|
+
prow.appendChild(plabel);
|
|
1002
|
+
pending.appendChild(prow);
|
|
1003
|
+
acctSection.appendChild(pending);
|
|
1004
|
+
body.appendChild(acctSection);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
774
1008
|
const cliCard = el("div", "settings__card");
|
|
775
1009
|
const cliRow = el("div", "settings__card-row");
|
|
776
1010
|
cliRow.appendChild(dot(hs.found ? "success" : "warn"));
|
|
@@ -903,6 +1137,28 @@ function renderGitHubTab(body, data) {
|
|
|
903
1137
|
section.appendChild(sectionTitle("GitHub CLI"));
|
|
904
1138
|
section.appendChild(desc("GitHub CLI enables pushing your theme to a repository. Optional \u2014 not needed for HubSpot deployment."));
|
|
905
1139
|
|
|
1140
|
+
// GitHub install/auth is detected via subprocess, so it's checked on demand
|
|
1141
|
+
// rather than on every settings open (VIB-1835).
|
|
1142
|
+
const checkRow = el("div", "settings__card-row");
|
|
1143
|
+
const checkHint = el("span", "settings__card-meta");
|
|
1144
|
+
checkHint.textContent = scannedGroups.platform ? "Status checked" : "Not checked yet";
|
|
1145
|
+
checkRow.appendChild(checkHint);
|
|
1146
|
+
checkRow.appendChild(makeScanButton(scannedGroups.platform ? "Recheck" : "Check", () => fetchTools("platform", true)));
|
|
1147
|
+
section.appendChild(checkRow);
|
|
1148
|
+
|
|
1149
|
+
if (!scannedGroups.platform) {
|
|
1150
|
+
const pending = el("div", "settings__card");
|
|
1151
|
+
const pendingRow = el("div", "settings__card-row");
|
|
1152
|
+
pendingRow.appendChild(dot("muted"));
|
|
1153
|
+
const pendingLabel = el("span", "settings__card-label");
|
|
1154
|
+
pendingLabel.textContent = "GitHub CLI \u2014 not scanned";
|
|
1155
|
+
pendingRow.appendChild(pendingLabel);
|
|
1156
|
+
pending.appendChild(pendingRow);
|
|
1157
|
+
section.appendChild(pending);
|
|
1158
|
+
body.appendChild(section);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
906
1162
|
const card = el("div", "settings__card");
|
|
907
1163
|
|
|
908
1164
|
// CLI status
|
package/ui/styles.css
CHANGED
|
@@ -4184,6 +4184,19 @@ body { display: flex; }
|
|
|
4184
4184
|
font-size: var(--text-sm);
|
|
4185
4185
|
color: var(--text-dim);
|
|
4186
4186
|
}
|
|
4187
|
+
/* Per-project running generation cost chip (VIB-1770) */
|
|
4188
|
+
.chat__cost-total {
|
|
4189
|
+
margin-left: auto;
|
|
4190
|
+
font-size: var(--text-xs, 11px);
|
|
4191
|
+
font-variant-numeric: tabular-nums;
|
|
4192
|
+
color: var(--text-dim);
|
|
4193
|
+
background: var(--bg-subtle, rgba(127, 127, 127, 0.12));
|
|
4194
|
+
border: 1px solid var(--border);
|
|
4195
|
+
border-radius: 999px;
|
|
4196
|
+
padding: 2px 8px;
|
|
4197
|
+
cursor: default;
|
|
4198
|
+
white-space: nowrap;
|
|
4199
|
+
}
|
|
4187
4200
|
|
|
4188
4201
|
.chat__messages {
|
|
4189
4202
|
flex: 1;
|
|
@@ -5032,6 +5045,15 @@ body { display: flex; }
|
|
|
5032
5045
|
color: var(--warning, #f59e0b);
|
|
5033
5046
|
}
|
|
5034
5047
|
|
|
5048
|
+
/* Per-page estimated generation cost (VIB-1770) */
|
|
5049
|
+
.pipeline-cost {
|
|
5050
|
+
font-size: var(--text-xs, 11px);
|
|
5051
|
+
color: var(--text-dim);
|
|
5052
|
+
font-variant-numeric: tabular-nums;
|
|
5053
|
+
padding: 0 0 2px;
|
|
5054
|
+
cursor: default;
|
|
5055
|
+
}
|
|
5056
|
+
|
|
5035
5057
|
.pipeline-footer {
|
|
5036
5058
|
display: flex;
|
|
5037
5059
|
justify-content: space-between;
|