shadok-ai 0.11.5 → 0.11.7
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 +7 -5
- package/package.json +1 -1
- package/public/index.html +246 -17
package/README.md
CHANGED
|
@@ -92,11 +92,13 @@ Enter to skip; you can add it later from the web UI).
|
|
|
92
92
|
and three go-to-market roles (paid, organic, support). Each one is yours to
|
|
93
93
|
edit, and a role you delete stays deleted.
|
|
94
94
|
- **Secret vault** — stored under `~/.shadok-ai`, never in your repo, injected
|
|
95
|
-
as env vars into the agents that need them.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
as env vars into the agents that need them. Add or **update** a secret from the
|
|
96
|
+
panel, tick the **profiles** it should feed right there, and reload the running
|
|
97
|
+
agents on those profiles in one click so a rotated value takes effect now. An
|
|
98
|
+
agent that **obtains** a credential (a CLI login, a key it just provisioned)
|
|
99
|
+
can add it to the vault itself so the next agent doesn't start from nothing —
|
|
100
|
+
write-only, the value piped in rather than typed as an argument, and never
|
|
101
|
+
overwriting an existing name without you saying so.
|
|
100
102
|
- **Scheduled prompts** — give a channel a recurring prompt (every N minutes, or
|
|
101
103
|
daily at HH:MM in a time zone you choose) for monitoring and reporting. Each
|
|
102
104
|
schedule can carry a **deterministic guard command** that runs *without the
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -759,6 +759,27 @@
|
|
|
759
759
|
}
|
|
760
760
|
.turn { display: flex; flex-direction: column; gap: 3px; max-width: 82%; margin-top: 8px; }
|
|
761
761
|
.turn:first-child { margin-top: 0; }
|
|
762
|
+
/* "Jump to the latest": floats bottom-right of the chat, above the composer
|
|
763
|
+
(its `bottom` is set in JS to the composer's height). Hidden at the bottom. */
|
|
764
|
+
#jumpBottom {
|
|
765
|
+
position: absolute; right: 18px; z-index: 6;
|
|
766
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
767
|
+
font: inherit; font-size: 12px; line-height: 1; padding: 7px 12px;
|
|
768
|
+
border-radius: 16px; cursor: pointer;
|
|
769
|
+
background: var(--bg-raised); color: var(--text); border: 1px solid var(--line);
|
|
770
|
+
box-shadow: 0 3px 12px rgba(0,0,0,.45);
|
|
771
|
+
}
|
|
772
|
+
#jumpBottom:hover { border-color: var(--amber); color: var(--amber); }
|
|
773
|
+
#jumpBottom svg { display: block; }
|
|
774
|
+
/* Divider between messages already seen and the ones that arrived since. */
|
|
775
|
+
.unread-sep {
|
|
776
|
+
display: flex; align-items: center; gap: 10px; margin: 10px 2px 4px;
|
|
777
|
+
color: var(--amber); font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
|
|
778
|
+
}
|
|
779
|
+
.unread-sep::before, .unread-sep::after {
|
|
780
|
+
content: ""; flex: 1; height: 1px;
|
|
781
|
+
background: color-mix(in srgb, var(--amber) 45%, transparent);
|
|
782
|
+
}
|
|
762
783
|
/* Copy: always visible, never on hover — a phone has no hover, and a button
|
|
763
784
|
you cannot reveal does not exist. */
|
|
764
785
|
.turn .copy {
|
|
@@ -1476,7 +1497,11 @@
|
|
|
1476
1497
|
#secretsList li { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-radius: 6px; }
|
|
1477
1498
|
#secretsList li:nth-child(odd) { background: rgba(255,255,255,.04); }
|
|
1478
1499
|
#secretsList code { font-size: 13px; }
|
|
1479
|
-
#secretsList
|
|
1500
|
+
#secretsList .s-actions { display: flex; gap: 2px; flex: none; }
|
|
1501
|
+
#secretsList button { background: none; border: none; cursor: pointer; font-size: 14px; padding: 0 4px; }
|
|
1502
|
+
#secretsList .s-del { color: #e26; }
|
|
1503
|
+
#secretsList .s-edit { color: var(--text-dim); }
|
|
1504
|
+
#secretsList .s-edit:hover { color: var(--amber); }
|
|
1480
1505
|
/* Le panneau Users n'avait AUCUN style : d'où les puces brutes et le nom, le
|
|
1481
1506
|
rôle et les boutons empilés les uns sous les autres. Même traitement que
|
|
1482
1507
|
#profilesList, dont il est le frère. */
|
|
@@ -1497,9 +1522,21 @@
|
|
|
1497
1522
|
#inviteBox .ib-url { margin-top: 6px; font-family: var(--mono); font-size: 12px; line-height: 1.45; word-break: break-all; user-select: all; }
|
|
1498
1523
|
#inviteBox button { background: none; border: 1px solid rgba(255,255,255,.15); border-radius: 5px; color: inherit; cursor: pointer; font-size: 12px; padding: 2px 8px; flex: none; }
|
|
1499
1524
|
#inviteBox button:hover { border-color: var(--amber); color: var(--amber); }
|
|
1500
|
-
#secretsAdd { display: flex; gap: 8px; }
|
|
1501
|
-
#secretsAdd
|
|
1502
|
-
#secretsAdd
|
|
1525
|
+
#secretsAdd { display: flex; flex-direction: column; gap: 8px; }
|
|
1526
|
+
#secretsAdd .sec-add-row { display: flex; gap: 8px; }
|
|
1527
|
+
#secretsAdd input { flex: 1; min-width: 0; padding: 6px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(0,0,0,.25); color: inherit; font-size: 16px; }
|
|
1528
|
+
#secretsAdd #secretSubmit { flex: none; padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.08); color: inherit; cursor: pointer; }
|
|
1529
|
+
#secretKey[readonly] { opacity: .65; }
|
|
1530
|
+
.sec-attach { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
|
|
1531
|
+
.sec-attach-label { font-size: 12px; color: var(--text-dim); }
|
|
1532
|
+
.sec-profiles { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
1533
|
+
.sec-profiles label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; padding: 3px 9px; border: 1px solid var(--line); border-radius: 14px; cursor: pointer; }
|
|
1534
|
+
.sec-profiles label:hover { border-color: var(--amber); }
|
|
1535
|
+
.sec-profiles input { accent-color: var(--amber); margin: 0; }
|
|
1536
|
+
.sec-reload { margin-top: 10px; border: 1px solid var(--amber); border-radius: 8px; padding: 9px 11px; background: color-mix(in srgb, var(--amber) 8%, transparent); font-size: 12.5px; display: flex; align-items: center; gap: 10px; }
|
|
1537
|
+
.sec-reload span { flex: 1; }
|
|
1538
|
+
.sec-reload button { flex: none; padding: 5px 11px; border-radius: 6px; border: 1px solid var(--amber); background: none; color: var(--amber); cursor: pointer; font: inherit; font-size: 12px; }
|
|
1539
|
+
.sec-reload button:hover { background: color-mix(in srgb, var(--amber) 15%, transparent); }
|
|
1503
1540
|
</style>
|
|
1504
1541
|
</head>
|
|
1505
1542
|
<body>
|
|
@@ -1609,10 +1646,18 @@
|
|
|
1609
1646
|
<div class="pin-body">
|
|
1610
1647
|
<ul id="secretsList"></ul>
|
|
1611
1648
|
<form id="secretsAdd">
|
|
1612
|
-
<
|
|
1613
|
-
|
|
1614
|
-
|
|
1649
|
+
<div class="sec-add-row">
|
|
1650
|
+
<input id="secretKey" placeholder="NAME" autocomplete="off" spellcheck="false" />
|
|
1651
|
+
<input id="secretVal" placeholder="value" autocomplete="off" spellcheck="false" />
|
|
1652
|
+
<button type="submit" id="secretSubmit">Add</button>
|
|
1653
|
+
</div>
|
|
1654
|
+
<div id="secretAttach" class="sec-attach" hidden>
|
|
1655
|
+
<span class="sec-attach-label">Inject into profiles:</span>
|
|
1656
|
+
<div id="secretProfiles" class="sec-profiles"></div>
|
|
1657
|
+
<button type="button" id="secretEditCancel" class="linkish" hidden>cancel edit</button>
|
|
1658
|
+
</div>
|
|
1615
1659
|
</form>
|
|
1660
|
+
<div id="secretsReload" class="sec-reload" hidden></div>
|
|
1616
1661
|
</div>
|
|
1617
1662
|
</div>
|
|
1618
1663
|
</div>
|
|
@@ -1979,6 +2024,13 @@
|
|
|
1979
2024
|
with the favicon, `shadokInner`); it pumps via CSS. -->
|
|
1980
2025
|
<div id="thinking"><span class="tw-text">Shadok is pumping</span><span class="tw-shadok" aria-hidden="true"></span></div>
|
|
1981
2026
|
|
|
2027
|
+
<!-- Floating "jump to the latest": shown only when the active channel is
|
|
2028
|
+
scrolled up. Its bottom offset tracks the composer's height (JS). -->
|
|
2029
|
+
<button id="jumpBottom" type="button" hidden aria-label="Jump to the latest">
|
|
2030
|
+
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M12 5v13M6 12l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
2031
|
+
Latest
|
|
2032
|
+
</button>
|
|
2033
|
+
|
|
1982
2034
|
<form id="composer" hidden>
|
|
1983
2035
|
<textarea id="promptInput" rows="1"
|
|
1984
2036
|
placeholder="Write your prompt — Enter to send, Shift+Enter for a new line"></textarea>
|
|
@@ -2253,7 +2305,12 @@
|
|
|
2253
2305
|
// (no more stolen scroll during live streaming). See pinBottom().
|
|
2254
2306
|
el._stickBottom = true;
|
|
2255
2307
|
el.addEventListener("scroll", () => {
|
|
2256
|
-
|
|
2308
|
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
2309
|
+
el._stickBottom = atBottom;
|
|
2310
|
+
if (tab === active) {
|
|
2311
|
+
if (atBottom) markCaughtUp(tab); // scrolled down to the end → all seen
|
|
2312
|
+
updateJumpButton();
|
|
2313
|
+
}
|
|
2257
2314
|
});
|
|
2258
2315
|
|
|
2259
2316
|
const btn = document.createElement("div");
|
|
@@ -2331,6 +2388,7 @@
|
|
|
2331
2388
|
contextPct: null,
|
|
2332
2389
|
muted: false, // no favicon pip, no title badge/blink, no chime
|
|
2333
2390
|
transcriptEl: el,
|
|
2391
|
+
unreadSep: null, // the "new messages" separator element, when present
|
|
2334
2392
|
profile: null, // the agent's "type": the profile applied at spawn
|
|
2335
2393
|
crons: [], // labels of the scheduled prompts (the ⏰ on the tab)
|
|
2336
2394
|
btnEl: btn, ledEl: led, nameEl: name, timerEl: timer,
|
|
@@ -3304,7 +3362,17 @@
|
|
|
3304
3362
|
// Experimental terminal follows the active channel (re-seeds on switch).
|
|
3305
3363
|
if (typeof syncTerm === "function") syncTerm();
|
|
3306
3364
|
if (tab.status !== "setup") {
|
|
3307
|
-
|
|
3365
|
+
// Resume at the "new" separator when this channel has unread messages
|
|
3366
|
+
// (you left off there), otherwise at the latest. Either way the composer
|
|
3367
|
+
// clears the mood on activate; the separator lingers until you read down.
|
|
3368
|
+
if (tab.unreadSep) {
|
|
3369
|
+
tab.unreadSep.scrollIntoView({ block: "start" });
|
|
3370
|
+
tab.transcriptEl._stickBottom = false;
|
|
3371
|
+
} else {
|
|
3372
|
+
tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
|
|
3373
|
+
tab.transcriptEl._stickBottom = true;
|
|
3374
|
+
}
|
|
3375
|
+
updateJumpButton();
|
|
3308
3376
|
promptInput.focus();
|
|
3309
3377
|
}
|
|
3310
3378
|
}
|
|
@@ -3796,6 +3864,53 @@
|
|
|
3796
3864
|
if (tab === active && el._stickBottom !== false) el.scrollTop = el.scrollHeight;
|
|
3797
3865
|
}
|
|
3798
3866
|
|
|
3867
|
+
// ── "Jump to the latest" + the unread separator ─────────────────────────
|
|
3868
|
+
// A channel is "caught up" when you're viewing it AND at its bottom. Anything
|
|
3869
|
+
// that arrives otherwise (another channel in the background, or this one while
|
|
3870
|
+
// you scrolled up) is unread: a separator is dropped before the first such
|
|
3871
|
+
// message, and the floating button offers a one-click way down.
|
|
3872
|
+
const jumpBottom = $("jumpBottom");
|
|
3873
|
+
function isCaughtUp(tab) {
|
|
3874
|
+
return tab === active && tab.transcriptEl._stickBottom !== false;
|
|
3875
|
+
}
|
|
3876
|
+
/** Drop a "new" separator before `turnEl` when the channel isn't caught up and
|
|
3877
|
+
* doesn't already carry one. */
|
|
3878
|
+
function markUnreadBoundary(tab, turnEl) {
|
|
3879
|
+
if (tab.unreadSep || isCaughtUp(tab) || !turnEl) return;
|
|
3880
|
+
const sep = document.createElement("div");
|
|
3881
|
+
sep.className = "unread-sep";
|
|
3882
|
+
sep.setAttribute("role", "separator");
|
|
3883
|
+
const label = document.createElement("span");
|
|
3884
|
+
label.textContent = "new";
|
|
3885
|
+
sep.appendChild(label);
|
|
3886
|
+
tab.transcriptEl.insertBefore(sep, turnEl);
|
|
3887
|
+
tab.unreadSep = sep;
|
|
3888
|
+
}
|
|
3889
|
+
/** Reached the bottom of a channel → its new messages are now seen. */
|
|
3890
|
+
function markCaughtUp(tab) {
|
|
3891
|
+
if (tab.unreadSep) { tab.unreadSep.remove(); tab.unreadSep = null; }
|
|
3892
|
+
if (tab.btnEl.classList.contains("unread")) setTabMood(tab, null);
|
|
3893
|
+
}
|
|
3894
|
+
/** Show the button only while the active channel is scrolled up; keep it clear
|
|
3895
|
+
* of the composer. */
|
|
3896
|
+
function updateJumpButton() {
|
|
3897
|
+
const el = active && active.transcriptEl;
|
|
3898
|
+
const composerH = $("composer").hidden ? 12 : $("composer").offsetHeight + 12;
|
|
3899
|
+
jumpBottom.style.bottom = composerH + "px";
|
|
3900
|
+
const up = !!el && !$("composer").hidden && el._stickBottom === false && el.scrollHeight - el.clientHeight > 60;
|
|
3901
|
+
jumpBottom.hidden = !up;
|
|
3902
|
+
}
|
|
3903
|
+
function goToLatest() {
|
|
3904
|
+
if (!active) return;
|
|
3905
|
+
const el = active.transcriptEl;
|
|
3906
|
+
el.scrollTop = el.scrollHeight;
|
|
3907
|
+
el._stickBottom = true;
|
|
3908
|
+
markCaughtUp(active);
|
|
3909
|
+
updateJumpButton();
|
|
3910
|
+
}
|
|
3911
|
+
jumpBottom.addEventListener("click", goToLatest);
|
|
3912
|
+
window.addEventListener("resize", updateJumpButton);
|
|
3913
|
+
|
|
3799
3914
|
/* A message's date/time. Today → the time alone ("21:57"). Another day → date
|
|
3800
3915
|
+ time ("3 August, 21:57"), with the year when it differs — otherwise a
|
|
3801
3916
|
resumed history spanning several days just showed "21:57", which misleads.
|
|
@@ -4817,6 +4932,7 @@
|
|
|
4817
4932
|
// reconnect (laptop wake / dropped link) rebuilds the transcript instead
|
|
4818
4933
|
// of appending a second copy of the whole history.
|
|
4819
4934
|
t.transcriptEl.innerHTML = "";
|
|
4935
|
+
t.unreadSep = null; // the separator element went with the wipe
|
|
4820
4936
|
addTurn(t, "system", "", "— resumed session history —");
|
|
4821
4937
|
for (const h of msg.turns) {
|
|
4822
4938
|
if (h.role === "user") {
|
|
@@ -4924,7 +5040,11 @@
|
|
|
4924
5040
|
// who typed). Falling back to the origin still beats "elsewhere": a
|
|
4925
5041
|
// scheduled run answering on its own has an explanation this way.
|
|
4926
5042
|
closeActivity(t);
|
|
4927
|
-
|
|
5043
|
+
{
|
|
5044
|
+
const b = addTurn(t, "user", echoAuthorLabel(msg), msg.text, null, Date.now());
|
|
5045
|
+
markUnreadBoundary(t, b.parentElement);
|
|
5046
|
+
}
|
|
5047
|
+
if (t === active) updateJumpButton();
|
|
4928
5048
|
break;
|
|
4929
5049
|
case "profile":
|
|
4930
5050
|
// The desired / in-force pair. Broadcast to EVERY client: changing the
|
|
@@ -4972,7 +5092,9 @@
|
|
|
4972
5092
|
// gluing the two bubbles into one wordless run (the CSS group rule
|
|
4973
5093
|
// spares `.after-gap`).
|
|
4974
5094
|
if (msg.afterInternal) b.parentElement.classList.add("after-gap");
|
|
5095
|
+
markUnreadBoundary(t, b.parentElement);
|
|
4975
5096
|
}
|
|
5097
|
+
if (t === active) updateJumpButton();
|
|
4976
5098
|
break;
|
|
4977
5099
|
case "stream-tool":
|
|
4978
5100
|
// A tool call, grouped into the open activity block.
|
|
@@ -5422,6 +5544,7 @@
|
|
|
5422
5544
|
function autosizePrompt() {
|
|
5423
5545
|
promptInput.style.height = "auto";
|
|
5424
5546
|
promptInput.style.height = Math.min(promptInput.scrollHeight, 160) + "px";
|
|
5547
|
+
updateJumpButton(); // the composer grew/shrank — keep the button above it
|
|
5425
5548
|
}
|
|
5426
5549
|
promptInput.addEventListener("input", () => {
|
|
5427
5550
|
autosizePrompt();
|
|
@@ -5743,24 +5866,122 @@
|
|
|
5743
5866
|
}
|
|
5744
5867
|
code.appendChild(tag);
|
|
5745
5868
|
}
|
|
5869
|
+
const actions = document.createElement("div");
|
|
5870
|
+
actions.className = "s-actions";
|
|
5871
|
+
const edit = document.createElement("button");
|
|
5872
|
+
edit.type = "button"; edit.className = "s-edit"; edit.textContent = "✎";
|
|
5873
|
+
edit.title = "Update this secret's value / the profiles it feeds";
|
|
5874
|
+
edit.addEventListener("click", () => startEditSecret(k));
|
|
5746
5875
|
const del = document.createElement("button");
|
|
5747
|
-
del.textContent = "✕"; del.title = "Remove from vault";
|
|
5876
|
+
del.type = "button"; del.className = "s-del"; del.textContent = "✕"; del.title = "Remove from vault";
|
|
5748
5877
|
del.addEventListener("click", async () => {
|
|
5749
5878
|
await fetch("/secrets?name=" + encodeURIComponent(k), { method: "DELETE" });
|
|
5879
|
+
if (editingSecret === k) resetSecretForm();
|
|
5750
5880
|
await loadVault(); renderSecrets();
|
|
5751
5881
|
});
|
|
5752
|
-
|
|
5882
|
+
actions.append(edit, del);
|
|
5883
|
+
li.append(code, actions);
|
|
5753
5884
|
ul.appendChild(li);
|
|
5754
5885
|
}
|
|
5755
5886
|
}
|
|
5887
|
+
|
|
5888
|
+
// ── Add / update a secret, and wire it to profiles ──────────────────────
|
|
5889
|
+
// The vault add form doubles as an editor: ✎ on a row loads the name (locked)
|
|
5890
|
+
// and the profiles it already feeds, so you can rotate the value or change
|
|
5891
|
+
// which profiles get it in one place.
|
|
5892
|
+
let editingSecret = null;
|
|
5893
|
+
/** Paint a checkbox per (non-managed) profile; `checked` is the set to tick. */
|
|
5894
|
+
function renderSecretProfiles(checked) {
|
|
5895
|
+
const box = $("secretProfiles");
|
|
5896
|
+
box.replaceChildren();
|
|
5897
|
+
const names = profileCache
|
|
5898
|
+
.map((p) => p.name)
|
|
5899
|
+
.filter((n) => !(window.isManagedProfile && window.isManagedProfile(n)))
|
|
5900
|
+
.sort();
|
|
5901
|
+
$("secretAttach").hidden = names.length === 0;
|
|
5902
|
+
for (const n of names) {
|
|
5903
|
+
const label = document.createElement("label");
|
|
5904
|
+
const cb = document.createElement("input");
|
|
5905
|
+
cb.type = "checkbox"; cb.value = n; cb.checked = checked.has(n);
|
|
5906
|
+
const span = document.createElement("span");
|
|
5907
|
+
span.textContent = n;
|
|
5908
|
+
label.append(cb, span);
|
|
5909
|
+
box.appendChild(label);
|
|
5910
|
+
}
|
|
5911
|
+
}
|
|
5912
|
+
function checkedProfiles() {
|
|
5913
|
+
return [...$("secretProfiles").querySelectorAll("input:checked")].map((c) => c.value);
|
|
5914
|
+
}
|
|
5915
|
+
function resetSecretForm() {
|
|
5916
|
+
editingSecret = null;
|
|
5917
|
+
$("secretKey").value = ""; $("secretKey").readOnly = false;
|
|
5918
|
+
$("secretVal").value = ""; $("secretVal").placeholder = "value";
|
|
5919
|
+
$("secretSubmit").textContent = "Add";
|
|
5920
|
+
$("secretEditCancel").hidden = true;
|
|
5921
|
+
renderSecretProfiles(new Set());
|
|
5922
|
+
}
|
|
5923
|
+
function startEditSecret(name) {
|
|
5924
|
+
editingSecret = name;
|
|
5925
|
+
$("secretKey").value = name; $("secretKey").readOnly = true;
|
|
5926
|
+
$("secretVal").value = ""; $("secretVal").placeholder = "new value (blank = keep)";
|
|
5927
|
+
$("secretSubmit").textContent = "Update";
|
|
5928
|
+
$("secretEditCancel").hidden = false;
|
|
5929
|
+
const users = window.secretUsers ? window.secretUsers(profileCache, name) : [];
|
|
5930
|
+
renderSecretProfiles(new Set(users));
|
|
5931
|
+
$("secretVal").focus();
|
|
5932
|
+
}
|
|
5933
|
+
/** Write the checkbox choices back onto every affected profile (add where now
|
|
5934
|
+
* ticked, remove where now unticked), preserving each profile's other fields —
|
|
5935
|
+
* crucially its systemPrompt, or omitting it would silently un-edit the role. */
|
|
5936
|
+
async function syncSecretToProfiles(name, wanted) {
|
|
5937
|
+
const want = new Set(wanted);
|
|
5938
|
+
const changed = [];
|
|
5939
|
+
for (const p of profileCache) {
|
|
5940
|
+
if (window.isManagedProfile && window.isManagedProfile(p.name)) continue;
|
|
5941
|
+
const has = (p.secrets || []).includes(name);
|
|
5942
|
+
if (want.has(p.name) === has) continue; // no change for this profile
|
|
5943
|
+
const secrets = want.has(p.name)
|
|
5944
|
+
? [...(p.secrets || []), name]
|
|
5945
|
+
: (p.secrets || []).filter((s) => s !== name);
|
|
5946
|
+
const body = { name: p.name, deny: p.deny, allow: p.allow, model: p.model, secrets };
|
|
5947
|
+
if (typeof p.systemPrompt === "string") body.systemPrompt = p.systemPrompt; // keep an edited prompt
|
|
5948
|
+
await fetch("/profiles", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
5949
|
+
changed.push(p.name);
|
|
5950
|
+
}
|
|
5951
|
+
if (changed.length) await loadProfilesInto();
|
|
5952
|
+
return changed;
|
|
5953
|
+
}
|
|
5954
|
+
/** Offer to reload the LIVE agents whose profile now feeds this secret — the
|
|
5955
|
+
* value only reaches a process at spawn, so a running one needs a respawn. */
|
|
5956
|
+
function offerReload(secretName) {
|
|
5957
|
+
const users = new Set(window.secretUsers ? window.secretUsers(profileCache, secretName) : []);
|
|
5958
|
+
// Only LIVE agents: a stopped one will get the value when it next launches.
|
|
5959
|
+
const agents = tabs.filter((t) => users.has(t.profile) && t.ws && t.ws.readyState === WebSocket.OPEN);
|
|
5960
|
+
const box = $("secretsReload");
|
|
5961
|
+
box.replaceChildren();
|
|
5962
|
+
if (!agents.length) { box.hidden = true; return; }
|
|
5963
|
+
const txt = document.createElement("span");
|
|
5964
|
+
txt.textContent =
|
|
5965
|
+
agents.length === 1
|
|
5966
|
+
? `1 running agent (${agents[0].nameEl.textContent}) uses “${secretName}”. Reload it to pick up the new value?`
|
|
5967
|
+
: `${agents.length} running agents use “${secretName}”. Reload them to pick up the new value?`;
|
|
5968
|
+
const go = document.createElement("button");
|
|
5969
|
+
go.type = "button"; go.textContent = "Reload " + (agents.length === 1 ? "it" : "all");
|
|
5970
|
+
go.addEventListener("click", () => { agents.forEach(reloadTab); box.hidden = true; });
|
|
5971
|
+
box.append(txt, go);
|
|
5972
|
+
box.hidden = false;
|
|
5973
|
+
}
|
|
5756
5974
|
// profileCache feeds the "no profile uses it" warning: it must be fresh when
|
|
5757
5975
|
// the panel opens, or we would show a stale diagnosis.
|
|
5758
5976
|
$("secretsBtn").addEventListener("click", async () => {
|
|
5759
5977
|
await Promise.all([loadVault(), loadProfilesInto()]);
|
|
5760
5978
|
renderSecrets();
|
|
5979
|
+
resetSecretForm();
|
|
5980
|
+
$("secretsReload").hidden = true;
|
|
5761
5981
|
$("secretsOverlay").hidden = false;
|
|
5762
5982
|
$("secretKey").focus();
|
|
5763
5983
|
});
|
|
5984
|
+
$("secretEditCancel").addEventListener("click", resetSecretForm);
|
|
5764
5985
|
// The missing link: from the secrets, go and attach them to a profile.
|
|
5765
5986
|
$("secretsToProfiles").addEventListener("click", () => {
|
|
5766
5987
|
$("secretsOverlay").hidden = true;
|
|
@@ -5825,13 +6046,21 @@
|
|
|
5825
6046
|
e.preventDefault();
|
|
5826
6047
|
const name = $("secretKey").value.trim();
|
|
5827
6048
|
const value = $("secretVal").value;
|
|
6049
|
+
const wanted = checkedProfiles();
|
|
5828
6050
|
if (!name) return;
|
|
5829
|
-
//
|
|
5830
|
-
//
|
|
5831
|
-
//
|
|
5832
|
-
|
|
5833
|
-
|
|
6051
|
+
// Editing an existing secret with the value left BLANK means "keep the value,
|
|
6052
|
+
// just change the profiles" — the vault is write-only, so we must not clobber
|
|
6053
|
+
// it with "". A new secret, or a value typed, is written (overwrite: true
|
|
6054
|
+
// because a human at the vault is acting deliberately; the flag only stops an
|
|
6055
|
+
// AGENT replacing a credential silently).
|
|
6056
|
+
const keepValue = editingSecret && value === "";
|
|
6057
|
+
if (!keepValue) {
|
|
6058
|
+
await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, value, overwrite: true }) });
|
|
6059
|
+
}
|
|
6060
|
+
await syncSecretToProfiles(name, wanted); // refreshes profileCache
|
|
5834
6061
|
await loadVault(); renderSecrets();
|
|
6062
|
+
offerReload(name); // propose reloading the live agents that now feed on it
|
|
6063
|
+
resetSecretForm();
|
|
5835
6064
|
$("secretKey").focus();
|
|
5836
6065
|
});
|
|
5837
6066
|
// Parse a pasted .env-style block → [{name, value}]. Handles `export
|