Add the Bot settings group with limit, usage bar, and clean modal

This commit is contained in:
Fisto
2026-09-16 08:50:53 +02:00
parent 8f3f153c55
commit af07facbb5
6 changed files with 314 additions and 5 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ mobile draft at 390px.
- [x] C5 daemon wiring: `scrape media` command, MediaService, `/api/media/*`
- [x] C6 Media and Bot tabs in the Web UI
- [x] C7a storage limits, retention, warning/confirm flow, storage and clean API
- [ ] C7b Settings group: limit field with info hover, usage bar, clean modal
- [x] C7b Settings group: limit field with info hover, usage bar, clean modal
- [ ] C8 MCP service hooks
- [ ] C9 live verification, tuning, docs
- Flextalk voice profile, end to end. The profile window is a movable panel that recenters on each
+4 -3
View File
@@ -1,8 +1,8 @@
# Media archive
Status: design approved 2026-09-16. The engine, the slopbot backend, the `scrape media` command,
the `/api/media/*` routes, and storage limits with retention are implemented (C3-C7a). The Media
and Bot tabs are C6; the Settings group is C7b. Checkpoints live in [CURRENT.md](../CURRENT.md).
the `/api/media/*` routes, storage limits with retention, and the Settings group are implemented
(C3-C7b). The Media and Bot tabs are C6. Checkpoints live in [CURRENT.md](../CURRENT.md).
The media archive saves the media history of a channel. A trusted user runs `scrape media` in the
channel. The daemon walks the history through a scrape backend and indexes every image, GIF,
@@ -125,7 +125,8 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
database overhead.
- Settings gains one collapsible group. Its name is **Bot** while the bot path is active, and
**Media** if the bot path is removed. It holds the limit field with the info tooltip, the usage
bar, and the clean button.
bar (segmented by image, GIF, video, audio, and other), and the clean button with its 5 second
countdown.
- Mobile is out of scope for now.
## MCP
+3
View File
@@ -171,6 +171,9 @@ The Settings button opens a slide-over:
the bot's devices. Clicking **Client audio** in the header does the same: it asks for the
permission, unlocks the audio context in the same click, and then starts listening. A second
click stops it.
- **Bot** — slopbot connection state, the media storage limit in GiB with an info tooltip (5% overrun
and the warning rule), the usage bar segmented by image, GIF, video, audio, and other, and
**Clean media…** with a 5 second confirmation countdown. See [media.md](media.md).
- **Server** — a collapsible group with:
- **Core process** — state, last exit, output, Start / Stop / Restart.
- **Account login** — token file state, QR login, captcha.
+43
View File
@@ -294,6 +294,49 @@
<div id="guild-visibility" class="vk-settings-card"></div>
</section>
<details class="vk-settings-group" open>
<summary class="vk-settings-summary">
<span class="vk-icon vk-caret" data-icon="caretDown"></span>
<span>Bot</span>
</summary>
<div class="vk-settings-group-body">
<section class="vk-settings-section">
<div class="vk-section-head">
<span class="vk-rail-label vk-rail-label-flat">Media storage</span>
<span class="vk-settings-actions">
<button id="media-save" class="btn btn-primary vk-small-btn" type="button" disabled>Save</button>
<button id="media-clean" class="btn btn-secondary vk-small-btn" type="button">Clean media…</button>
</span>
</div>
<div class="vk-settings-card">
<div class="vk-status-row">
<span class="vk-status-label">Slopbot</span>
<span class="vk-status-value" id="media-slopbot">checking…</span>
</div>
<div class="vk-status-row">
<span class="vk-status-label">
Limit (GiB)
<span
class="vk-info"
title="Combined size of the scrape databases, downloads, and previews. Below the warn threshold scrapes run freely. At the threshold a new scrape asks for a re-run. Up to 5% over the limit nothing is deleted; past that the oldest marked scrapes are removed."
>i</span
>
</span>
<span class="vk-status-value">
<input id="media-limit" class="vk-media-input" type="number" min="0.25" step="0.25" />
</span>
</div>
<div class="vk-status-row">
<span class="vk-status-label">Used</span>
<span class="vk-status-value" id="media-used">—</span>
</div>
<div id="media-usage-bar" class="vk-media-usage"></div>
</div>
</section>
</div>
</details>
<details class="vk-settings-group" open>
<summary class="vk-settings-summary">
<span class="vk-icon vk-caret" data-icon="caretDown"></span>
+91
View File
@@ -113,3 +113,94 @@
.vk-media-scrape button {
flex: none;
}
.vk-info {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
margin-left: 4px;
border-radius: 50%;
background: var(--color-neutral-800);
color: var(--color-neutral-300);
font: 600 9px/1 ui-monospace, Menlo, monospace;
cursor: help;
}
.vk-media-input {
width: 90px;
padding: 3px 6px;
border: none;
border-radius: var(--radius-sm, 6px);
background: var(--color-neutral-900);
color: var(--color-neutral-200);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
font: 400 11px/1 ui-monospace, Menlo, monospace;
}
.vk-media-usage {
display: flex;
height: 10px;
margin-top: 8px;
overflow: hidden;
border-radius: 5px;
background: var(--color-neutral-900);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
}
.vk-media-usage-seg {
height: 100%;
}
.vk-seg-image {
background: #4f8cff;
}
.vk-seg-gif {
background: #8a6cff;
}
.vk-seg-video {
background: #ff7a45;
}
.vk-seg-audio {
background: #2fbf71;
}
.vk-seg-other {
background: #6b7280;
}
.vk-media-modal {
position: fixed;
inset: 0;
z-index: 95;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-neutral-900) 70%, transparent);
}
.vk-media-modal[hidden] {
display: none;
}
.vk-media-modal-box {
display: flex;
flex-direction: column;
gap: 10px;
width: min(520px, 92vw);
padding: 14px;
border-radius: var(--radius-lg, 12px);
background: var(--color-bg);
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
}
.vk-media-modal-list {
max-height: 220px;
overflow-y: auto;
font: 400 11px/1.5 ui-monospace, Menlo, monospace;
color: var(--color-neutral-400);
}
+172 -1
View File
@@ -252,5 +252,176 @@
document.querySelector("#scope-guild")?.addEventListener("click", hide);
document.querySelector("#scope-dm")?.addEventListener("click", hide);
window.VK.media = { show, hide };
// ---- Settings: media storage ----
function formatBytes(bytes) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`;
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MiB`;
return `${bytes} B`;
}
function renderUsageBar(status) {
const bar = document.querySelector("#media-usage-bar");
if (!bar || !status) return;
bar.replaceChildren();
const limit = Math.max(status.limitBytes, 1);
const measured =
(status.byKind?.image ?? 0) +
(status.byKind?.gif ?? 0) +
(status.byKind?.video ?? 0) +
(status.byKind?.audio ?? 0);
const other = Math.max(0, status.usedBytes - measured);
for (const [kind, bytes] of [
["image", status.byKind?.image ?? 0],
["gif", status.byKind?.gif ?? 0],
["video", status.byKind?.video ?? 0],
["audio", status.byKind?.audio ?? 0],
["other", other]
]) {
if (bytes <= 0) continue;
const segment = el("div", `vk-media-usage-seg vk-seg-${kind}`);
segment.style.width = `${Math.min(100, (bytes / limit) * 100)}%`;
segment.title = `${kind}: ${formatBytes(bytes)}`;
bar.append(segment);
}
}
async function loadMediaSettings() {
try {
const [storage, status] = await Promise.all([
window.VK.api.get("/api/media/storage"),
window.VK.api.get("/api/media/status")
]);
state.storage = storage;
const bot = status?.slopbot;
const slopbotRow = document.querySelector("#media-slopbot");
if (slopbotRow) slopbotRow.textContent = bot?.connected ? `connected as ${bot.user}` : "disabled";
const limit = document.querySelector("#media-limit");
if (limit && document.activeElement !== limit) {
limit.value = (storage.limitBytes / 1024 ** 3).toFixed(2);
}
const used = document.querySelector("#media-used");
if (used) {
used.textContent = `${formatBytes(storage.usedBytes)} of ${formatBytes(storage.limitBytes)}${
storage.nearLimit ? " (near limit)" : ""
}`;
}
renderUsageBar(storage);
} catch (error) {
const used = document.querySelector("#media-used");
if (used) used.textContent = `unavailable: ${error.message}`;
}
}
document.querySelector("#media-save")?.addEventListener("click", async () => {
const input = document.querySelector("#media-limit");
const gib = Number(input?.value);
if (!Number.isFinite(gib) || gib <= 0) return;
try {
await window.VK.api.post("/api/preferences", { media: { limitBytes: Math.round(gib * 1024 ** 3) } });
setStatus("media limit saved");
const save = document.querySelector("#media-save");
if (save) save.disabled = true;
} catch (error) {
setStatus(`save failed: ${error.message}`);
}
void loadMediaSettings();
});
document.querySelector("#media-limit")?.addEventListener("input", () => {
const save = document.querySelector("#media-save");
if (save) save.disabled = false;
});
const cleanModal = document.createElement("div");
cleanModal.className = "vk-media-modal";
cleanModal.hidden = true;
cleanModal.innerHTML = `
<div class="vk-media-modal-box">
<strong>Delete media scrapes?</strong>
<div id="clean-note" class="vk-media-status"></div>
<div id="clean-list" class="vk-media-modal-list"></div>
<div class="vk-media-toolbar">
<button id="clean-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
<button id="clean-confirm" class="btn btn-primary vk-small-btn" type="button" disabled>Delete</button>
</div>
</div>
`;
document.body.append(cleanModal);
let cleanTimer = null;
function openCleanModal() {
const scrapes = state.storage?.scrapes ?? [];
const list = cleanModal.querySelector("#clean-list");
const note = cleanModal.querySelector("#clean-note");
const confirm = cleanModal.querySelector("#clean-confirm");
list.replaceChildren();
for (const scrape of scrapes) {
list.append(el("div", null, `${scrape.id} · ${scrape.state} · ${formatBytes(scrape.bytes)}`));
}
const bytes = scrapes.reduce((sum, scrape) => sum + scrape.bytes, 0);
note.textContent = scrapes.length
? `Everything above will be deleted permanently, including files and databases (${formatBytes(bytes)}). This cannot be undone.`
: "No scrapes to delete.";
confirm.disabled = true;
let left = 5;
confirm.textContent = `Delete (${left})`;
cleanModal.hidden = false;
clearInterval(cleanTimer);
if (!scrapes.length) {
confirm.textContent = "Delete";
return;
}
cleanTimer = setInterval(() => {
left--;
if (left <= 0) {
clearInterval(cleanTimer);
confirm.disabled = false;
confirm.textContent = "Delete";
return;
}
confirm.textContent = `Delete (${left})`;
}, 1000);
}
cleanModal.querySelector("#clean-cancel").addEventListener("click", () => {
clearInterval(cleanTimer);
cleanModal.hidden = true;
});
cleanModal.querySelector("#clean-confirm").addEventListener("click", async () => {
const ids = (state.storage?.scrapes ?? []).map(scrape => scrape.id);
try {
await window.VK.api.post("/api/media/clean", { ids });
} catch (error) {
setStatus(`clean failed: ${error.message}`);
}
cleanModal.hidden = true;
void loadMediaSettings();
void loadScrapes();
});
document.querySelector("#media-clean")?.addEventListener("click", openCleanModal);
const settingsOverlay = document.querySelector("#settings-overlay");
if (settingsOverlay) {
new MutationObserver(() => {
if (!settingsOverlay.hidden) void loadMediaSettings();
}).observe(settingsOverlay, { attributes: true, attributeFilter: ["hidden"] });
}
window.VK.media = { show, hide, loadMediaSettings };
})();