@enigmax/dashboard 0.1.60 → 0.1.63
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/assets/index.html +453 -59
- package/package.json +1 -1
package/assets/index.html
CHANGED
|
@@ -200,19 +200,92 @@
|
|
|
200
200
|
background: var(--surface2); color: var(--text); border: 1px solid var(--border);
|
|
201
201
|
border-radius: var(--radius-sm); padding: 6px 10px; font-size: 12px; font-family: var(--mono); cursor: pointer;
|
|
202
202
|
}
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
/* Top-of-page "update available" banner: spans the content width, amber-tinted so it
|
|
204
|
+
reads as a notice without alarming. Hidden until the stats poll reports an update. */
|
|
205
|
+
.update-alert {
|
|
206
|
+
display: flex; align-items: center; gap: 12px;
|
|
207
|
+
max-width: 1040px; margin: 16px auto -8px; padding: 11px 16px;
|
|
208
|
+
background: rgba(215,135,95,0.10); border: 1px solid rgba(215,135,95,0.45);
|
|
209
|
+
border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
|
|
210
|
+
}
|
|
211
|
+
.update-alert[hidden] { display: none; }
|
|
212
|
+
.update-alert .ua-ico { width: 18px; height: 18px; color: var(--accent); flex: none; }
|
|
213
|
+
.update-alert .ua-msg { flex: 1; }
|
|
214
|
+
.update-alert .ua-x { background: none; border: none; color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer; padding: 0 4px; }
|
|
215
|
+
.update-alert .ua-x:hover { color: var(--text); }
|
|
216
|
+
/* Status notes now surface as toasts; the inline element stays for call compatibility but
|
|
217
|
+
collapses when empty so no leftover green/red line shows. */
|
|
218
|
+
.set-note { color: var(--accent); font-size: 12px; margin-top: 10px; }
|
|
219
|
+
.set-note:empty { display: none; margin: 0; }
|
|
205
220
|
.set-note.ok { color: var(--good); }
|
|
206
221
|
.set-note.err { color: var(--bad); }
|
|
207
|
-
/* Toasts
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
222
|
+
/* Toasts (Sonner-style, ported to vanilla). Stacked bottom-right: the newest is in front,
|
|
223
|
+
older ones peek behind and fan out on hover. Rich-coloured per type, self-dismissing,
|
|
224
|
+
swipe- or click-to-close - so a result is never a cutre green line again. */
|
|
225
|
+
#toaster {
|
|
226
|
+
position: fixed; right: var(--t-offset); bottom: var(--t-offset); z-index: 999999;
|
|
227
|
+
width: var(--t-width); list-style: none; margin: 0; padding: 0; outline: none;
|
|
228
|
+
--t-offset: 24px; --t-width: 356px; --t-gap: 14px; --t-front: 0px;
|
|
229
|
+
font-family: var(--sans);
|
|
230
|
+
--t-bg: var(--surface); --t-border: var(--border); --t-text: var(--text);
|
|
231
|
+
--success-bg: hsl(150,38%,9%); --success-border: hsl(147,55%,18%); --success-text: hsl(150,80%,72%);
|
|
232
|
+
--error-bg: hsl(358,42%,12%); --error-border: hsl(357,55%,24%); --error-text: hsl(358,100%,82%);
|
|
233
|
+
--info-bg: hsl(215,45%,11%); --info-border: hsl(223,50%,24%); --info-text: hsl(216,87%,74%);
|
|
234
|
+
--warning-bg: hsl(40,55%,9%); --warning-border: hsl(40,55%,20%); --warning-text: hsl(46,87%,70%);
|
|
235
|
+
transition: transform .35s ease;
|
|
236
|
+
}
|
|
237
|
+
#toaster[data-expanded="true"] { transform: translateY(-6px); }
|
|
238
|
+
.etoast {
|
|
239
|
+
position: absolute; bottom: 0; right: 0; left: 0; width: 100%; box-sizing: border-box;
|
|
240
|
+
display: flex; align-items: center; gap: 10px; padding: 15px 16px;
|
|
241
|
+
background: var(--t-bg); border: 1px solid var(--t-border); color: var(--t-text);
|
|
242
|
+
border-radius: 12px; box-shadow: 0 6px 22px rgba(0,0,0,.34); font-size: 13px;
|
|
243
|
+
z-index: var(--z); opacity: 0; touch-action: none; overflow-wrap: anywhere;
|
|
244
|
+
transform: translateY(110%); transform-origin: bottom center;
|
|
245
|
+
transition: transform .42s cubic-bezier(.21,1.02,.73,1), opacity .35s, height .35s, box-shadow .2s;
|
|
246
|
+
}
|
|
247
|
+
.etoast[data-mounted="true"] { opacity: 1; transform: translateY(0); }
|
|
248
|
+
/* Collapsed (default): older toasts sit behind the front one, scaled + clipped to its height. */
|
|
249
|
+
.etoast[data-front="false"][data-expanded="false"] {
|
|
250
|
+
transform: translateY(calc(var(--toasts-before) * -14px)) scale(calc(1 - var(--toasts-before) * .06));
|
|
251
|
+
height: var(--t-front);
|
|
252
|
+
}
|
|
253
|
+
.etoast[data-front="false"][data-expanded="false"] > * { opacity: 0; }
|
|
254
|
+
/* Expanded (hover): fan out, each lifted by the summed height of the ones in front of it. */
|
|
255
|
+
.etoast[data-expanded="true"] { transform: translateY(calc(-1 * var(--offset))); }
|
|
256
|
+
.etoast[data-visible="false"] { opacity: 0; pointer-events: none; }
|
|
257
|
+
.etoast[data-removed="true"] { opacity: 0; }
|
|
258
|
+
.etoast[data-removed="true"][data-expanded="false"] { transform: translateY(150%) scale(.9); }
|
|
259
|
+
.etoast[data-removed="true"][data-expanded="true"] { transform: translateY(calc(-1 * var(--offset) + 110%)); }
|
|
260
|
+
.etoast[data-swiping="true"] { transition: opacity .2s; transform: translateX(var(--swipe-x, 0px)); }
|
|
261
|
+
.etoast > * { transition: opacity .35s; }
|
|
262
|
+
.etoast-icon { display: flex; align-items: center; flex-shrink: 0; width: 20px; height: 20px; }
|
|
263
|
+
.etoast-icon svg { width: 20px; height: 20px; display: block; }
|
|
264
|
+
.etoast-content { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
|
|
265
|
+
.etoast-title { font-weight: 600; line-height: 1.4; }
|
|
266
|
+
.etoast-desc { font-size: 12px; line-height: 1.45; color: var(--muted); }
|
|
267
|
+
.etoast-close {
|
|
268
|
+
position: absolute; top: 0; left: 0; transform: translate(-35%,-35%);
|
|
269
|
+
width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
|
|
270
|
+
padding: 0; border-radius: 50%; cursor: pointer; opacity: 0; z-index: 1;
|
|
271
|
+
background: var(--t-bg); border: 1px solid var(--t-border); color: var(--t-text);
|
|
272
|
+
transition: opacity .15s, background .2s, border-color .2s;
|
|
273
|
+
}
|
|
274
|
+
.etoast:hover .etoast-close, .etoast-close:focus-visible { opacity: 1; }
|
|
275
|
+
.etoast-close:hover { background: var(--surface2); }
|
|
276
|
+
.etoast[data-type="success"] { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); }
|
|
277
|
+
.etoast[data-type="error"] { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); }
|
|
278
|
+
.etoast[data-type="info"] { background: var(--info-bg); border-color: var(--info-border); color: var(--info-text); }
|
|
279
|
+
.etoast[data-type="warning"] { background: var(--warning-bg); border-color: var(--warning-border); color: var(--warning-text); }
|
|
280
|
+
.etoast[data-type="success"] .etoast-desc, .etoast[data-type="error"] .etoast-desc,
|
|
281
|
+
.etoast[data-type="info"] .etoast-desc, .etoast[data-type="warning"] .etoast-desc { color: inherit; opacity: .82; }
|
|
282
|
+
.etoast[data-type] .etoast-close { background: inherit; border-color: currentColor; color: inherit; }
|
|
283
|
+
.etoast-spin { width: 18px; height: 18px; animation: etoast-spin 0.9s linear infinite; transform-origin: center; }
|
|
284
|
+
@keyframes etoast-spin { to { transform: rotate(360deg); } }
|
|
285
|
+
@media (max-width: 600px) {
|
|
286
|
+
#toaster { right: 14px; left: 14px; width: auto; --t-width: 100%; }
|
|
287
|
+
}
|
|
288
|
+
@media (prefers-reduced-motion) { .etoast, .etoast > * { transition: opacity .2s !important; } }
|
|
216
289
|
/* Settings: two-column grid of rows on wide screens, so a long config list stays compact. */
|
|
217
290
|
.set-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 0 32px; }
|
|
218
291
|
.set-grid .set-row { padding: 10px 0; }
|
|
@@ -245,6 +318,9 @@
|
|
|
245
318
|
.btn-danger.icon { display: inline-flex; align-items: center; justify-content: center; padding: 6px 9px; }
|
|
246
319
|
.btn-danger.icon:hover { color: var(--bad); border-color: var(--bad); }
|
|
247
320
|
.btn-danger svg { width: 15px; height: 15px; display: block; }
|
|
321
|
+
/* Per-memory delete in the Recall list: kept to the right and revealed on row hover/focus. */
|
|
322
|
+
.recall-del { margin-left: auto; opacity: 0; transition: opacity .12s; }
|
|
323
|
+
.set-row:hover .recall-del, .recall-del:focus-visible { opacity: 1; }
|
|
248
324
|
/* Per-agent skill toggles: one pill per app (the app's icon), green when the skill is on for it; the app name is the tooltip. */
|
|
249
325
|
.set-agents { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
|
250
326
|
.achip { display: inline-flex; align-items: center; justify-content: center; padding: 5px 8px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface2); color: var(--muted); cursor: pointer; text-decoration: none; transition: color .15s, background .15s, border-color .15s; }
|
|
@@ -394,6 +470,19 @@
|
|
|
394
470
|
.dd-opt { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text); white-space: nowrap; cursor: pointer; }
|
|
395
471
|
.dd-opt:hover, .dd-opt:focus { background: var(--surface2); outline: none; }
|
|
396
472
|
.dd-opt.sel { color: var(--accent); }
|
|
473
|
+
.dd-btn:focus-visible { outline: none; border-color: var(--accent); }
|
|
474
|
+
/* Enhanced native <select>s (enhanceSelect): same look, but they hug their content instead
|
|
475
|
+
of the wider provider picker, and fill a flex slot when the original select did. */
|
|
476
|
+
.ddx { display: inline-flex; vertical-align: middle; }
|
|
477
|
+
.ddx .dd-btn { min-width: 120px; }
|
|
478
|
+
.ddx .dd-menu { max-height: 320px; overflow-y: auto; }
|
|
479
|
+
/* Native selects that stay native (inside modals) get a custom caret so they match the
|
|
480
|
+
styled dropdowns - no default OS chevron. */
|
|
481
|
+
select.linput, select.modal-input, select.scope-sel, select.choice {
|
|
482
|
+
-webkit-appearance: none; -moz-appearance: none; appearance: none;
|
|
483
|
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='13' height='13' viewBox='0 0 24 24' fill='none' stroke='%23808a99' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
|
|
484
|
+
background-repeat: no-repeat; background-position: right 9px center; padding-right: 28px;
|
|
485
|
+
}
|
|
397
486
|
/* Icon picker: clickable app cards (a native <select> can't render SVG in options). */
|
|
398
487
|
.modal-picker { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
|
|
399
488
|
.pick-card {
|
|
@@ -445,6 +534,13 @@
|
|
|
445
534
|
</div>
|
|
446
535
|
</header>
|
|
447
536
|
|
|
537
|
+
<div id="updateAlert" class="update-alert" hidden>
|
|
538
|
+
<svg class="ua-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56"/><path d="M12 7v5l3 2"/></svg>
|
|
539
|
+
<span class="ua-msg" id="updateAlertMsg"></span>
|
|
540
|
+
<button type="button" id="updateAlertBtn" class="toggle on">Update now</button>
|
|
541
|
+
<button type="button" id="updateAlertDismiss" class="ua-x" title="Dismiss" aria-label="Dismiss">×</button>
|
|
542
|
+
</div>
|
|
543
|
+
|
|
448
544
|
<main id="view-savings">
|
|
449
545
|
<div class="grid">
|
|
450
546
|
<div class="card hero"><div class="label">Money Saved</div><div id="money" class="value good">-</div><div id="moneyFoot" class="foot">Estimated, by source</div></div>
|
|
@@ -710,10 +806,13 @@
|
|
|
710
806
|
<div id="recallBody"><div class="empty" style="padding:12px 0">Loading...</div></div>
|
|
711
807
|
<div id="recallProviderPanel" class="panel" style="display:none">
|
|
712
808
|
<div class="set-cat-h">Memory provider</div>
|
|
713
|
-
<div class="set-hint" style="padding:4px 0">Which
|
|
714
|
-
<div class="set-row"><div class="set-meta"><div class="set-name">LLM curation</div><div class="set-hint">
|
|
809
|
+
<div class="set-hint" style="padding:4px 0">Which model curates your memory - it keeps only what's worth remembering. The default reuses your local Claude Code login (no key). Or pick Anthropic or an OpenAI-compatible API and add its key. The key is stored encrypted, and <code>ENIGMA_RECALL_API_KEY</code> overrides it.</div>
|
|
810
|
+
<div class="set-row"><div class="set-meta"><div class="set-name">LLM curation</div><div class="set-hint">Off falls back to a local heuristic - no network calls</div></div><div class="set-ctl"><button id="recallLlmBtn" type="button" class="toggle" data-kind="bool" role="switch" aria-checked="false" aria-label="LLM curation">off</button></div></div>
|
|
715
811
|
<div class="ladd" style="gap:8px;flex-wrap:wrap;margin-top:8px">
|
|
716
|
-
<
|
|
812
|
+
<div class="dd" id="recallProvDD">
|
|
813
|
+
<button type="button" class="dd-btn" aria-haspopup="listbox" aria-expanded="false"><span class="dd-ic"></span><span class="dd-label"></span><svg class="dd-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
|
|
814
|
+
<ul class="dd-menu" role="listbox" aria-label="Memory provider" hidden></ul>
|
|
815
|
+
</div>
|
|
717
816
|
<input id="recallModel" class="linput" placeholder="model (provider default)" style="flex:1;min-width:160px">
|
|
718
817
|
</div>
|
|
719
818
|
<div id="recallKeyRow" class="ladd" style="gap:8px;flex-wrap:wrap;margin-top:8px">
|
|
@@ -730,6 +829,24 @@
|
|
|
730
829
|
<select id="recallProject" class="linput" style="max-width:200px"></select>
|
|
731
830
|
</div>
|
|
732
831
|
</div>
|
|
832
|
+
<div id="recallAddPanel" class="panel" style="display:none">
|
|
833
|
+
<div class="set-cat-h">Add a memory</div>
|
|
834
|
+
<div class="set-hint" style="padding:4px 0">Write one yourself, or generate it from a short note with the configured model.</div>
|
|
835
|
+
<div class="ladd" style="gap:8px;flex-wrap:wrap">
|
|
836
|
+
<select id="recallNewType" class="linput" style="max-width:150px"></select>
|
|
837
|
+
<input id="recallNewTitle" class="linput" placeholder="Title" style="flex:1;min-width:200px">
|
|
838
|
+
<input id="recallNewProject" class="linput" placeholder="Project (optional)" style="flex:1;min-width:140px">
|
|
839
|
+
</div>
|
|
840
|
+
<div class="ladd" style="gap:8px;flex-wrap:wrap;margin-top:8px">
|
|
841
|
+
<input id="recallNewNarrative" class="linput" placeholder="What's worth remembering (optional)" style="flex:1;min-width:240px">
|
|
842
|
+
<input id="recallNewFacts" class="linput" placeholder="Facts, comma-separated (optional)" style="flex:1;min-width:200px">
|
|
843
|
+
</div>
|
|
844
|
+
<div style="margin-top:8px"><button id="recallCreate" type="button" class="toggle on">Save memory</button></div>
|
|
845
|
+
<div class="ladd" style="gap:8px;flex-wrap:wrap;margin-top:12px;border-top:1px solid rgba(128,128,128,.2);padding-top:12px">
|
|
846
|
+
<input id="recallGenNote" class="linput" placeholder="Note to generate a memory from..." style="flex:1;min-width:260px">
|
|
847
|
+
<button id="recallGenerate" type="button" class="toggle" title="Generate a structured memory from your note">Generate with LLM</button>
|
|
848
|
+
</div>
|
|
849
|
+
</div>
|
|
733
850
|
<div id="recallList"></div>
|
|
734
851
|
</section>
|
|
735
852
|
|
|
@@ -1974,23 +2091,28 @@ export {
|
|
|
1974
2091
|
try { await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: Number(value) }) }); refreshAll(true); }
|
|
1975
2092
|
catch { /* leave as-is */ }
|
|
1976
2093
|
}
|
|
1977
|
-
//
|
|
1978
|
-
//
|
|
1979
|
-
//
|
|
1980
|
-
|
|
1981
|
-
|
|
2094
|
+
// Shared dropdown core. Renders one styled listbox over a `.dd` root (button + menu) from a
|
|
2095
|
+
// model: entries() -> [{ value, label, icon }], get() -> current value, set(value) -> commit.
|
|
2096
|
+
// Icons are optional per entry, so the SAME component drives the icon pickers (provider,
|
|
2097
|
+
// usage) AND every plain selector on the page (see enhanceSelect) - one look, one set of
|
|
2098
|
+
// keyboard semantics, with or without logos.
|
|
2099
|
+
function bindDropdown(root, model) {
|
|
2100
|
+
const btn = root.querySelector(".dd-btn"), menu = root.querySelector(".dd-menu");
|
|
1982
2101
|
const btnIc = btn.querySelector(".dd-ic"), btnLabel = btn.querySelector(".dd-label");
|
|
1983
2102
|
const opts = () => [...menu.querySelectorAll(".dd-opt")];
|
|
2103
|
+
const ic = (svg) => '<span class="dd-ic"' + (svg ? "" : ' style="display:none"') + ">" + (svg || "") + "</span>";
|
|
1984
2104
|
const render = () => {
|
|
1985
|
-
const
|
|
1986
|
-
|
|
2105
|
+
const entries = model.entries(), cur = model.get();
|
|
2106
|
+
const e = entries.find((x) => x.value === cur) || entries[0] || { label: "", icon: "" };
|
|
2107
|
+
btnIc.innerHTML = e.icon || ""; btnIc.style.display = e.icon ? "" : "none";
|
|
2108
|
+
btnLabel.textContent = e.label || "";
|
|
1987
2109
|
menu.innerHTML = entries.map((x) => '<li class="dd-opt' + (x.value === cur ? " sel" : "")
|
|
1988
2110
|
+ '" role="option" tabindex="-1" data-val="' + esc(x.value) + '" aria-selected="' + (x.value === cur)
|
|
1989
|
-
+ '"
|
|
2111
|
+
+ '">' + ic(x.icon) + "<span>" + esc(x.label) + "</span></li>").join("");
|
|
1990
2112
|
};
|
|
1991
2113
|
const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
|
|
1992
|
-
const open = () => { menu.hidden = false; btn.setAttribute("aria-expanded", "true");
|
|
1993
|
-
const pick = (li) => { close(); btn.focus(); if (li && li.dataset.val)
|
|
2114
|
+
const open = () => { render(); menu.hidden = false; btn.setAttribute("aria-expanded", "true"); const f = menu.querySelector(".sel") || opts()[0]; if (f) f.focus(); };
|
|
2115
|
+
const pick = (li) => { close(); btn.focus(); if (li && li.dataset.val != null) { model.set(li.dataset.val); render(); } };
|
|
1994
2116
|
btn.addEventListener("click", (e) => { e.stopPropagation(); menu.hidden ? open() : close(); });
|
|
1995
2117
|
btn.addEventListener("keydown", (e) => { if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } });
|
|
1996
2118
|
menu.addEventListener("click", (e) => pick(e.target.closest(".dd-opt")));
|
|
@@ -2000,11 +2122,65 @@ export {
|
|
|
2000
2122
|
else if (e.key === "ArrowDown") { e.preventDefault(); (list[i + 1] || list[0]).focus(); }
|
|
2001
2123
|
else if (e.key === "ArrowUp") { e.preventDefault(); (list[i - 1] || list[list.length - 1]).focus(); }
|
|
2002
2124
|
else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(document.activeElement); }
|
|
2125
|
+
else if (e.key === "Tab") close();
|
|
2003
2126
|
});
|
|
2004
|
-
document.addEventListener("click", close);
|
|
2005
2127
|
render();
|
|
2006
2128
|
return { render };
|
|
2007
2129
|
}
|
|
2130
|
+
// One global outside-click closes any open dropdown - cheaper and leak-free vs a per-instance
|
|
2131
|
+
// listener (enhanceSelect can create/replace many over a session). A `.dd-btn` click stops
|
|
2132
|
+
// propagation, so opening one never trips this.
|
|
2133
|
+
document.addEventListener("click", () => {
|
|
2134
|
+
document.querySelectorAll(".dd-menu:not([hidden])").forEach((m) => {
|
|
2135
|
+
m.hidden = true;
|
|
2136
|
+
const b = m.parentElement && m.parentElement.querySelector(".dd-btn");
|
|
2137
|
+
if (b) b.setAttribute("aria-expanded", "false");
|
|
2138
|
+
});
|
|
2139
|
+
});
|
|
2140
|
+
// entries: [{ value, label, icon }] bound to a pre-rendered `.dd` root by id.
|
|
2141
|
+
function iconDropdown(rootId, entries, getVal, onPick) {
|
|
2142
|
+
return bindDropdown($(rootId), { entries: () => entries, get: getVal, set: onPick });
|
|
2143
|
+
}
|
|
2144
|
+
// Markup for an enhanced dropdown's button + menu, matching the hand-written provider picker.
|
|
2145
|
+
const DD_INNER = '<button type="button" class="dd-btn" aria-haspopup="listbox" aria-expanded="false"><span class="dd-ic"></span><span class="dd-label"></span><svg class="dd-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button><ul class="dd-menu" role="listbox" hidden></ul>';
|
|
2146
|
+
// Progressive enhancement: replace a native <select> with the shared pretty dropdown while
|
|
2147
|
+
// keeping the <select> itself as the source of truth (hidden). Its .value and `change`
|
|
2148
|
+
// events are untouched - we set .value and dispatch a real `change` on pick - so every
|
|
2149
|
+
// existing reader and delegated handler keeps working. Icons are opt-in via
|
|
2150
|
+
// data-dd-icons="agent" (option value -> brand logo) or per-option data-icon="<key>".
|
|
2151
|
+
function enhanceSelect(select) {
|
|
2152
|
+
if (select.__dd || select.dataset.noDd != null) return;
|
|
2153
|
+
if (select.closest(".modal-back")) return; // a popup menu inside an animated modal would clip; left native (still styled)
|
|
2154
|
+
const iconMode = select.dataset.ddIcons || "";
|
|
2155
|
+
const iconFor = (opt) => { const k = opt.dataset.icon || (iconMode === "agent" ? opt.value : ""); return k ? (agentSvg(k) || "") : ""; };
|
|
2156
|
+
const root = document.createElement("span");
|
|
2157
|
+
root.className = "dd ddx";
|
|
2158
|
+
root.innerHTML = DD_INNER;
|
|
2159
|
+
select.style.display = "none";
|
|
2160
|
+
select.insertAdjacentElement("afterend", root);
|
|
2161
|
+
const api = bindDropdown(root, {
|
|
2162
|
+
entries: () => [...select.options].map((o) => ({ value: o.value, label: o.textContent, icon: iconFor(o) })),
|
|
2163
|
+
get: () => select.value,
|
|
2164
|
+
set: (v) => { if (v !== select.value) { select.value = v; select.dispatchEvent(new Event("change", { bubbles: true })); } api.render(); }
|
|
2165
|
+
});
|
|
2166
|
+
select.__dd = api;
|
|
2167
|
+
// Re-sync the button when options are rebuilt (innerHTML) or the value changes.
|
|
2168
|
+
new MutationObserver(() => api.render()).observe(select, { childList: true });
|
|
2169
|
+
select.addEventListener("change", () => api.render());
|
|
2170
|
+
return api;
|
|
2171
|
+
}
|
|
2172
|
+
// Push a programmatic `select.value = x` (no `change` event) into the enhanced dropdown.
|
|
2173
|
+
const syncDD = (el) => { if (el && el.__dd) el.__dd.render(); };
|
|
2174
|
+
function enhanceAll(root) { (root || document).querySelectorAll("select:not([data-no-dd])").forEach(enhanceSelect); }
|
|
2175
|
+
// Any selector added later (settings rows, project config, account profiles...) is enhanced
|
|
2176
|
+
// automatically, so there is nothing to wire per-view.
|
|
2177
|
+
new MutationObserver((muts) => {
|
|
2178
|
+
for (const m of muts) for (const n of m.addedNodes) {
|
|
2179
|
+
if (n.nodeType !== 1) continue;
|
|
2180
|
+
if (n.tagName === "SELECT") enhanceSelect(n);
|
|
2181
|
+
else if (n.querySelectorAll) n.querySelectorAll("select:not([data-no-dd])").forEach(enhanceSelect);
|
|
2182
|
+
}
|
|
2183
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
2008
2184
|
let providerDD = null;
|
|
2009
2185
|
function wireUsage() {
|
|
2010
2186
|
// The provider picker is a custom icon dropdown; the account one stays a native select.
|
|
@@ -2081,6 +2257,7 @@ export {
|
|
|
2081
2257
|
+ names.map((n) => '<option value="' + esc(n) + '">' + esc(accountLabel(n)) + "</option>").join("");
|
|
2082
2258
|
}
|
|
2083
2259
|
sel.value = usageAccount;
|
|
2260
|
+
syncDD(sel);
|
|
2084
2261
|
}
|
|
2085
2262
|
|
|
2086
2263
|
// Fill the panel from one scope: the whole report (isAll) or one account's sub-report.
|
|
@@ -2309,6 +2486,7 @@ export {
|
|
|
2309
2486
|
if (loadedUi === null) loadedUi = data.ui;
|
|
2310
2487
|
else if (data.ui !== loadedUi) { location.reload(); return; }
|
|
2311
2488
|
}
|
|
2489
|
+
renderUpdateAlert(data.updates);
|
|
2312
2490
|
priceOverride = Number(data.priceOverride) || 0;
|
|
2313
2491
|
speedOverride = Number(data.speedOverride) || 0;
|
|
2314
2492
|
const s = data.stats || {};
|
|
@@ -2367,6 +2545,35 @@ export {
|
|
|
2367
2545
|
}
|
|
2368
2546
|
}
|
|
2369
2547
|
|
|
2548
|
+
// --- package update alert --------------------------------------------------
|
|
2549
|
+
// Server-side npm version check (CLI + dashboard + linter), surfaced in /api/stats and
|
|
2550
|
+
// refreshed every poll. Dismiss is sticky only for the exact versions shown - a later
|
|
2551
|
+
// release re-shows the banner. The "Update now" button runs `enigma update` behind the
|
|
2552
|
+
// scenes; the alert then clears on its own as the next polls see the new versions.
|
|
2553
|
+
let dismissedUpdateSig = "";
|
|
2554
|
+
function renderUpdateAlert(u) {
|
|
2555
|
+
const el = $("updateAlert");
|
|
2556
|
+
if (!el) return;
|
|
2557
|
+
const items = ((u && u.packages) || []).filter((p) => p.hasUpdate);
|
|
2558
|
+
const sig = items.map((p) => p.name + "@" + p.latest).join(",");
|
|
2559
|
+
if (!items.length || sig === dismissedUpdateSig) { el.hidden = true; return; }
|
|
2560
|
+
const list = items.map((p) => esc(p.label) + " " + esc(p.installed) + " -> " + esc(p.latest)).join(", ");
|
|
2561
|
+
$("updateAlertMsg").innerHTML = "<strong>Update available</strong> " + list;
|
|
2562
|
+
el.dataset.sig = sig;
|
|
2563
|
+
el.hidden = false;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
async function runFullUpdate() {
|
|
2567
|
+
const btn = $("updateAlertBtn");
|
|
2568
|
+
if (btn) { btn.disabled = true; btn.textContent = "Updating..."; }
|
|
2569
|
+
try {
|
|
2570
|
+
const res = await fetch("/api/run-update", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
|
2571
|
+
const out = await res.json();
|
|
2572
|
+
toast(out.note || (out.ok ? "Update started." : "Could not start the update."), out.ok ? "ok" : "err");
|
|
2573
|
+
} catch { toast("Could not reach the server to update.", "err"); }
|
|
2574
|
+
finally { if (btn) { btn.disabled = false; btn.textContent = "Update now"; } }
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2370
2577
|
// --- dashboard self-update -------------------------------------------------
|
|
2371
2578
|
// Where the latest published dashboard version lives: the raw package.json on the
|
|
2372
2579
|
// repo's main branch (no API, no auth, no rate limit). A repo rename 404s here and is
|
|
@@ -2414,24 +2621,153 @@ export {
|
|
|
2414
2621
|
// --- settings subpage (mirrors the TUI registry over /api/settings) ---
|
|
2415
2622
|
function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
|
|
2416
2623
|
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2624
|
+
// --- Toasts (vanilla port of Sonner) -------------------------------------------------
|
|
2625
|
+
// A stacked, self-dismissing notification system: the newest sits in front, older ones
|
|
2626
|
+
// peek behind and fan out on hover; rich-coloured per type; swipe- or click-to-close.
|
|
2627
|
+
// API: toast(msg, opts) and toast.success/error/info/warning/loading/message/dismiss.
|
|
2628
|
+
// opts: { description, duration, id }. A loading toast has Infinity duration; calling a
|
|
2629
|
+
// type with the same id UPDATES it in place (loading -> success/error), so an async flow
|
|
2630
|
+
// is a single morphing toast, not a pile.
|
|
2631
|
+
const toast = (() => {
|
|
2632
|
+
const ICONS = {
|
|
2633
|
+
success: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>',
|
|
2634
|
+
error: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>',
|
|
2635
|
+
info: '<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/></svg>',
|
|
2636
|
+
warning: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/></svg>',
|
|
2637
|
+
loading: '<svg class="etoast-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" opacity="0.85"/></svg>',
|
|
2638
|
+
close: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
|
|
2639
|
+
};
|
|
2640
|
+
const VISIBLE = 3, DURATION = { default: 4000, success: 4000, info: 4000, warning: 5000, error: 6000, loading: Infinity };
|
|
2641
|
+
let list = null, counter = 1, expanded = false;
|
|
2642
|
+
const items = []; // newest-first; { id, el, type, duration, remaining, startedAt, timer, removed }
|
|
2643
|
+
const mount = () => { if (!list) { list = document.createElement("ol"); list.id = "toaster"; list.setAttribute("aria-live", "polite"); list.tabIndex = -1; document.body.appendChild(list); wireHover(); } return list; };
|
|
2644
|
+
const wireHover = () => {
|
|
2645
|
+
list.addEventListener("mouseenter", () => { expanded = true; list.dataset.expanded = "true"; pauseAll(); place(); });
|
|
2646
|
+
list.addEventListener("mouseleave", () => { expanded = false; list.dataset.expanded = "false"; resumeAll(); place(); });
|
|
2647
|
+
};
|
|
2648
|
+
const front = () => items.find((t) => !t.removed);
|
|
2649
|
+
const pause = (t) => { if (t.timer) { clearTimeout(t.timer); t.timer = null; t.remaining -= Date.now() - t.startedAt; } };
|
|
2650
|
+
const resume = (t) => { if (t.duration === Infinity || t.removed || t.timer) return; t.startedAt = Date.now(); t.timer = setTimeout(() => dismiss(t.id), Math.max(0, t.remaining)); };
|
|
2651
|
+
const pauseAll = () => items.forEach(pause);
|
|
2652
|
+
const resumeAll = () => items.forEach(resume);
|
|
2653
|
+
// Position every toast: front shows fully, the rest peek behind (collapsed) or fan out
|
|
2654
|
+
// (expanded). --offset is the summed height of the live toasts in front of this one.
|
|
2655
|
+
function place() {
|
|
2656
|
+
let acc = 0, vi = 0;
|
|
2657
|
+
for (const t of items) {
|
|
2658
|
+
if (t.removed) { t.el.style.setProperty("--z", "0"); continue; }
|
|
2659
|
+
// The front toast (and any toast while the stack is expanded) is never
|
|
2660
|
+
// height-clamped, so its offsetHeight is the true natural height - cache it.
|
|
2661
|
+
if (vi === 0 && !expanded) t.height = t.el.offsetHeight;
|
|
2662
|
+
if (vi === 0) list.style.setProperty("--t-front", (t.height || t.el.offsetHeight) + "px");
|
|
2663
|
+
t.el.dataset.front = vi === 0 ? "true" : "false";
|
|
2664
|
+
t.el.dataset.expanded = expanded ? "true" : "false";
|
|
2665
|
+
t.el.dataset.visible = vi < VISIBLE ? "true" : "false";
|
|
2666
|
+
t.el.style.setProperty("--toasts-before", String(vi));
|
|
2667
|
+
t.el.style.setProperty("--offset", acc + "px");
|
|
2668
|
+
t.el.style.setProperty("--z", String(1000 - vi));
|
|
2669
|
+
acc += (t.height || t.el.offsetHeight) + 14;
|
|
2670
|
+
vi++;
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
function dismiss(id) {
|
|
2674
|
+
const t = items.find((x) => x.id === id);
|
|
2675
|
+
if (!t || t.removed) return id;
|
|
2676
|
+
t.removed = true; if (t.timer) clearTimeout(t.timer);
|
|
2677
|
+
t.el.dataset.removed = "true"; t.el.dataset.expanded = expanded ? "true" : "false";
|
|
2678
|
+
place();
|
|
2679
|
+
setTimeout(() => { const i = items.indexOf(t); if (i !== -1) items.splice(i, 1); t.el.remove(); place(); }, 420);
|
|
2680
|
+
return id;
|
|
2681
|
+
}
|
|
2682
|
+
function build(t, type, title, description) {
|
|
2683
|
+
t.el.dataset.type = type;
|
|
2684
|
+
t.el.setAttribute("role", type === "error" ? "alert" : "status");
|
|
2685
|
+
const icon = ICONS[type] ? '<div class="etoast-icon">' + ICONS[type] + "</div>" : "";
|
|
2686
|
+
t.el.innerHTML = icon
|
|
2687
|
+
+ '<div class="etoast-content"><div class="etoast-title"></div>'
|
|
2688
|
+
+ (description ? '<div class="etoast-desc"></div>' : "") + "</div>"
|
|
2689
|
+
+ '<button type="button" class="etoast-close" aria-label="Close">' + ICONS.close + "</button>";
|
|
2690
|
+
t.el.querySelector(".etoast-title").textContent = title == null ? "" : String(title);
|
|
2691
|
+
if (description) t.el.querySelector(".etoast-desc").textContent = String(description);
|
|
2692
|
+
t.el.querySelector(".etoast-close").addEventListener("click", (e) => { e.stopPropagation(); dismiss(t.id); });
|
|
2693
|
+
}
|
|
2694
|
+
// Horizontal swipe-to-dismiss (pointer drag), matching Sonner's feel.
|
|
2695
|
+
function wireSwipe(t) {
|
|
2696
|
+
let startX = null;
|
|
2697
|
+
t.el.addEventListener("pointerdown", (e) => { if (e.target.closest(".etoast-close")) return; startX = e.clientX; t.el.setPointerCapture(e.pointerId); pause(t); });
|
|
2698
|
+
t.el.addEventListener("pointermove", (e) => {
|
|
2699
|
+
if (startX == null) return;
|
|
2700
|
+
const dx = e.clientX - startX;
|
|
2701
|
+
t.el.dataset.swiping = "true"; t.el.style.setProperty("--swipe-x", dx + "px");
|
|
2702
|
+
});
|
|
2703
|
+
const end = (e) => {
|
|
2704
|
+
if (startX == null) return;
|
|
2705
|
+
const dx = e.clientX - startX; startX = null; t.el.dataset.swiping = "false";
|
|
2706
|
+
if (Math.abs(dx) > 80) dismiss(t.id);
|
|
2707
|
+
else { t.el.style.removeProperty("--swipe-x"); if (!expanded) resume(t); }
|
|
2708
|
+
};
|
|
2709
|
+
t.el.addEventListener("pointerup", end);
|
|
2710
|
+
t.el.addEventListener("pointercancel", end);
|
|
2711
|
+
}
|
|
2712
|
+
function create(opts) {
|
|
2713
|
+
const type = opts.type || "default";
|
|
2714
|
+
const id = opts.id != null ? opts.id : counter++;
|
|
2715
|
+
const duration = opts.duration != null ? opts.duration : DURATION[type];
|
|
2716
|
+
const existing = items.find((x) => x.id === id && !x.removed);
|
|
2717
|
+
if (existing) { // update in place (e.g. loading -> success)
|
|
2718
|
+
build(existing, type, opts.title, opts.description);
|
|
2719
|
+
existing.duration = duration; existing.remaining = duration; existing.startedAt = Date.now();
|
|
2720
|
+
if (existing.timer) { clearTimeout(existing.timer); existing.timer = null; }
|
|
2721
|
+
if (!expanded) resume(existing);
|
|
2722
|
+
place();
|
|
2723
|
+
return id;
|
|
2724
|
+
}
|
|
2725
|
+
mount();
|
|
2726
|
+
const el = document.createElement("li");
|
|
2727
|
+
el.className = "etoast"; el.tabIndex = 0;
|
|
2728
|
+
const t = { id, el, type, duration, remaining: duration, startedAt: Date.now(), timer: null, removed: false };
|
|
2729
|
+
build(t, type, opts.title, opts.description);
|
|
2730
|
+
wireSwipe(t);
|
|
2731
|
+
items.unshift(t);
|
|
2732
|
+
list.prepend(el);
|
|
2733
|
+
place();
|
|
2734
|
+
requestAnimationFrame(() => { el.dataset.mounted = "true"; place(); if (!expanded) resume(t); });
|
|
2735
|
+
return id;
|
|
2736
|
+
}
|
|
2737
|
+
// Back-compat: a string 2nd arg is the legacy kind ("ok"/"err"); an object is opts.
|
|
2738
|
+
const KIND = { ok: "success", err: "error" };
|
|
2739
|
+
const api = (msg, o) => create(typeof o === "string" ? { type: KIND[o] || "default", title: msg } : { ...o, type: "default", title: msg });
|
|
2740
|
+
["success", "error", "info", "warning", "loading", "message"].forEach((k) => {
|
|
2741
|
+
api[k] = (msg, o) => create({ ...o, type: k === "message" ? "default" : k, title: msg });
|
|
2742
|
+
});
|
|
2743
|
+
api.dismiss = (id) => { if (id == null) items.slice().forEach((t) => dismiss(t.id)); else dismiss(id); return id; };
|
|
2744
|
+
return api;
|
|
2745
|
+
})();
|
|
2746
|
+
|
|
2747
|
+
// Bridge the old inline-note API onto toasts so every existing call site upgrades at once.
|
|
2748
|
+
// A flow reuses ONE note target (e.g. "settingsNote") start to finish, so we key a single
|
|
2749
|
+
// toast per target: a progress message ("Saving...") opens a loading toast, and the ok/err
|
|
2750
|
+
// result morphs that same toast - no pile-up, no leftover green text. `restartNote`-style
|
|
2751
|
+
// long messages split into title + description at the first sentence break.
|
|
2752
|
+
const noteToastId = new Map();
|
|
2753
|
+
function noteKey(el) { return typeof el === "string" ? el : (el && el.id) || "__note__"; }
|
|
2754
|
+
function splitMsg(msg) {
|
|
2755
|
+
msg = String(msg);
|
|
2756
|
+
const m = msg.match(/^(.*?[.!])\s+(.+)$/s);
|
|
2757
|
+
return m && m[1].length <= 64 ? { title: m[1], description: m[2] } : { title: msg };
|
|
2758
|
+
}
|
|
2759
|
+
// Drop-in replacement for the former setNote(el, msg, kind). `el` is ignored for display
|
|
2760
|
+
// (kept for call compatibility); everything surfaces as a toast.
|
|
2431
2761
|
function setNote(el, msg, kind) {
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
if (
|
|
2762
|
+
const key = noteKey(el);
|
|
2763
|
+
const prev = noteToastId.get(key);
|
|
2764
|
+
if (msg == null || msg === "") { if (prev != null) { toast.dismiss(prev); noteToastId.delete(key); } return; }
|
|
2765
|
+
const isProgress = !kind && /(\.\.\.|…)\s*$/.test(String(msg));
|
|
2766
|
+
const type = kind === "ok" ? "success" : kind === "err" ? "error" : isProgress ? "loading" : "info";
|
|
2767
|
+
const parts = splitMsg(msg);
|
|
2768
|
+
const id = toast[type](parts.title, { id: prev, description: parts.description });
|
|
2769
|
+
if (isProgress) noteToastId.set(key, id);
|
|
2770
|
+
else noteToastId.delete(key);
|
|
2435
2771
|
}
|
|
2436
2772
|
|
|
2437
2773
|
// Capitalize a choice value for display ("on-demand" -> "On demand"), keeping the raw
|
|
@@ -2578,7 +2914,7 @@ export {
|
|
|
2578
2914
|
const want = isBool ? (value === true || value === 1) : String(value);
|
|
2579
2915
|
const prevBool = ctl.dataset.on === "1";
|
|
2580
2916
|
const prevChoice = ctl.dataset.prev != null ? ctl.dataset.prev : ctl.value; // <select> already changed natively
|
|
2581
|
-
const rollback = () => { if (isBool) applyBool(ctl, prevBool); else ctl.value = prevChoice; };
|
|
2917
|
+
const rollback = () => { if (isBool) applyBool(ctl, prevBool); else { ctl.value = prevChoice; syncDD(ctl); } };
|
|
2582
2918
|
if (isBool) applyBool(ctl, want);
|
|
2583
2919
|
setNote(el, "Saving " + key + "...");
|
|
2584
2920
|
try {
|
|
@@ -2587,7 +2923,7 @@ export {
|
|
|
2587
2923
|
if (!out.ok) { rollback(); setNote(el, "Could not change " + key + ": " + (out.error || "error"), "err"); return; }
|
|
2588
2924
|
const s = out.setting;
|
|
2589
2925
|
if (s && isBool) applyBool(ctl, s.value);
|
|
2590
|
-
else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; }
|
|
2926
|
+
else if (s) { ctl.value = s.choice; ctl.dataset.prev = s.choice; syncDD(ctl); }
|
|
2591
2927
|
if (s && (isBool ? s.value !== want : String(s.choice) !== want)) { setNote(el, key + " did not change (the write had no effect).", "err"); return; }
|
|
2592
2928
|
setNote(el, out.restartNote || ("Saved " + key + "."), "ok");
|
|
2593
2929
|
} catch { rollback(); setNote(el, "Could not reach the server to change " + key + ".", "err"); }
|
|
@@ -3361,25 +3697,27 @@ export {
|
|
|
3361
3697
|
v = v || {};
|
|
3362
3698
|
if (!v.available) {
|
|
3363
3699
|
$("recallBody").innerHTML = '<div class="panel"><div class="empty" style="padding:16px 0">Recall needs the enigma binary - open the dashboard via the enigma app.</div></div>';
|
|
3364
|
-
$("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
|
|
3700
|
+
$("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallAddPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
|
|
3365
3701
|
}
|
|
3366
3702
|
if (!v.enabled) {
|
|
3367
3703
|
$("recallBody").innerHTML = '<div class="panel"><div class="set-cat-h">Session memory is off</div>'
|
|
3368
3704
|
+ '<div class="set-hint" style="padding:8px 0">Build a searchable memory of your coding sessions from your own transcripts.</div>'
|
|
3369
|
-
+ '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><button type="button" class="toggle" data-recall-on="1">Enable recall</button>'
|
|
3370
|
-
+ '<span class="set-hint">or run <code>enigma config recall on</code></span></div></div>';
|
|
3371
|
-
$("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
|
|
3705
|
+
+ '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><button type="button" class="toggle" data-kind="bool" role="switch" aria-checked="false" data-recall-on="1" aria-label="Session memory" title="Turn session memory on">Enable recall</button>'
|
|
3706
|
+
+ '<span class="set-hint">Turn on, or run <code>enigma config recall on</code></span></div></div>';
|
|
3707
|
+
$("recallSearch").style.display = "none"; $("recallProviderPanel").style.display = "none"; $("recallAddPanel").style.display = "none"; $("recallList").innerHTML = ""; return;
|
|
3372
3708
|
}
|
|
3373
3709
|
const s = v.stats || {};
|
|
3374
3710
|
const types = Object.entries(s.byType || {}).map(([t, n]) => '<span class="tag">' + esc(t) + " " + n + "</span>").join(" ");
|
|
3375
3711
|
const last = v.lastSync ? "last sync " + new Date(v.lastSync).toLocaleString() : "not synced yet";
|
|
3376
3712
|
$("recallBody").innerHTML = '<div class="panel"><div style="display:flex;justify-content:space-between;align-items:center;gap:10px"><div class="set-cat-h" style="margin:0">Stored memory</div>'
|
|
3377
|
-
+ '<button type="button" class="toggle on" data-recall-on="0" title="Turn session memory off">Disable</button></div>'
|
|
3713
|
+
+ '<button type="button" class="toggle on" data-kind="bool" role="switch" aria-checked="true" data-recall-on="0" aria-label="Session memory" title="Turn session memory off">Disable</button></div>'
|
|
3378
3714
|
+ '<div class="set-hint" style="padding:6px 0">' + (s.observations || 0) + " observations · " + (s.sessions || 0) + " sessions · "
|
|
3379
3715
|
+ (s.projects || 0) + " projects · " + Math.round((s.dbBytes || 0) / 1024) + " KB · " + esc(last) + "</div>"
|
|
3380
3716
|
+ (types ? '<div style="padding-top:6px">' + types + "</div>" : "") + "</div>";
|
|
3381
3717
|
$("recallSearch").style.display = "";
|
|
3718
|
+
$("recallAddPanel").style.display = "";
|
|
3382
3719
|
if (!$("recallType").options.length) $("recallType").innerHTML = '<option value="">All types</option>' + RECALL_TYPES.map((t) => '<option value="' + t + '">' + t + "</option>").join("");
|
|
3720
|
+
if (!$("recallNewType").options.length) $("recallNewType").innerHTML = RECALL_TYPES.map((t) => '<option value="' + t + '">' + t + "</option>").join("");
|
|
3383
3721
|
const sig = (v.projects || []).join("|");
|
|
3384
3722
|
if (sig !== recallProjSig) {
|
|
3385
3723
|
const keep = $("recallProject").value;
|
|
@@ -3390,13 +3728,30 @@ export {
|
|
|
3390
3728
|
renderRecallProvider(v.provider);
|
|
3391
3729
|
renderRecallList(v.items);
|
|
3392
3730
|
}
|
|
3393
|
-
|
|
3731
|
+
// Provider picker shows the right logo + a human label (the stored value stays the
|
|
3732
|
+
// internal id). claude-local and anthropic are Anthropic models (Claude logo); openai
|
|
3733
|
+
// is any OpenAI-compatible endpoint (OpenAI/Codex logo).
|
|
3734
|
+
const RECALL_PROV_META = {
|
|
3735
|
+
"claude-local": { label: "Claude Code (local login)", icon: "claude" },
|
|
3736
|
+
"anthropic": { label: "Anthropic API", icon: "claude" },
|
|
3737
|
+
"openai": { label: "OpenAI-compatible", icon: "codex" },
|
|
3738
|
+
};
|
|
3739
|
+
let recallProvDD = null, recallProvVal = "claude-local";
|
|
3394
3740
|
function renderRecallProvider(p) {
|
|
3395
3741
|
if (!p) { $("recallProviderPanel").style.display = "none"; return; }
|
|
3396
3742
|
$("recallProviderPanel").style.display = "";
|
|
3397
|
-
|
|
3398
|
-
if (
|
|
3399
|
-
|
|
3743
|
+
recallProvVal = p.provider;
|
|
3744
|
+
if (!recallProvDD) {
|
|
3745
|
+
const entries = (p.providers || []).map((v) => {
|
|
3746
|
+
const m = RECALL_PROV_META[v] || { label: v, icon: "" };
|
|
3747
|
+
return { value: v, label: m.label, icon: agentSvg(m.icon) };
|
|
3748
|
+
});
|
|
3749
|
+
recallProvDD = iconDropdown("recallProvDD", entries, () => recallProvVal, (val) => {
|
|
3750
|
+
recallProvVal = val;
|
|
3751
|
+
$("recallKeyRow").style.display = val === "claude-local" ? "none" : "";
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
recallProvDD.render();
|
|
3400
3755
|
$("recallModel").value = p.model || "";
|
|
3401
3756
|
$("recallBase").value = p.base || "";
|
|
3402
3757
|
$("recallKey").placeholder = p.keyFromEnv ? "set via ENIGMA_RECALL_API_KEY" : (p.hasKey ? "set - leave blank to keep" : "API key");
|
|
@@ -3407,6 +3762,7 @@ export {
|
|
|
3407
3762
|
llmBtn.textContent = p.llm ? "on" : "off";
|
|
3408
3763
|
llmBtn.classList.toggle("on", !!p.llm);
|
|
3409
3764
|
llmBtn.dataset.on = p.llm ? "1" : "0";
|
|
3765
|
+
llmBtn.setAttribute("aria-checked", p.llm ? "true" : "false");
|
|
3410
3766
|
}
|
|
3411
3767
|
let recallItemsById = {};
|
|
3412
3768
|
function recallRowHtml(it, anchorId) {
|
|
@@ -3433,7 +3789,8 @@ export {
|
|
|
3433
3789
|
if (!items || !items.length) { $("recallList").innerHTML = '<div class="empty" style="padding:16px 0">No memories yet - run a sync, or adjust your search.</div>'; return; }
|
|
3434
3790
|
$("recallList").innerHTML = '<div class="panel">' + items.map((it) => {
|
|
3435
3791
|
if (it.id) recallItemsById[it.id] = it;
|
|
3436
|
-
|
|
3792
|
+
const del = it.id ? '<button type="button" class="btn-danger icon recall-del" title="Delete memory" aria-label="Delete this memory" data-del="' + it.id + '">' + TRASH_ICON + "</button>" : "";
|
|
3793
|
+
return '<div class="set-row" style="cursor:pointer" data-rid="' + (it.id || "") + '">' + recallRowHtml(it) + del + "</div>"
|
|
3437
3794
|
+ '<div id="rd-' + (it.id || "") + '" style="display:none"></div>';
|
|
3438
3795
|
}).join("") + "</div>";
|
|
3439
3796
|
}
|
|
@@ -3482,6 +3839,16 @@ export {
|
|
|
3482
3839
|
setNote("recallNote", out.ok ? "Saved." : (out.error || "Failed."), out.ok ? "ok" : "err");
|
|
3483
3840
|
} catch { setNote("recallNote", "Could not reach the server.", "err"); }
|
|
3484
3841
|
}
|
|
3842
|
+
// Generic recall action post (delete/create/generate); refreshes the view and returns ok.
|
|
3843
|
+
async function recallOpPost(body, msg, okMsg) {
|
|
3844
|
+
setNote("recallNote", msg || "Working...");
|
|
3845
|
+
try {
|
|
3846
|
+
const out = await (await fetch("/api/recall", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json();
|
|
3847
|
+
if (out.view) renderRecall(out.view);
|
|
3848
|
+
setNote("recallNote", out.ok ? (okMsg || "Done.") : (out.error || "Failed."), out.ok ? "ok" : "err");
|
|
3849
|
+
return out.ok;
|
|
3850
|
+
} catch { setNote("recallNote", "Could not reach the server.", "err"); return false; }
|
|
3851
|
+
}
|
|
3485
3852
|
function wireRecall() {
|
|
3486
3853
|
$("recallSync").addEventListener("click", () => recallAction("sync"));
|
|
3487
3854
|
$("recallClear").addEventListener("click", () => recallAction("clear", "Delete all stored session memory? This cannot be undone."));
|
|
@@ -3489,20 +3856,43 @@ export {
|
|
|
3489
3856
|
const t = e.target.closest("[data-recall-on]");
|
|
3490
3857
|
if (t) setRecallEnabled(t.dataset.recallOn === "1");
|
|
3491
3858
|
});
|
|
3492
|
-
// Provider panel:
|
|
3493
|
-
|
|
3859
|
+
// Provider panel: save provider; flip LLM curation. The provider picker is the
|
|
3860
|
+
// custom icon dropdown (recallProvVal), wired in renderRecallProvider.
|
|
3494
3861
|
$("recallProvSave").addEventListener("click", () => {
|
|
3495
|
-
const e = { provider:
|
|
3862
|
+
const e = { provider: recallProvVal, model: $("recallModel").value.trim(), base: $("recallBase").value.trim() };
|
|
3496
3863
|
if (!$("recallKey").disabled && $("recallKey").value) e.key = $("recallKey").value; // only send a key the user typed
|
|
3497
3864
|
recallProviderPost(e, "Saving provider...");
|
|
3498
3865
|
$("recallKey").value = "";
|
|
3499
3866
|
});
|
|
3500
3867
|
$("recallLlmBtn").addEventListener("click", () => recallProviderPost({ llm: $("recallLlmBtn").dataset.on !== "1" }, "Updating curation..."));
|
|
3868
|
+
// Add a memory: manual form, or generate one from a note via the configured model.
|
|
3869
|
+
$("recallCreate").addEventListener("click", async () => {
|
|
3870
|
+
const title = $("recallNewTitle").value.trim();
|
|
3871
|
+
if (!title) { setNote("recallNote", "A title is required.", "err"); return; }
|
|
3872
|
+
const facts = $("recallNewFacts").value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3873
|
+
const ok = await recallOpPost({
|
|
3874
|
+
op: "create", type: $("recallNewType").value, title, project: $("recallNewProject").value.trim(),
|
|
3875
|
+
narrative: $("recallNewNarrative").value.trim(), facts,
|
|
3876
|
+
}, "Saving memory...", "Memory saved.");
|
|
3877
|
+
if (ok) { $("recallNewTitle").value = ""; $("recallNewNarrative").value = ""; $("recallNewFacts").value = ""; }
|
|
3878
|
+
});
|
|
3879
|
+
$("recallGenerate").addEventListener("click", async () => {
|
|
3880
|
+
const prompt = $("recallGenNote").value.trim();
|
|
3881
|
+
if (!prompt) { setNote("recallNote", "Write a note to generate from.", "err"); return; }
|
|
3882
|
+
const ok = await recallOpPost({ op: "generate", prompt, project: $("recallNewProject").value.trim() }, "Generating memory...", "Memory generated.");
|
|
3883
|
+
if (ok) $("recallGenNote").value = "";
|
|
3884
|
+
});
|
|
3501
3885
|
let t; const deb = () => { clearTimeout(t); t = setTimeout(loadRecall, 250); };
|
|
3502
3886
|
$("recallQ").addEventListener("input", deb);
|
|
3503
3887
|
$("recallType").addEventListener("change", loadRecall);
|
|
3504
3888
|
$("recallProject").addEventListener("change", loadRecall);
|
|
3505
|
-
$("recallList").addEventListener("click", (e) => {
|
|
3889
|
+
$("recallList").addEventListener("click", async (e) => {
|
|
3890
|
+
const del = e.target.closest("[data-del]");
|
|
3891
|
+
if (del) {
|
|
3892
|
+
if (!(await askConfirm("Delete memory", "Delete this memory? This cannot be undone.", true))) return;
|
|
3893
|
+
recallOpPost({ op: "delete", id: Number(del.dataset.del) }, "Deleting...", "Memory deleted.");
|
|
3894
|
+
return;
|
|
3895
|
+
}
|
|
3506
3896
|
const tl = e.target.closest("[data-tl]");
|
|
3507
3897
|
if (tl) { loadRecallTimeline(tl.dataset.tl); return; }
|
|
3508
3898
|
const row = e.target.closest("[data-rid]");
|
|
@@ -3536,6 +3926,7 @@ export {
|
|
|
3536
3926
|
+ projectsCache.map((p) => '<option value="' + esc(p.path) + '">' + esc(p.label) + "</option>").join("")
|
|
3537
3927
|
+ '<option value="__create__">+ Create new project</option>';
|
|
3538
3928
|
sel.value = currentScope;
|
|
3929
|
+
syncDD(sel);
|
|
3539
3930
|
}
|
|
3540
3931
|
async function loadProjectsList() {
|
|
3541
3932
|
try { projectsCache = ((await (await fetch("/api/projects", { cache: "no-store" })).json()).projects) || []; }
|
|
@@ -3615,7 +4006,7 @@ export {
|
|
|
3615
4006
|
const sel = $("scopeSel");
|
|
3616
4007
|
if (sel) sel.addEventListener("change", () => {
|
|
3617
4008
|
const val = sel.value;
|
|
3618
|
-
if (val === "__create__") { sel.value = currentScope; openCreateProject(); return; }
|
|
4009
|
+
if (val === "__create__") { sel.value = currentScope; syncDD(sel); openCreateProject(); return; }
|
|
3619
4010
|
currentScope = val;
|
|
3620
4011
|
try { localStorage.setItem("enigmaScope", val); } catch { /* */ }
|
|
3621
4012
|
route();
|
|
@@ -3807,10 +4198,13 @@ export {
|
|
|
3807
4198
|
wireResources();
|
|
3808
4199
|
wireRecall();
|
|
3809
4200
|
wireScope();
|
|
4201
|
+
enhanceAll(); // upgrade every static <select> into the shared pretty dropdown
|
|
3810
4202
|
$("cfgExport").addEventListener("click", exportConfig);
|
|
3811
4203
|
$("cfgImport").addEventListener("click", importConfig);
|
|
3812
4204
|
$("updateBtn").addEventListener("click", runUpdate);
|
|
3813
4205
|
$("fixPathBtn").addEventListener("click", runFixPath);
|
|
4206
|
+
$("updateAlertBtn").addEventListener("click", runFullUpdate);
|
|
4207
|
+
$("updateAlertDismiss").addEventListener("click", () => { dismissedUpdateSig = $("updateAlert").dataset.sig || ""; $("updateAlert").hidden = true; });
|
|
3814
4208
|
$("refreshBtn").addEventListener("click", async () => {
|
|
3815
4209
|
const b = $("refreshBtn");
|
|
3816
4210
|
if (b.classList.contains("spinning")) return; // already refreshing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/dashboard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.63",
|
|
4
4
|
"description": "Local browser dashboard UI for enigma: the static page and chart assets enigma serves on its loopback dashboard (savings, real tool usage, in-browser settings). Installed on demand by enigma-cli; not a runtime dependency.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|