Files
Veskslop-t/poc/public/app.js
T

3237 lines
106 KiB
JavaScript

"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const SENDABLE_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]);
const TEXT_TYPES = new Set([0, 5]);
const VOICE_TYPES = new Set([2, 13]);
const state = {
user: null,
guilds: [],
guildId: null,
channelId: null,
channel: null,
messages: [],
dms: [],
voiceOverview: null,
voiceSelf: null,
health: null,
eventCount: 0,
lastEventSample: null,
eventRate: 0,
events: [],
visibleGuilds: null,
visibleGuildsSaved: null,
timeZoneOverride: localStorage.getItem("veskslop-timezone") ?? "",
speech: { provider: null, language: null, voice: null },
commands: { wakeword: "!s" },
profileDefaults: {},
expressionDefault: null,
scope: "guild",
speaking: new Set(),
speakingFade: new Map(),
people: new Map(),
favourites: { users: [], channels: [] },
favouriteUsers: [],
speakingRenderAt: 0,
daemonDown: false,
composerChannelId: null,
audioRecording: false,
clientAudio: {
on: false,
ws: null,
ctx: null,
nextTime: 0,
sinkId: localStorage.getItem("veskslop-browser-output") ?? ""
},
takeover: localStorage.getItem("veskslop-takeover") === "1",
consoleOpen: false,
consoleTab: "events",
mobileTab: localStorage.getItem("veskslop-mobile-tab") ?? "chat",
renderedChannelId: null,
messagesPinned: true,
voiceReloadAt: 0
};
/* Drafts survive re-renders and reloads. Nothing else is persisted client-side. */
const draftKey = channelId => `veskslop-draft:${channelId}`;
const TTS_DRAFT_KEY = "veskslop-tts-draft";
function isGuildVisible(guildId) {
return !state.visibleGuilds || state.visibleGuilds.has(guildId);
}
function visibleGuildsList() {
return state.guilds.filter(guild => isGuildVisible(guild.id));
}
/** All guilds visible is stored as null. */
function normalizedVisibility(visibility) {
if (!visibility) return null;
if (state.guilds.length > 0 && visibility.size === state.guilds.length) return null;
return visibility;
}
function sameVisibility(a, b) {
if (!a || !b) return !a && !b;
if (a.size !== b.size) return false;
for (const id of a) if (!b.has(id)) return false;
return true;
}
function updateGuildsSaveButton() {
const button = $("#guilds-save");
if (button) button.disabled = sameVisibility(state.visibleGuilds, state.visibleGuildsSaved);
}
async function loadPreferences() {
try {
const prefs = await VK.api.get("/api/preferences");
const visibility = Array.isArray(prefs.visibleGuilds) ? new Set(prefs.visibleGuilds) : null;
state.visibleGuilds = visibility ? new Set(visibility) : null;
state.visibleGuildsSaved = visibility ? new Set(visibility) : null;
} catch {
state.visibleGuilds = null;
state.visibleGuildsSaved = null;
}
updateGuildsSaveButton();
}
/** Persist the current checkbox selection to the daemon state. */
async function saveGuildVisibility() {
const visibility = normalizedVisibility(state.visibleGuilds);
const visibleGuilds = visibility ? [...visibility] : null;
try {
const prefs = await VK.api.post("/api/preferences", { visibleGuilds });
state.visibleGuildsSaved = Array.isArray(prefs.visibleGuilds) ? new Set(prefs.visibleGuilds) : null;
state.visibleGuilds = state.visibleGuildsSaved ? new Set(state.visibleGuildsSaved) : null;
renderVisibleGuilds();
renderGuildPicker();
renderGuildSwitcher();
toast("Guild visibility saved", "ok");
} catch (error) {
toast(error.message, "error");
}
}
/* Helpers */
function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value === undefined || value === null) continue;
if (key === "class") node.className = value;
else if (key === "text") node.textContent = value;
else if (key === "html") node.innerHTML = value;
else if (key.startsWith("on")) node.addEventListener(key.slice(2).toLowerCase(), value);
else if (key === "disabled") node.disabled = !!value;
else node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child === undefined || child === null) continue;
node.append(child.nodeType ? child : document.createTextNode(String(child)));
}
return node;
}
function icon(name, size = 13) {
const span = el("span", { class: "vk-icon" });
span.innerHTML = VK.icon(name, size);
return span;
}
function hydrateIcons(root = document) {
for (const span of $$("[data-icon]", root)) span.innerHTML = VK.icon(span.dataset.icon);
}
function toast(message, kind = "") {
const node = el("div", { class: `vk-toast ${kind}`, text: message });
$("#toasts").append(node);
setTimeout(() => node.remove(), 5000);
}
function monogram(name) {
const clean = String(name ?? "?").replace(/[^a-zA-Z0-9]/g, "");
return (clean[0] ?? "?").toUpperCase();
}
const CDN_BASE = "https://cdn.discordapp.com";
function cdnHashUrl(route, id, hash, size = 64) {
if (!hash) return null;
const ext = String(hash).startsWith("a_") ? "gif" : "png";
return `${CDN_BASE}/${route}/${id}/${hash}.${ext}?size=${size}`;
}
function defaultAvatarUrl(id) {
try {
return `${CDN_BASE}/embed/avatars/${Number(BigInt(String(id)) >> 22n) % 6}.png`;
} catch {
return null;
}
}
function userAvatarUrl(user, size = 64) {
if (!user?.id) return null;
return cdnHashUrl("avatars", user.id, user.avatar, size) ?? defaultAvatarUrl(user.id);
}
function guildIconUrl(guild, size = 64) {
return cdnHashUrl("icons", guild.id, guild.icon, size);
}
function dmIconUrl(dm, size = 64) {
if (dm?.icon) return cdnHashUrl("channel-icons", dm.id, dm.icon, size);
const recipient = dm?.recipients?.[0];
return recipient ? userAvatarUrl(recipient, size) : null;
}
function avatar(entity, { agent = false, size = "", url } = {}) {
const name = typeof entity === "string" ? entity : entity?.username ?? entity?.name ?? "?";
const resolved = url ?? (typeof entity === "object" && entity ? userAvatarUrl(entity) : null);
const node = el("span", { class: `vk-avatar ${agent ? "agent" : ""} ${size}`.trim() });
if (resolved) {
const img = el("img", { src: resolved, alt: "", loading: "lazy" });
img.addEventListener("error", () => {
img.remove();
node.textContent = monogram(name);
});
node.append(img);
} else {
node.textContent = monogram(name);
}
return node;
}
function dmAvatar(dm, opts = {}) {
return avatar(dmLabel(dm), { ...opts, url: dmIconUrl(dm) });
}
function guildMarkContent(mark, guild) {
const url = guildIconUrl(guild);
if (!url) {
mark.textContent = monogram(guild.name);
return;
}
const img = el("img", { src: url, alt: "", loading: "lazy" });
img.addEventListener("error", () => {
img.remove();
mark.textContent = monogram(guild.name);
});
mark.append(img);
}
function browserTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
}
function formatDate(timestamp, options) {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
...options,
timeZone: state.timeZoneOverride || undefined
}).format(date);
}
function formatTime(timestamp) {
return formatDate(timestamp, { hour: "2-digit", minute: "2-digit" });
}
function formatClock(timestamp) {
return formatDate(timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function formatDateTime(timestamp) {
return formatDate(timestamp, { dateStyle: "medium", timeStyle: "medium" });
}
function updateTimeZoneStatus() {
const status = $("#timezone-status");
if (!status) return;
status.textContent = state.timeZoneOverride
? `override · ${state.timeZoneOverride}`
: `auto · ${browserTimeZone()}`;
}
function renderTimeZones() {
renderMessages();
renderEvents();
if (state.consoleTab === "transcript") void refreshTranscript();
if (!$("#settings-overlay").hidden) void refreshLogs();
}
const CURATED_ZONES = [
{ name: "UTC", abbr: "UTC", zone: "UTC" },
{ name: "UK", abbr: "GMT/BST", zone: "Europe/London" },
{ name: "Central Europe", abbr: "CET/CEST", zone: "Europe/Berlin" },
{ name: "Eastern Europe", abbr: "EET/EEST", zone: "Europe/Bucharest" },
{ name: "US East Coast", abbr: "ET", zone: "America/New_York" },
{ name: "Ohio", abbr: "ET", zone: "America/New_York" },
{ name: "US Central", abbr: "CT", zone: "America/Chicago" },
{ name: "US Mountain", abbr: "MT", zone: "America/Denver" },
{ name: "US West Coast", abbr: "PT", zone: "America/Los_Angeles" },
{ name: "Japan", abbr: "JST", zone: "Asia/Tokyo" },
{ name: "India", abbr: "IST", zone: "Asia/Kolkata" },
{ name: "Australia", abbr: "AET", zone: "Australia/Sydney" }
];
function initTimeZone() {
const select = $("#timezone-select");
const zones = typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : [];
const curated = new Set(CURATED_ZONES.map(entry => entry.zone));
select.replaceChildren(el("option", { value: "", text: `Auto (browser: ${browserTimeZone()})` }));
select.append(
el(
"optgroup",
{ label: "Common" },
...CURATED_ZONES.map(entry =>
el("option", { value: entry.zone, text: `${entry.name} (${entry.abbr})` })
)
)
);
select.append(
el(
"optgroup",
{ label: "All timezones" },
...zones.filter(zone => !curated.has(zone)).map(zone => el("option", { value: zone, text: zone }))
)
);
if (state.timeZoneOverride && !zones.includes(state.timeZoneOverride) && !curated.has(state.timeZoneOverride)) {
select.append(el("option", { value: state.timeZoneOverride, text: state.timeZoneOverride }));
}
select.value = state.timeZoneOverride;
updateTimeZoneStatus();
select.addEventListener("change", () => {
state.timeZoneOverride = select.value;
if (state.timeZoneOverride) localStorage.setItem("veskslop-timezone", state.timeZoneOverride);
else localStorage.removeItem("veskslop-timezone");
updateTimeZoneStatus();
renderTimeZones();
});
}
function formatUptime(seconds) {
if (!Number.isFinite(seconds)) return "up —";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days) return `up ${days}d ${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
if (hours) return `up ${hours}h ${String(minutes).padStart(2, "0")}m`;
return `up ${minutes}m`;
}
function currentGuild() {
return state.guilds.find(guild => guild.id === state.guildId) ?? null;
}
function findGuildChannel(channelId) {
for (const guild of state.guilds) {
const channel = guild.channels.find(item => item.id === channelId);
if (channel) return channel;
}
return null;
}
function dmById(channelId) {
return state.dms.find(dm => dm.id === channelId) ?? null;
}
function dmLabel(dm) {
if (dm.name) return dm.name;
const names = (dm.recipients ?? []).map(recipient => recipient.username);
if (!names.length) return "DM";
if (names.length <= 2) return names.join(", ");
return `${names[0]} + ${names.length - 1}`;
}
function channelLabel() {
if (!state.channel) return "No channel selected";
return state.channel.name ?? channelLabelFromDm();
}
function channelLabelFromDm() {
const dm = state.channelId ? dmById(state.channelId) : null;
return dm ? dmLabel(dm) : "Direct message";
}
/* Data loading */
async function loadState() {
let data;
try {
data = await VK.api.get("/api/state");
} catch {
renderIdentity();
renderGuildPicker();
renderGuildSwitcher();
return false;
}
state.user = data.user ?? null;
state.guilds = data.guilds ?? [];
const visible = visibleGuildsList();
if (!state.guildId || !visible.some(guild => guild.id === state.guildId)) {
state.guildId = visible[0]?.id ?? null;
}
renderIdentity();
renderGuildPicker();
renderGuildSwitcher();
renderVisibleGuilds();
renderRail();
const guild = currentGuild();
if (!state.channelId && guild) {
const first = guild.channels.find(channel => SENDABLE_TYPES.has(channel.type));
if (first) await selectChannel(first.id, guild.id);
} else if (state.channelId) {
renderComposer();
}
return true;
}
async function loadDms() {
try {
const result = await VK.api.get("/api/dms");
state.dms = result.channels ?? [];
} catch {
state.dms = [];
}
renderDmRail();
renderRightDms();
renderRail();
updateMobileBadge();
}
async function loadVoice() {
try {
state.voiceSelf = await VK.api.get("/api/voice");
} catch {
state.voiceSelf = null;
}
if (state.guildId) {
try {
const overview = await VK.api.get(`/api/voice/overview?guildId=${encodeURIComponent(state.guildId)}`);
state.voiceOverview = overview.channels ?? [];
} catch {
state.voiceOverview = [];
}
} else {
state.voiceOverview = [];
}
trackPeople((state.voiceOverview ?? []).flatMap(entry => entry.members ?? []));
renderVoiceRail();
renderPeople();
renderRightVoice();
renderCallStrip();
}
async function loadMessages({ refresh = false } = {}) {
if (!state.channelId) {
state.messages = [];
renderMessages();
return;
}
const channelId = state.channelId;
try {
// refresh fetches the latest 50 from Discord and fills the client cache.
// Attachments stay CDN links; nothing is downloaded or cached locally.
const result = await VK.api.get(
`/api/messages?channelId=${encodeURIComponent(channelId)}&limit=50${refresh ? "&refresh=1" : ""}`
);
if (channelId !== state.channelId) return;
state.messages = result.messages ?? [];
} catch (error) {
if (channelId !== state.channelId) return;
state.messages = [];
toast(error.message, "error");
}
renderMessages();
}
let refreshing = false;
/** Reload everything from the daemon without touching any draft input. */
async function refreshAll() {
if (refreshing) return;
refreshing = true;
try {
await loadPreferences();
// The bridge can connect before the renderer answers state.get.
let loaded = false;
for (let attempt = 0; attempt < 15 && !loaded; attempt++) {
loaded = await loadState();
if (!loaded) await new Promise(resolve => setTimeout(resolve, 2000));
}
if (!loaded) return;
await loadDms();
await refreshFavourites();
await loadVoice();
if (state.channelId) await loadMessages({ refresh: true });
} catch (error) {
toast(error.message, "error");
} finally {
refreshing = false;
}
}
async function selectGuild(guildId) {
state.guildId = guildId;
state.channelId = null;
state.channel = null;
state.scope = "guild";
$("#scope-guild").classList.add("active");
$("#scope-dm").classList.remove("active");
$("#guild-menu").hidden = true;
renderGuildPicker();
renderGuildSwitcher();
renderRail();
renderRightVoice();
const guild = currentGuild();
const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type));
if (first) await selectChannel(first.id, guildId);
await loadVoice();
}
async function selectChannel(channelId, guildId) {
if (guildId && guildId !== state.guildId) state.guildId = guildId;
const switched = Boolean(state.channelId) && state.channelId !== channelId;
state.channelId = channelId;
state.channel = findGuildChannel(channelId);
if (!state.channel) {
const dm = dmById(channelId);
if (dm) state.channel = { id: dm.id, name: dmLabel(dm), type: dm.type, dm };
}
renderGuildPicker();
renderRail();
renderCenterHeader();
renderRightDms();
renderComposer();
await loadMessages({ refresh: true });
await loadVoice();
if (switched && isMobileLayout()) {
setMobileTab(VOICE_TYPES.has(state.channel?.type) ? "call" : "chat");
}
}
async function sendMessage() {
if (!state.takeover) {
toast("Composer is read-only — turn Takeover on to send", "error");
return;
}
const input = $("#composer-input");
const content = input?.value.trim() ?? "";
if (!content || !state.channelId) return;
try {
await VK.api.post("/api/messages", { channelId: state.channelId, content });
localStorage.removeItem(draftKey(state.channelId));
input.value = "";
autoGrow(input);
toast("Message sent", "ok");
await loadMessages();
} catch (error) {
toast(error.message, "error");
}
}
function autoGrow(input) {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
}
/* Header and rail rendering */
function renderIdentity() {
const footer = $("#agent-identity");
const name = state.user?.username ?? "not logged in";
footer.replaceChildren(
state.user ? avatar(state.user, { agent: true }) : avatar(name, { agent: true }),
el(
"span",
{ class: "vk-right-name" },
el("span", { class: "vk-agent-name", text: name }),
el("span", { class: "vk-agent-sub", text: "agent · headless" })
)
);
}
function renderGuildPicker() {
const guild = currentGuild();
$("#guild-picker-label").textContent =
state.scope === "dm" ? "Direct messages" : guild?.name ?? "Direct messages";
}
function renderGuildSwitcher() {
const root = $("#guild-switcher");
root.replaceChildren();
for (const guild of visibleGuildsList()) {
const button = el("button", {
class: `vk-guild-mark ${guild.id === state.guildId ? "active" : ""}`,
type: "button",
title: guild.name,
onclick: () => void selectGuild(guild.id)
});
guildMarkContent(button, guild);
root.append(button);
}
}
function setGuildVisible(guildId, visible) {
if (!state.visibleGuilds) state.visibleGuilds = new Set(state.guilds.map(guild => guild.id));
if (visible) state.visibleGuilds.add(guildId);
else state.visibleGuilds.delete(guildId);
if (state.guildId && !isGuildVisible(state.guildId)) {
const next = visibleGuildsList()[0];
if (next) void selectGuild(next.id);
}
renderGuildPicker();
renderGuildSwitcher();
renderVisibleGuilds();
}
function renderVisibleGuilds() {
const root = $("#guild-visibility");
if (!root) return;
root.replaceChildren();
if (!state.guilds.length) {
root.append(el("div", { class: "vk-empty", text: "No guilds cached." }));
return;
}
for (const guild of state.guilds) {
const checkbox = el("input", { type: "checkbox" });
checkbox.checked = isGuildVisible(guild.id);
checkbox.addEventListener("change", () => setGuildVisible(guild.id, checkbox.checked));
const mark = el("span", { class: "vk-guild-icon-sm" });
guildMarkContent(mark, guild);
root.append(el("label", { class: "vk-visibility-row" }, checkbox, mark, el("span", { text: guild.name })));
}
updateGuildsSaveButton();
}
function voiceMarks(member) {
const marks = el("span", { class: "vk-marks" });
const muted = member.suppress || member.mute || member.selfMute;
// The bars are always in the layout; they scale up while speaking and
// decay back to nothing when the voice stops.
const classes = ["vk-levels"];
if (!muted && state.speaking.has(member.id)) classes.push("speaking");
if (!muted && state.speakingFade.has(member.id)) classes.push("stopping");
const levels = el("span", { class: classes.join(" "), title: "speaking" });
for (let index = 0; index < 4; index++) levels.append(el("span"));
marks.append(levels);
if (muted) {
const title = member.suppress ? "muted by server" : member.mute ? "server-muted" : "self-muted";
marks.append(el("span", { class: "neutral", title, html: VK.icon("micSlash") }));
}
if (member.deaf || member.selfDeaf) {
marks.append(el("span", { class: "neutral", title: "deafened", html: VK.icon("speakerSlash") }));
}
if (member.video) {
marks.append(el("span", { class: "accent", title: "camera on", html: VK.icon("camera") }));
}
if (member.stream) {
marks.append(el("span", { class: "accent", title: "screen sharing", html: VK.icon("monitor") }));
}
return marks;
}
function memberName(member) {
return member.nick ?? member.username ?? member.id;
}
function memberNameNode(member, className = "vk-name") {
return el("span", {
class: `${className} ${state.speaking.has(member.id) ? "speaking" : ""}`.trim(),
text: memberName(member),
style: member.id === state.user?.id ? "color: var(--color-accent-300)" : ""
});
}
/* Speaking marks. Bars stay on until an explicit stop, a mute, or a channel
change: Discord's speaking events are not reliable enough for an idle decay.
The fade runs the equalizer animation inside a shrinking container, so the
amplitude drops instead of the whole block collapsing at full height. */
const SPEAKING_FADE_MS = 750;
function renderSpeakingViews() {
renderVoiceRail();
renderPeople();
renderRightVoice();
renderRightFavourites();
renderCallStrip();
}
function scheduleSpeakingRender() {
const now = Date.now();
if (now - state.speakingRenderAt < 250) return;
state.speakingRenderAt = now;
renderSpeakingViews();
}
function beginSpeaking(userId) {
const fade = state.speakingFade.get(userId);
if (fade) {
clearTimeout(fade);
state.speakingFade.delete(userId);
}
const wasSpeaking = state.speaking.has(userId);
state.speaking.add(userId);
if (!wasSpeaking) scheduleSpeakingRender();
}
function endSpeaking(userId) {
if (!state.speaking.delete(userId)) return;
const fadeTimer = setTimeout(() => {
state.speakingFade.delete(userId);
renderSpeakingViews();
}, SPEAKING_FADE_MS);
state.speakingFade.set(userId, fadeTimer);
scheduleSpeakingRender();
}
function trackPeople(members) {
for (const member of members ?? []) {
if (!member?.id || member.id === state.user?.id) continue;
state.people.set(member.id, {
id: member.id,
username: member.username ?? member.id,
nick: member.nick ?? member.username ?? member.id,
avatar: member.avatar ?? null,
at: Date.now()
});
}
}
function renderRail() {
const guildScope = state.scope === "guild";
$("#rail-guild").hidden = !guildScope;
$("#rail-dm").hidden = guildScope;
if (guildScope) {
renderTextChannels();
renderVoiceRail();
renderPeople();
} else {
renderDmRail();
}
}
function renderTextChannels() {
const root = $("#text-channels");
root.replaceChildren();
const guild = currentGuild();
if (!guild) return;
const channels = guild.channels.filter(channel => TEXT_TYPES.has(channel.type));
if (!channels.length) {
root.append(el("div", { class: "vk-empty", text: "No text channels cached." }));
return;
}
for (const channel of channels) {
root.append(
el(
"button",
{
class: `vk-rail-row ${channel.id === state.channelId ? "active" : ""}`,
type: "button",
onclick: () => void selectChannel(channel.id)
},
el("span", { class: "vk-icon vk-dim", html: VK.icon("hash") }),
el("span", { class: "vk-rail-name", text: channel.name })
)
);
}
}
function renderVoiceRail() {
const root = $("#voice-channels");
if (!root) return;
root.replaceChildren();
const guild = currentGuild();
if (!guild) return;
const voiceChannels = guild.channels.filter(channel => VOICE_TYPES.has(channel.type));
if (!voiceChannels.length) {
root.append(el("div", { class: "vk-empty", text: "No voice channels cached." }));
return;
}
const occupied = new Map((state.voiceOverview ?? []).map(entry => [entry.channelId, entry]));
for (const channel of voiceChannels) {
const call = occupied.get(channel.id);
const active = state.voiceSelf?.channelId === channel.id;
if (call?.members?.length) {
trackPeople(call.members);
const block = el("div", { class: "vk-rail-voice" });
block.append(
el(
"button",
{
class: "vk-rail-voice-head",
type: "button",
title: active ? "Leave this call" : "Join this voice channel",
onclick: () => void joinVoice(channel.id)
},
el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
el("span", { class: "vk-rail-name", style: "font-weight:500", text: channel.name }),
el(
"span",
{ class: "vk-rail-trail" },
el("span", {
style: `display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:5px;background:var(--color-${active ? "accent" : "neutral-500"})`
}),
`${call.members.length}`
)
)
);
const members = el("div", { class: "vk-rail-voice-members" });
for (const member of call.members) {
members.append(
el("span", { class: "vk-voice-member" }, memberNameNode(member, "vk-name"), voiceMarks(member))
);
}
block.append(members);
root.append(block);
} else {
root.append(
el(
"button",
{
class: `vk-rail-row ${active ? "active" : ""}`,
type: "button",
title: "Join this voice channel",
onclick: () => joinVoice(channel.id)
},
el("span", { class: "vk-icon vk-dim", html: VK.icon("volume") }),
el("span", { class: "vk-rail-name", text: channel.name }),
el("span", { class: "vk-rail-trail", text: "0" })
)
);
}
}
}
function renderPeople() {
const root = $("#people-list");
if (!root) return;
root.replaceChildren();
const people = [...state.people.values()].sort((a, b) => b.at - a.at).slice(0, 20);
if (!people.length) {
root.append(el("div", { class: "vk-empty", text: "No voice people seen yet." }));
return;
}
for (const person of people) {
root.append(
el(
"button",
{
class: "vk-rail-row",
type: "button",
title: person.username,
onclick: () => openPersonDm(person.id)
},
avatar(person, { size: "sm" }),
el("span", {
class: `vk-rail-name ${state.speaking.has(person.id) ? "speaking" : ""}`.trim(),
text: person.nick ?? person.username
})
)
);
}
}
function openPersonDm(userId) {
const dm = state.dms.find(
entry => entry.type === 1 && (entry.recipients ?? []).some(recipient => recipient.id === userId)
);
if (!dm) {
toast("No cached DM with that user", "error");
return;
}
void openDm(dm);
}
function dmStarred(dm) {
if (dm.type === 3) return state.favourites.channels.includes(dm.id);
const recipient = dm.recipients?.[0]?.id;
return !!recipient && state.favourites.users.includes(recipient);
}
function starButton(active, onToggle) {
const button = el("button", {
class: `vk-star ${active ? "on" : ""}`,
type: "button",
title: active ? "Remove from favourites" : "Add to favourites",
onclick: event => {
event.stopPropagation();
onToggle();
}
});
button.innerHTML = VK.icon("star", 12);
return button;
}
function unreadBadge(dm) {
const mentions = dm.mentions ?? 0;
const unread = dm.unread ?? 0;
if (!mentions && !unread) return null;
const kind = !state.takeover ? "muted" : mentions ? "mention" : "unread";
return el("span", {
class: `vk-dm-badge ${kind}`,
text: mentions ? String(mentions) : unread > 99 ? "99+" : String(unread)
});
}
function formatDmTime(iso) {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
if (date.toDateString() === now.toDateString()) return formatTime(iso);
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) return "yesterday";
return formatDate(iso, { month: "short", day: "numeric" });
}
function dmRow(dm) {
const active = dm.id === state.channelId;
const row = el("button", {
class: `vk-rail-row ${active ? "active" : ""}`,
type: "button",
onclick: () => void openDm(dm)
});
const badge = unreadBadge(dm);
if (badge) row.append(badge);
row.append(dmAvatar(dm, { size: "sm" }));
row.append(el("span", { class: "vk-rail-name", text: dmLabel(dm) }));
const time = formatDmTime(dm.lastMessageAt);
if (time) row.append(el("span", { class: "vk-dm-time", text: time }));
row.append(starButton(dmStarred(dm), () => void toggleFavouriteDm(dm)));
return row;
}
function renderDmRail() {
const root = $("#dm-rail-list");
if (!root) return;
root.replaceChildren();
if (!state.dms.length) {
root.append(el("div", { class: "vk-empty", text: "No cached DMs." }));
return;
}
for (const dm of state.dms) root.append(dmRow(dm));
}
async function openDm(dm) {
await applyScope("dm");
await selectChannel(dm.id);
}
async function applyScope(scope) {
state.scope = scope;
$("#scope-guild").classList.toggle("active", scope === "guild");
$("#scope-dm").classList.toggle("active", scope === "dm");
renderGuildPicker();
if (scope === "dm") {
if (!state.channel?.dm) {
const first = state.dms[0];
if (first) await selectChannel(first.id);
}
} else if (state.channel?.dm) {
const guild = currentGuild();
const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type));
if (first) await selectChannel(first.id);
}
renderRail();
}
async function saveFavourites(patch) {
try {
const prefs = await VK.api.post("/api/preferences", {
favourites: { ...state.favourites, ...patch }
});
state.favourites = prefs.favourites ?? { users: [], channels: [] };
await refreshFavourites();
renderDmRail();
renderRightDms();
} catch (error) {
toast(error.message, "error");
}
}
function toggleFavouriteDm(dm) {
if (dm.type === 3) {
const channels = state.favourites.channels.includes(dm.id)
? state.favourites.channels.filter(id => id !== dm.id)
: [...state.favourites.channels, dm.id];
void saveFavourites({ channels });
return;
}
const userId = dm.recipients?.[0]?.id;
if (!userId) {
toast("No cached recipient to favourite", "error");
return;
}
const users = state.favourites.users.includes(userId)
? state.favourites.users.filter(id => id !== userId)
: [...state.favourites.users, userId];
void saveFavourites({ users });
}
function removeFavouriteUser(userId) {
void saveFavourites({ users: state.favourites.users.filter(id => id !== userId) });
}
/* Center rendering */
function renderCenterHeader() {
const title = channelLabel();
const channel = state.channel;
const guild = currentGuild();
$("#channel-title").textContent = title;
$("#channel-icon").innerHTML = VK.icon(
channel && VOICE_TYPES.has(channel.type) ? "volume" : channel?.dm ? "speaker" : "hash"
);
let context = "";
if (channel?.dm) {
const count = channel.dm.recipients?.length ?? 0;
context = channel.dm.type === 3 ? `group · ${count} recipients` : "direct message";
} else if (guild) {
context = `${guild.name} · ${guild.channels.length} channels cached`;
}
$("#channel-context").textContent = context;
}
/* Message scroll: a channel opens on the newest message. The view stays pinned
while the user is at the bottom, and never gets dragged by content that loads
late above the viewport. */
function messagesNearBottom(slack = 80) {
const scroll = $("#message-scroll");
if (!scroll || !scroll.scrollHeight) return true;
return scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight <= slack;
}
function pinMessagesToBottom() {
const scroll = $("#message-scroll");
if (!scroll) return;
const apply = () => {
scroll.scrollTop = scroll.scrollHeight;
};
apply();
requestAnimationFrame(apply);
// Avatars, attachments, and embeds can change the height after the first paint.
setTimeout(apply, 120);
setTimeout(apply, 400);
}
function renderMessages() {
const root = $("#message-list");
const channelChanged = state.renderedChannelId !== state.channelId;
const pinned = channelChanged || messagesNearBottom();
state.renderedChannelId = state.channelId;
state.messagesPinned = pinned;
root.replaceChildren();
if (!state.channelId) {
root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." }));
pinMessagesToBottom();
return;
}
if (!state.messages.length) {
root.append(
el("div", {
class: "vk-empty",
text: "No cached messages. The client only holds channels it has loaded."
})
);
pinMessagesToBottom();
return;
}
for (const message of state.messages) {
const author = message.author?.username ?? "unknown";
const isAgent = message.author?.id === state.user?.id;
const body = el(
"div",
{ class: "vk-message-body-wrap" },
el(
"span",
{ class: "vk-message-head" },
el("span", { class: "vk-message-author", text: author }),
isAgent ? el("span", { class: "tag tag-outline vk-tag-agent", text: "agent" }) : null,
el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) }),
message.editedAt ? el("span", { class: "vk-mono vk-dim", text: "· edited" }) : null
),
message.replyTo
? el("span", {
class: "vk-message-reply",
text: `↳ replying to ${message.replyTo.authorName ?? message.replyTo.authorId ?? "a message"}`
})
: null,
el("span", { class: "vk-message-body", text: message.content })
);
for (const attachment of message.attachments ?? []) {
if (!attachment) continue;
const filename = attachment.filename ?? "attachment";
const isImage =
String(attachment.contentType ?? "").startsWith("image/") ||
/\.(png|jpe?g|gif|webp|avif)$/i.test(filename);
if (isImage && attachment.url) {
const image = el("img", {
class: "vk-message-image",
src: attachment.url,
alt: filename,
loading: "lazy"
});
image.addEventListener("error", () => image.remove());
body.append(image);
} else if (attachment.url) {
body.append(
el("a", {
class: "vk-attachment",
href: attachment.url,
target: "_blank",
rel: "noreferrer",
text: filename
})
);
} else if (filename) {
body.append(el("span", { class: "vk-attachment", text: filename }));
}
}
root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body));
}
if (pinned) pinMessagesToBottom();
}
function ttsToggleButton() {
return el("button", {
id: "tts-toggle",
class: "btn btn-secondary vk-icon-btn vk-tts-toggle",
type: "button",
title: "Text to speech",
onclick: toggleTts,
html: VK.icon("wave")
});
}
function composerPlaceholder() {
const target = channelLabel() === "No channel selected" ? "" : channelLabel();
return `Message ${target} as ${state.user?.username ?? "the account"}`;
}
function renderComposer() {
const wrap = $("#composer");
const existing = $("#composer-input");
if (state.takeover) {
wrap.classList.add("unlocked");
// Reuse the live input so typing and caret survive re-renders.
if (existing && state.composerChannelId === state.channelId) {
existing.placeholder = composerPlaceholder();
return;
}
const draft = state.channelId ? localStorage.getItem(draftKey(state.channelId)) ?? "" : "";
const input = el("textarea", {
id: "composer-input",
class: "vk-composer-input",
rows: "1",
placeholder: composerPlaceholder()
});
input.value = draft;
input.addEventListener("input", () => {
autoGrow(input);
if (state.channelId) localStorage.setItem(draftKey(state.channelId), input.value);
});
input.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void sendMessage();
}
});
state.composerChannelId = state.channelId;
wrap.replaceChildren(
ttsToggleButton(),
input,
el("button", {
id: "send",
class: "btn btn-primary vk-send",
type: "button",
text: "Send",
onclick: () => void sendMessage()
})
);
requestAnimationFrame(() => autoGrow(input));
return;
}
state.composerChannelId = null;
wrap.classList.remove("unlocked");
wrap.replaceChildren(
el("span", { class: "tag tag-neutral vk-readonly-tag", text: "read-only" }),
icon("lock"),
el("span", {
class: "vk-composer-copy",
text: `Composer locked — takeover is off. Flip Takeover to send as ${state.user?.username ?? "the account"}.`
}),
ttsToggleButton(),
el("button", { class: "btn btn-secondary vk-send", type: "button", text: "Send", disabled: true })
);
}
function toggleTts() {
const panel = $("#tts-panel");
panel.hidden = !panel.hidden;
$("#tts-target").textContent = `as ${state.user?.username ?? "account"} → ${channelLabel()}`;
if (!panel.hidden) void refreshSpeech();
}
/* Right column */
function renderRightVoice() {
const root = $("#right-voice");
if (!root) return;
root.replaceChildren();
// The phone Call tab shows the live call, not just favourites.
const callMembers = isMobileLayout() && state.mobileTab === "call" ? (state.voiceSelf?.members ?? []) : [];
if (callMembers.length) {
$("#voice-count").textContent = `${callMembers.length} in call`;
const card = el(
"div",
{ class: "vk-right-group" },
el(
"div",
{ class: "vk-right-group-head" },
el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
el("span", { class: "vk-name", text: state.voiceSelf?.channelName ?? "call" })
),
...callMembers.map(member =>
el(
"div",
{ class: "vk-right-row" },
avatar(member, { size: "sm" }),
el(
"span",
{ class: "vk-right-name" },
el("span", {
class: `vk-name ${state.speaking.has(member.id) ? "speaking" : ""}`.trim(),
text: member.username ?? member.id
}),
el("span", {
class: "vk-sub",
text: member.id === state.user?.id ? "this client" : member.status ?? ""
})
),
el("span", { class: "vk-right-trail" }, voiceMarks(member))
)
)
);
root.append(card);
return;
}
const inCall = state.favouriteUsers.filter(user => user.channelId);
$("#voice-count").textContent = inCall.length ? `${inCall.length} in call` : "";
if (!inCall.length) {
root.append(el("div", { class: "vk-empty", text: "No favourited users in a call." }));
return;
}
const groups = new Map();
for (const user of inCall) {
const list = groups.get(user.channelId) ?? [];
list.push(user);
groups.set(user.channelId, list);
}
for (const [channelId, users] of groups) {
const card = el(
"div",
{ class: "vk-right-group" },
el(
"div",
{ class: "vk-right-group-head" },
el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
el("span", { class: "vk-name", text: users[0].channelName ?? channelId })
)
);
for (const user of users) {
card.append(
el(
"div",
{ class: "vk-right-row" },
avatar({ id: user.id, username: user.username, avatar: user.avatar }, { size: "sm" }),
el(
"span",
{ class: "vk-right-name" },
el("span", {
class: `vk-name ${state.speaking.has(user.id) ? "speaking" : ""}`.trim(),
text: user.username
}),
el("span", { class: "vk-sub", text: user.status })
),
el(
"span",
{ class: "vk-right-trail" },
el("button", {
class: "btn btn-primary vk-small-btn",
type: "button",
text: "Join",
onclick: () => joinVoice(channelId)
})
)
)
);
}
root.append(card);
}
}
function renderRightDms() {
const root = $("#right-dms");
if (!root) return;
root.replaceChildren();
$("#dm-count").textContent = state.dms.length ? `${state.dms.length} threads` : "";
if (!state.dms.length) {
root.append(el("div", { class: "vk-empty", text: "No cached DMs." }));
return;
}
for (const dm of state.dms.slice(0, 8)) root.append(dmRow(dm));
}
function renderCallStrip() {
const strip = $("#call-strip");
const call = state.voiceSelf;
if (!call?.channelId || call.channelId === state.channelId) {
strip.hidden = true;
strip.replaceChildren();
return;
}
strip.replaceChildren(
el(
"span",
{ class: "vk-call-title" },
el("span", { class: "vk-call-live" }),
el("span", { class: "vk-call-name", text: call.channelName ?? call.channelId }),
el("span", { class: "vk-mono vk-dim", text: `${call.members?.length ?? 0} in call` })
),
el(
"div",
{ class: "vk-call-pills" },
...(call.members ?? []).map(member =>
el(
"span",
{ class: "vk-strip-pill" },
avatar(member, { size: "sm" }),
memberNameNode(member, "vk-strip-name"),
voiceMarks(member)
)
)
),
el(
"div",
{ class: "vk-call-actions" },
el("button", {
class: "btn btn-secondary vk-small-btn",
type: "button",
text: "Return to call",
onclick: () => void selectChannel(call.channelId)
}),
el("button", {
class: "btn btn-secondary vk-small-btn",
type: "button",
text: "Leave",
onclick: () => voiceAction("/api/voice/leave")
})
)
);
strip.hidden = false;
}
async function joinVoice(channelId) {
if (state.voiceSelf?.channelId === channelId) {
await voiceAction("/api/voice/leave");
return;
}
await voiceAction("/api/voice/join", { channelId });
}
async function voiceAction(path, body) {
try {
await VK.api.post(path, body ?? {});
toast("Voice action accepted", "ok");
await loadVoice();
} catch (error) {
toast(error.message, "error");
}
}
/* Console */
function eventLine(entry) {
return el(
"div",
{ class: "vk-event-line" },
el("span", { class: "vk-event-time", text: formatClock(entry.at) }),
el("span", { class: "vk-event-type", text: entry.event.type ?? "event" }),
el("span", { text: JSON.stringify(entry.event) })
);
}
function renderEvents() {
const log = $("#event-log");
if (!log) return;
log.replaceChildren(...state.events.slice(0, 200).map(eventLine));
}
function addEvent(event) {
state.events.unshift({ at: Date.now(), event });
if (state.events.length > 300) state.events.pop();
state.eventCount++;
const log = $("#event-log");
if (log) {
log.prepend(eventLine(state.events[0]));
while (log.childElementCount > 200) log.lastElementChild.remove();
}
const ticker = $("#ticker-line");
if (ticker) ticker.textContent = `${event.type ?? "event"} ${JSON.stringify(event).slice(0, 180)}`;
syncConsoleCount();
}
function syncConsoleCount() {
const count = $("#console-count");
if (count) count.textContent = `${state.eventCount} events`;
}
function setConsoleOpen(open) {
state.consoleOpen = open;
$("#console-drawer").hidden = !open;
$("#console-caret").innerHTML = VK.icon(open ? "caretDown" : "caretUp");
}
function setConsoleTab(tab) {
state.consoleTab = tab;
for (const button of $$(".vk-console-tab")) button.classList.toggle("active", button.dataset.consoleTab === tab);
for (const panel of $$(".vk-console-panel")) panel.hidden = panel.id !== `console-panel-${tab}`;
if (!state.consoleOpen) setConsoleOpen(true);
if (tab === "transcript") void refreshTranscript();
}
/* Mobile layout (1c): one pane at a time behind a four-tab bar. */
const MOBILE_QUERY = window.matchMedia("(max-width: 768px)");
const MOBILE_TABS = new Set(["users", "chat", "call", "activity"]);
function isMobileLayout() {
return MOBILE_QUERY.matches;
}
function setMobileTab(tab) {
if (!MOBILE_TABS.has(tab)) return;
state.mobileTab = tab;
localStorage.setItem("veskslop-mobile-tab", tab);
for (const button of $$(".vk-tab")) button.classList.toggle("active", button.dataset.mobileTab === tab);
if (isMobileLayout()) $(".vk-app").dataset.mobileTab = tab;
// The chat panel was hidden during the last render: put the view back on the
// newest message.
if (tab === "chat" && state.messagesPinned) pinMessagesToBottom();
renderRightVoice();
updateMobileBadge();
}
function syncMobileLayout() {
const app = $(".vk-app");
if (!app) return;
if (isMobileLayout()) app.dataset.mobileTab = state.mobileTab;
else delete app.dataset.mobileTab;
for (const button of $$(".vk-tab")) {
button.classList.toggle("active", button.dataset.mobileTab === state.mobileTab);
}
}
function updateMobileBadge() {
const badgeEl = $("#mobile-chat-badge");
if (!badgeEl) return;
const total = state.dms.reduce((sum, dm) => sum + ((dm.unread ?? 0) || (dm.mentions ?? 0)), 0);
badgeEl.hidden = total === 0;
badgeEl.textContent = total > 99 ? "99+" : String(total);
}
/* Settings */
function openSettings() {
$("#settings-overlay").hidden = false;
void refreshCoreStatus();
void refreshAuth();
void refreshLogs();
void refreshAudio();
}
function closeSettings() {
$("#settings-overlay").hidden = true;
}
function statusRow(label, value) {
return el(
"div",
{ class: "vk-status-row" },
el("span", { class: "vk-status-label", text: label }),
el("span", { class: "vk-status-value" }, value)
);
}
function badge(text, kind = "") {
return el("span", { class: `tag ${kind === "ok" ? "tag-accent" : "tag-neutral"}`, text });
}
function renderCoreStatus(status) {
const card = $("#core-status");
const process = status.process ?? {};
const rows = [
statusRow("bridge", badge(status.bridgeConnected ? "connected" : "disconnected", status.bridgeConnected ? "ok" : "")),
statusRow("process", badge(process.running ? "running" : "stopped", process.running ? "ok" : "")),
statusRow("pid", el("code", { text: process.pid ?? "—" })),
statusRow("managed", el("span", { text: process.managed ? "yes" : process.running ? "external" : "no" })),
statusRow("started", el("span", { text: process.startedAt ? formatDateTime(process.startedAt) : "—" })),
statusRow("command", el("code", { text: process.command ?? "—" }))
];
if (process.lastExit) {
const exit = process.lastExit;
const text = exit.signal ? `signal ${exit.signal}` : `code ${exit.code ?? "?"}`;
rows.push(statusRow("last exit", badge(text, exit.code === 0 ? "ok" : "")));
}
card.replaceChildren(...rows);
if (process.output?.length) {
card.append(el("pre", { class: "vk-core-output", text: process.output.join("\n") }));
}
}
async function refreshCoreStatus() {
try {
renderCoreStatus(await VK.api.get("/api/core/status"));
} catch (error) {
toast(error.message, "error");
}
}
async function coreAction(path, label) {
try {
renderCoreStatus(await VK.api.post(path));
toast(`${label} requested`, "ok");
void refreshHealth();
} catch (error) {
toast(error.message, "error");
}
}
function renderAuth(status) {
const card = $("#auth-status");
const session = status.session;
const rows = [
statusRow("token file", el("code", { text: status.tokenFile })),
statusRow("token", badge(status.hasToken ? "present" : "missing", status.hasToken ? "ok" : "")),
statusRow("session", el("span", { text: session?.state ?? "none" }))
];
if (session?.user) rows.push(statusRow("user", el("span", { text: `${session.user.tag} — ${session.user.id}` })));
if (session?.captcha) rows.push(statusRow("captcha sitekey", el("code", { text: session.captcha.sitekey ?? "—" })));
if (session?.error) rows.push(statusRow("error", el("span", { text: session.error })));
card.replaceChildren(...rows);
const qr = $("#auth-qr");
if (status.qrDataUrl) {
qr.replaceChildren(el("img", { src: status.qrDataUrl, alt: "Discord login QR" }));
qr.hidden = false;
} else {
qr.replaceChildren();
qr.hidden = true;
}
$("#auth-captcha").hidden = session?.state !== "captcha";
}
async function refreshAuth() {
try {
renderAuth(await VK.api.get("/api/auth"));
} catch (error) {
toast(error.message, "error");
}
}
async function authAction(path, label) {
try {
renderAuth(await VK.api.post(path));
if (label) toast(label, "ok");
} catch (error) {
toast(error.message, "error");
}
}
async function refreshLogs() {
const root = $("#action-log");
try {
const entries = await VK.api.get("/api/logs?limit=100");
root.replaceChildren();
if (!entries.length) {
root.append(el("div", { class: "vk-empty", text: "Action log is empty." }));
return;
}
for (const entry of [...entries].reverse()) {
root.append(
el(
"div",
{ class: "vk-log-line" },
el("span", { class: "vk-log-time", text: entry.timestamp ? formatClock(entry.timestamp) : "" }),
el("span", { class: "vk-log-type", text: entry.type ?? "?" }),
el("span", { text: JSON.stringify(entry).slice(0, 220) })
)
);
}
} catch (error) {
root.replaceChildren(el("div", { class: "vk-empty", text: error.message }));
}
}
/* Audio */
function fillDeviceSelect(select, devices, defaultLabel) {
const current = select.value;
select.replaceChildren();
if (!devices.length) {
select.append(el("option", { value: "", text: "No devices reported", disabled: true }));
return;
}
select.append(el("option", { value: "", text: defaultLabel }));
for (const device of devices) {
select.append(el("option", { value: device.id, text: device.label }));
}
if ([...select.options].some(option => option.value === current)) select.value = current;
}
// The daemon plays asynchronously; keep the panel honest about queued vs speaking vs done.
let speechTracker = 0;
async function trackSpeech() {
const token = ++speechTracker;
for (let attempt = 0; attempt < 1200; attempt++) {
await new Promise(resolve => setTimeout(resolve, 500));
if (token !== speechTracker) return;
try {
const status = await VK.api.get("/api/audio");
if (token !== speechTracker) return;
const label = $("#tts-status");
if (status?.playing === true) {
label.textContent = "speaking\u2026";
continue;
}
const queue = Number(status?.queue ?? 0);
if (queue > 0) {
label.textContent = `queued (${queue})`;
continue;
}
label.textContent = "done";
setTimeout(() => {
if (token === speechTracker && $("#tts-status").textContent === "done") {
$("#tts-status").textContent = "idle";
}
}, 4000);
return;
} catch {
return;
}
}
}
async function refreshAudio() {
try {
const status = await VK.api.get("/api/audio");
state.audioRecording = !!status.capturing;
$("#audio-status").textContent = status.available
? `ok · ${status.backend ?? "pulse"} · duck ${status.duckPercent}%${status.capturing ? " · capturing" : ""}`
: (status.error ?? "unavailable");
const hintRow = $("#audio-hint-row");
hintRow.hidden = !status.hint;
$("#audio-hint").textContent = status.hint ?? "";
const tts = status.tts ?? {};
$("#audio-tts").textContent = `local ${tts.local ? "yes" : "no"} · api ${
tts.api ? "yes" : "no"
} · player ${status.player ? "yes" : "no"} · queue ${status.queue ?? 0}`;
$("#audio-capture").textContent = status.capturing ? "Stop capture" : "Start capture";
const duck = $("#tts-duck");
if (duck) {
const on = status.duckEnabled !== false;
duck.classList.toggle("vk-chip-on", on);
duck.classList.toggle("vk-chip-off", !on);
duck.textContent = on ? "duck on voice" : "duck off";
}
} catch (error) {
$("#audio-status").textContent = error.message;
}
await refreshBrowserAudio();
await refreshSpeech();
}
/* Command wakeword (Settings -> Commands). */
const WAKEWORD_CHOICES = ["!s", "!x", "!c", "!z", "!q", "!e", "!r", "!f", "!v", "!t", "!g", "!b"];
function fillWakewordControls() {
const wakeword = state.commands?.wakeword || "!s";
const select = $("#wakeword-select");
const customRow = $("#wakeword-custom-row");
if (!select || !customRow) return;
if (WAKEWORD_CHOICES.includes(wakeword)) {
select.value = wakeword;
customRow.hidden = true;
} else {
select.value = "custom";
$("#wakeword-custom").value = wakeword;
customRow.hidden = false;
}
$("#wakeword-status").textContent = wakeword;
}
async function saveWakeword(value) {
const wakeword = String(value ?? "").trim();
if (!wakeword || wakeword.length > 8 || /\s/.test(wakeword)) {
toast("the wakeword must be 1-8 characters with no spaces", "error");
fillWakewordControls();
return;
}
try {
const prefs = await VK.api.post("/api/preferences", { commands: { wakeword } });
state.commands = prefs.commands ?? { wakeword };
fillWakewordControls();
} catch (error) {
toast(`wakeword save failed: ${error.message}`, "error");
fillWakewordControls();
}
}
/* Discord voice processing gates (Settings -> TTS panel). */
function fillVoiceGates(voice) {
const gates = voice ?? {};
for (const [id, key] of [
["#vp-echo", "echoCancellation"],
["#vp-agc", "automaticGainControl"],
["#vp-nc", "noiseCancellation"],
["#vp-bypass", "bypassWhileSpeaking"]
]) {
const box = $(id);
if (box) box.checked = gates[key] === true;
}
}
async function saveVoiceGates() {
const voice = {
noiseSuppression: false,
echoCancellation: $("#vp-echo").checked,
automaticGainControl: $("#vp-agc").checked,
noiseCancellation: $("#vp-nc").checked,
bypassWhileSpeaking: $("#vp-bypass").checked
};
try {
const prefs = await VK.api.post("/api/preferences", { voice });
fillVoiceGates(prefs.voice ?? voice);
} catch (error) {
toast(`voice processing save failed: ${error.message}`, "error");
}
}
/* Speech settings (server-side preferences). */
async function refreshSpeech() {
let engines = [];
let defaultProvider = "local";
try {
const status = await VK.api.get("/api/audio");
const tts = status.tts ?? {};
const stt = status.stt ?? {};
engines = tts.engines ?? [];
defaultProvider = tts.defaultProvider ?? "local";
$("#speech-status").textContent = `${tts.engine ?? "no local engine"}${tts.api ? " + api" : ""}`;
$("#speech-stt").textContent = stt.local
? `local ready${stt.engine ? ` (${stt.engine})` : ""}`
: stt.api
? "API configured"
: "not configured";
} catch {
// the daemon status is optional here
}
try {
const prefs = await VK.api.get("/api/preferences");
state.speech = prefs.tts ?? { provider: null, language: null, voice: null, speed: null, pitch: null };
state.commands = prefs.commands ?? { wakeword: "!s" };
fillVoiceGates(prefs.voice);
fillWakewordControls();
} catch {
// preferences are optional
}
const selected = resolveEngine(state.speech.provider, engines, defaultProvider);
const selectedEngine = engines.find(engine => engine.id === selected);
const hasLanguage = selectedEngine?.language !== false;
fillEngineOptions($("#speech-provider"), engines, state.speech.provider ?? selected);
fillEngineOptions($("#tts-provider"), engines, state.speech.provider ?? selected);
let groups = [];
try {
groups = await VK.api.get(`/api/audio/tts/voices?engine=${encodeURIComponent(selected)}`);
} catch {
// voices need an engine
}
const languageSelect = $("#speech-language");
languageSelect.replaceChildren(el("option", { value: "", text: "Auto" }));
for (const group of groups) {
languageSelect.append(el("option", { value: group.id, text: `${group.name} (${group.id})` }));
}
languageSelect.value = state.speech.language ?? "";
$("#speech-language-row").hidden = !hasLanguage;
const voices = voiceList(groups, hasLanguage ? state.speech.language : null, hasLanguage);
fillVoiceSelect($("#speech-voice"), voices, state.speech.voice);
fillVoiceSelect($("#tts-voice"), voices, state.speech.voice);
syncVoiceControls(voices, state.speech.voice);
fillSpeechSliders(state.speech);
}
/** Resolve the engine that will actually run: provider override first, then the auto default. */
function resolveEngine(provider, engines, defaultProvider) {
if (provider === "espeak" || provider === "command" || provider === "api") return provider;
if (defaultProvider === "espeak" || defaultProvider === "command" || defaultProvider === "api") return defaultProvider;
const available = id => engines.some(engine => engine.id === id && engine.available);
if (available("command")) return "command";
if (available("espeak")) return "espeak";
return "api";
}
function fillEngineOptions(select, engines, value) {
if (!select) return;
select.replaceChildren(el("option", { value: "", text: "Auto" }));
for (const engine of engines) {
const option = el("option", {
value: engine.id,
text: engine.available ? engine.name : `${engine.name} (unavailable)`
});
option.disabled = !engine.available;
select.append(option);
}
select.value = value;
}
function voiceList(groups, language, hasLanguage) {
if (!hasLanguage) return (groups ?? []).flatMap(group => group.voices ?? []);
if (!language) return [];
return groups?.find(group => group.id === language)?.voices ?? [];
}
function fillVoiceSelect(select, voices, selected) {
if (!select) return;
select.replaceChildren(el("option", { value: "", text: "Default" }));
for (const voice of voices) {
const label = `${voice.name}${voice.gender ? ` · ${voice.gender}` : ""}`;
select.append(el("option", { value: voice.id, text: label }));
}
select.value = selected && voices.some(voice => voice.id === selected) ? selected : "";
}
function fillVoiceOptions(groups, language, selected) {
fillVoiceSelect($("#speech-voice"), voiceList(groups, language, !!language), selected);
}
async function saveSpeech(extra = {}) {
const provider = $("#speech-provider").value || null;
const language = $("#speech-language-row").hidden
? (state.speech.language ?? null)
: $("#speech-language").value || null;
const voice = $("#speech-voice").value || null;
const speed = Number($("#tts-speed").value);
const pitch = Number($("#tts-pitch").value);
const expressionEl = $("#tts-expression");
const expression =
expressionEl.dataset.dirty === "1" || typeof state.speech.expression === "number"
? Number(expressionEl.value)
: null;
try {
const prefs = await VK.api.post("/api/preferences", {
tts: { provider, language, voice, speed, pitch, expression, ...effectOptions(), ...extra }
});
state.speech = prefs.tts ?? {
provider: null,
language: null,
voice: null,
speed: null,
pitch: null,
expression: null
};
$("#tts-provider").value = state.speech.provider ?? "";
fillSpeechSliders(state.speech);
await refreshSpeech();
toast("Speech settings saved", "ok");
} catch (error) {
toast(error.message, "error");
}
}
async function warmEngine() {
const button = $("#speech-warm");
const engine = $("#speech-provider").value || null;
const label = $("#speech-provider").selectedOptions[0]?.textContent ?? "engine";
button.disabled = true;
button.textContent = "Starting…";
try {
const result = await VK.api.post("/api/audio/tts/warm", engine ? { engine } : {});
toast(result.warmed ? `${label} ready in ${result.ms} ms` : `${label} needs no warm-up`, "ok");
$("#speech-status").textContent = result.warmed ? `${label} ready (${result.ms} ms)` : label;
} catch (error) {
toast(error.message, "error");
} finally {
button.disabled = false;
button.textContent = "Start engine";
}
}
function fillSpeechSliders(tts) {
const speed = typeof tts?.speed === "number" ? tts.speed : 1;
const pitch = typeof tts?.pitch === "number" ? tts.pitch : 0;
const volume = typeof tts?.volume === "number" ? tts.volume : 100;
$("#tts-speed").value = Number.isFinite(speed) ? speed : 1;
$("#tts-pitch").value = Number.isFinite(pitch) ? pitch : 0;
$("#tts-volume").value = Number.isFinite(volume) ? volume : 100;
syncSpeechSliderLabels();
}
function syncSpeechSliderLabels() {
const speed = Number($("#tts-speed").value);
const pitch = Number($("#tts-pitch").value);
const pitchSlider = $("#tts-pitch");
$("#tts-speed-value").textContent = `${speed.toFixed(2)}x`;
$("#tts-pitch-value").textContent = pitchSlider.disabled ? "n/a" : `${pitch > 0 ? "+" : ""}${pitch} st`;
$("#tts-volume-value").textContent = `${Number($("#tts-volume").value)}%`;
const expressionSlider = $("#tts-expression");
if (expressionSlider) {
const explicit = expressionSlider.dataset.dirty === "1" || expressionSlider.dataset.stored === "1";
$("#tts-expression-value").textContent = explicit ? expressionSlider.value : "default";
}
for (const button of $$("#tts-speed-presets .vk-chip")) {
const active = Math.abs(Number(button.dataset.speed) - speed) < 0.001;
button.classList.toggle("vk-chip-on", active);
button.classList.toggle("vk-chip-off", !active);
}
}
/* Flextalk profile controls (VOX/HEV kit): profile values, effects, word mode. */
const PROFILE_SPEC = [
["pitch", 0, 120],
["rate", 0, 100],
["prominences", 0, 100],
["head_size", 0, 100],
["lowering", 0, 100]
];
function buildProfileSliders() {
const root = $("#tts-profile-sliders");
if (!root) return;
for (const [key, min, max] of PROFILE_SPEC) {
const slider = el("input", { type: "range", min: String(min), max: String(max), step: "1" });
slider.id = `tts-profile-${key}`;
slider.dataset.key = key;
const value = el("span", { class: "vk-mono vk-dim", text: "default" });
root.append(
el(
"div",
{ class: "vk-tts-profile-row" },
el("span", { class: "vk-micro-label", text: key.replace("_", " ") }),
slider,
value
)
);
slider.addEventListener("input", () => {
const engineDefault = state.profileDefaults?.[key];
const snapped = typeof engineDefault === "number" && Math.abs(Number(slider.value) - engineDefault) <= 2;
if (snapped) {
slider.value = engineDefault;
slider.dataset.dirty = "";
value.textContent = "default";
} else {
slider.dataset.dirty = "1";
value.textContent = slider.value;
}
});
slider.addEventListener("change", () => void saveSpeech());
}
}
function profileFromControls() {
const profile = {};
for (const [key] of PROFILE_SPEC) {
const slider = $(`#tts-profile-${key}`);
if (slider?.dataset.dirty === "1") profile[key] = Number(slider.value);
}
return profile;
}
function fillProfileControls() {
const stored = state.speech.profile ?? {};
fillProfileSlots();
for (const [key] of PROFILE_SPEC) {
const slider = $(`#tts-profile-${key}`);
if (!slider) continue;
if (typeof stored[key] === "number") {
slider.value = stored[key];
slider.dataset.dirty = "1";
} else {
const engineDefault = state.profileDefaults?.[key];
if (typeof engineDefault === "number") slider.value = engineDefault;
slider.dataset.dirty = "";
}
const value = slider.parentElement?.querySelector(".vk-mono");
if (value) value.textContent = slider.dataset.dirty === "1" ? slider.value : "default";
}
const highpass = Number(state.speech.highpass);
$("#tts-highpass").value = Number.isFinite(highpass) && state.speech.highpass !== null ? highpass : 40;
$("#tts-highpass-value").textContent = `${$("#tts-highpass").value}Hz`;
const echo = Number(state.speech.echo);
$("#tts-echo").value = Number.isFinite(echo) && state.speech.echo !== null ? echo : 0;
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
$("#tts-echo-delay").value = state.speech.echoDelayMs ?? "";
$("#tts-echo-level").value = state.speech.echoLevel ?? "";
$("#tts-crunch").value = state.speech.crunch ?? "";
$("#tts-crunch-bits").value = state.speech.crunchBits ?? "";
$("#tts-crunch-rate").value = state.speech.crunchRate ?? "";
$("#tts-stream-words").checked = state.speech.streamWords !== false;
$("#tts-words").checked = state.speech.words === true;
$("#tts-vox-clean").checked = state.speech.voxClean === true;
const gap = Number(state.speech.wordGapMs);
$("#tts-word-gap").value = Number.isFinite(gap) && state.speech.wordGapMs !== null ? gap : 120;
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
}
function fillProfileSlots() {
const select = $("#tts-profile-slot");
if (!select) return;
const presets = state.speech.profilePresets ?? [];
const current = select.value || "0";
select.replaceChildren(
...Array.from({ length: 5 }, (_, index) => {
const preset = presets[index];
const label = preset ? `${index + 1} · ${preset.name ?? "profile"}` : `${index + 1} · empty`;
return el("option", { value: String(index), text: label });
})
);
select.value = current;
const preset = presets[Number(select.value)];
const name = $("#tts-profile-name");
if (name) name.value = preset?.name ?? "";
}
function effectOptions() {
return {
profile: profileFromControls(),
highpass: Number($("#tts-highpass").value),
echo: Number($("#tts-echo").value),
volume: Number($("#tts-volume").value),
words: $("#tts-words").checked,
wordGapMs: Number($("#tts-word-gap").value),
// VOX and the Half-Life profile are part of the `vox`/`hev` voices now.
vox: false,
preset: null,
voxClean: $("#tts-vox-clean").checked,
echoDelayMs: $("#tts-echo-delay").value ? Number($("#tts-echo-delay").value) : null,
echoLevel: $("#tts-echo-level").value ? Number($("#tts-echo-level").value) : null,
crunch: $("#tts-crunch").value || null,
crunchBits: $("#tts-crunch-bits").value ? Number($("#tts-crunch-bits").value) : null,
crunchRate: $("#tts-crunch-rate").value ? Number($("#tts-crunch-rate").value) : null,
streamWords: $("#tts-stream-words").checked
};
}
function syncVoiceControls(voices, selected) {
const voice = (voices ?? []).find(entry => entry.id === selected);
const pitchable = !voice || voice.supportsPitch !== false;
// The Half-Life voices carry VOX prosody; their clean toggle lives in the profile area.
const voxOptions = $("#tts-vox-options");
if (voxOptions) voxOptions.hidden = selected !== "vox";
$("#tts-pitch-control").hidden = !pitchable;
$("#tts-pitch").disabled = !pitchable;
const expressive = !!voice?.supportsExpression;
state.profileDefaults = voice?.profileDefaults ?? {};
state.expressionDefault = typeof voice?.expressionDefault === "number" ? voice.expressionDefault : null;
if (expressive) fillProfileControls();
{
const slider = $("#tts-expression");
const pref = state.speech.expression;
if (typeof pref === "number") {
slider.value = pref;
} else if (typeof voice.expressionDefault === "number") {
slider.value = voice.expressionDefault;
} else {
slider.value = 50;
}
slider.dataset.stored = typeof pref === "number" ? "1" : "0";
delete slider.dataset.dirty;
}
syncSpeechSliderLabels();
}
/* Browser audio (puppeting) — separate from the bot's own audio devices. */
async function refreshBrowserAudio() {
const hint = $("#browser-hint");
const output = $("#browser-output");
if (!navigator.mediaDevices?.enumerateDevices) {
hint.textContent = "Browser audio needs HTTPS or localhost.";
output.disabled = true;
return;
}
hint.textContent = "The microphone will be used for talk-through later.";
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const outputs = devices
.filter(device => device.kind === "audiooutput")
.map(device => ({ id: device.deviceId, label: device.label || "Output device" }));
fillDeviceSelect(output, outputs, "Browser default");
output.value = state.clientAudio.sinkId;
if (!outputs.length) {
hint.textContent = "No devices listed. Press Enable browser audio and allow the microphone prompt.";
}
} catch (error) {
hint.textContent = error.message;
}
}
async function grantBrowserAudio() {
if (await ensureBrowserAudio()) toast("Browser audio enabled", "ok");
}
/** Get the browser ready for playback: permission, device labels, and a live
* AudioContext. Must run inside the click so the context is not suspended. */
async function ensureBrowserAudio() {
if (!navigator.mediaDevices?.getUserMedia) {
toast("Browser audio needs HTTPS or localhost", "error");
return false;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
for (const track of stream.getTracks()) track.stop();
} catch (error) {
toast(`Could not enable browser audio: ${error.message}`, "error");
return false;
}
if (!state.clientAudio.ctx) {
state.clientAudio.ctx = new AudioContext({ sampleRate: 48000 });
applyBrowserSink();
}
if (state.clientAudio.ctx.state === "suspended") {
await state.clientAudio.ctx.resume().catch(error => {
console.warn("[client-audio] could not resume the audio context:", error);
});
}
await refreshBrowserAudio();
return true;
}
function applyBrowserSink() {
const context = state.clientAudio.ctx;
if (!context || typeof context.setSinkId !== "function") return;
context.setSinkId(state.clientAudio.sinkId || "").catch(error => {
console.warn("[client-audio] could not set the output device:", error);
});
}
function playPcm(arrayBuffer) {
if (!state.clientAudio.ctx) {
state.clientAudio.ctx = new AudioContext({ sampleRate: 48000 });
applyBrowserSink();
}
const context = state.clientAudio.ctx;
if (context.state === "suspended") void context.resume();
const samples = new Int16Array(arrayBuffer);
const buffer = context.createBuffer(1, samples.length, 48000);
const data = buffer.getChannelData(0);
for (let index = 0; index < samples.length; index++) data[index] = samples[index] / 32768;
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
const now = context.currentTime;
if (state.clientAudio.nextTime < now + 0.05) state.clientAudio.nextTime = now + 0.05;
source.start(state.clientAudio.nextTime);
state.clientAudio.nextTime += buffer.duration;
}
function updateClientAudioButton() {
const button = $("#client-audio");
if (!button) return;
button.classList.toggle("active", state.clientAudio.on);
const label = button.querySelector(".vk-btn-text");
const text = state.clientAudio.on ? "Client audio on" : "Client audio";
if (label) label.textContent = text;
else button.textContent = text;
const status = $("#browser-audio-status");
if (status) status.textContent = state.clientAudio.on ? "on" : "off";
}
function startClientAudio() {
const socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/audio`);
socket.binaryType = "arraybuffer";
state.clientAudio.ws = socket;
socket.onopen = () => socket.send(JSON.stringify({ type: "listen", on: true }));
socket.onmessage = event => {
if (typeof event.data === "string") {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "error") {
toast(message.error, "error");
stopClientAudio();
} else if (message.type === "status" && message.listening) {
state.clientAudio.on = true;
updateClientAudioButton();
} else if (message.type === "status" && !message.listening) {
stopClientAudio();
}
return;
}
if (state.clientAudio.on) playPcm(event.data);
};
socket.onclose = () => {
state.clientAudio.ws = null;
state.clientAudio.on = false;
updateClientAudioButton();
};
}
function stopClientAudio() {
const socket = state.clientAudio.ws;
state.clientAudio.ws = null;
state.clientAudio.on = false;
if (socket) {
try {
socket.send(JSON.stringify({ type: "listen", on: false }));
} catch {
// already closing
}
try {
socket.close();
} catch {
// already closed
}
}
updateClientAudioButton();
}
async function toggleClientAudio() {
if (state.clientAudio.on || state.clientAudio.ws) {
stopClientAudio();
return;
}
// Clicking for audio is the consent: unlock the browser in the same gesture
// so playback is not blocked by a suspended AudioContext.
if (!(await ensureBrowserAudio())) return;
startClientAudio();
}
function syncTtsControls() {
const speak = $("#tts-speak");
if (speak) speak.disabled = !state.takeover;
}
async function setCapture(start) {
try {
await VK.api.post("/api/audio/capture", { start });
toast(start ? "Capture started" : "Capture stopped", "ok");
await refreshTranscript();
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
}
async function speakText() {
if (!state.takeover) {
toast("Composer is read-only — turn Takeover on to speak", "error");
return;
}
const text = $("#tts-input").value.trim();
if (!text) return;
try {
const provider = $("#tts-provider").value;
const speed = Number($("#tts-speed").value);
const pitch = Number($("#tts-pitch").value);
const expressionEl = $("#tts-expression");
const expression =
expressionEl.dataset.dirty === "1" ? Number(expressionEl.value) : state.speech.expression ?? undefined;
await VK.api.post("/api/audio/speak", {
text,
provider,
speed,
pitch,
...(typeof expression === "number" ? { expression } : {}),
...effectOptions()
});
$("#tts-status").textContent = "queued";
toast("Queued for speech", "ok");
void refreshAudio();
void trackSpeech();
} catch (error) {
toast(error.message, "error");
}
}
async function previewSpeech() {
const text = $("#tts-input").value.trim();
if (!text) return;
const button = $("#tts-preview");
button.disabled = true;
try {
const provider = $("#tts-provider").value;
const speed = Number($("#tts-speed").value);
const pitch = Number($("#tts-pitch").value);
const expressionEl = $("#tts-expression");
const expression =
expressionEl.dataset.dirty === "1" ? Number(expressionEl.value) : state.speech.expression ?? undefined;
const result = await VK.api.post("/api/audio/preview", {
text,
provider,
speed,
pitch,
...(typeof expression === "number" ? { expression } : {}),
...effectOptions()
});
const audio = new Audio(result.url);
audio.volume = Math.min(1, Math.max(0, Number($("#tts-volume").value) / 100));
await audio.play();
} catch (error) {
toast(error.message, "error");
} finally {
button.disabled = false;
}
}
/* Transcript */
function transcriptLine(segment) {
const time = formatClock(segment.startedAt);
const speaker = segment.speakerName ?? segment.speakerId ?? "call";
const text = segment.text ?? `(audio ${(segment.durationMs / 1000).toFixed(1)}s, no STT)`;
return el(
"div",
{ class: "vk-transcript-line", "data-segment": segment.id },
el("span", { class: "vk-transcript-time", text: time }),
el("span", { class: "vk-transcript-speaker", text: speaker }),
el("span", { class: "vk-transcript-text", text }),
segment.wav ? el("span", { class: "vk-transcript-wav", text: segment.wav.split("/").pop() }) : null
);
}
async function refreshTranscript() {
try {
const result = await VK.api.get("/api/transcript?limit=200");
const capture = await VK.api.get("/api/audio").catch(() => null);
const list = $("#transcript-list");
list.replaceChildren();
const segments = result.segments ?? [];
if (!segments.length) {
list.append(
el("div", {
class: "vk-empty",
text: "No captured segments yet. Press Start above."
})
);
} else {
for (const segment of segments) list.append(transcriptLine(segment));
list.scrollTop = list.scrollHeight;
}
const active = [...(result.sessions ?? [])].reverse().find(session => !session.endedAt);
const captureState = capture?.capturing ? "capturing" : "capture off";
$("#transcript-status").textContent = active
? `${captureState} · session ${active.id.slice(-8)} · ${segments.length} segments`
: `${captureState} · ${segments.length} segments`;
} catch (error) {
toast(error.message, "error");
}
}
/* Health */
async function refreshHealth() {
try {
const health = await VK.api.get("/api/health");
state.health = health;
const now = Date.now();
if (state.lastEventSample) {
const seconds = (now - state.lastEventSample.at) / 1000;
const delta = (health.events ?? 0) - state.lastEventSample.events;
if (seconds > 0) state.eventRate = delta / seconds;
}
state.lastEventSample = { at: now, events: health.events ?? 0 };
const dot = $("#health-dot");
dot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`;
$("#health-bridge").textContent = health.coreConnected ? "bridge ok" : "bridge down";
$("#health-uptime").textContent = formatUptime(health.uptimeSeconds);
$("#health-events").textContent = `${state.eventRate.toFixed(1)} ev/s`;
const mcpDot = $("#mcp-dot");
mcpDot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`;
$("#mcp-state").textContent = health.coreConnected ? "mcp connected" : "mcp offline";
const process = health.process ?? {};
const startButton = $("#core-start-banner");
state.daemonDown = false;
document.body.classList.toggle("vk-offline", !health.coreConnected);
if (health.coreConnected) {
startButton.hidden = true;
} else if (process.running) {
startButton.hidden = false;
startButton.disabled = true;
startButton.textContent = "Starting…";
} else {
startButton.hidden = false;
startButton.disabled = false;
startButton.textContent = "Start server";
}
// A missed CORE_STATUS, or a renderer that was not ready yet: keep trying.
if (health.coreConnected && !state.user) void refreshAll();
} catch {
state.daemonDown = true;
document.body.classList.add("vk-offline");
$("#health-dot").className = "vk-dot offline";
$("#health-bridge").textContent = "daemon down";
const startButton = $("#core-start-banner");
if (startButton) {
startButton.hidden = false;
startButton.disabled = false;
startButton.textContent = "Refresh";
}
}
}
/* Live events */
function applyHello(event) {
if (typeof event.coreConnected !== "boolean") return;
$("#health-dot").className = `vk-dot ${event.coreConnected ? "online" : "offline"}`;
$("#health-bridge").textContent = event.coreConnected ? "bridge ok" : "bridge down";
const mcpDot = $("#mcp-dot");
if (mcpDot) mcpDot.className = `vk-dot ${event.coreConnected ? "online" : "offline"}`;
// The page may have loaded before the bridge was up.
if (event.coreConnected && !state.user) void refreshAll();
}
function renderRightFavourites() {
const root = $("#right-favourites");
if (!root) return;
root.replaceChildren();
const users = state.favouriteUsers;
const groups = state.dms.filter(dm => dm.type === 3 && state.favourites.channels.includes(dm.id));
if (!users.length && !groups.length) {
root.append(el("div", { class: "vk-empty", text: "No favourites yet. Star a DM." }));
return;
}
for (const user of users) {
const status = user.channelId ? `in ${user.channelName ?? "a call"}` : user.status;
root.append(
el(
"div",
{ class: "vk-right-row" },
avatar({ id: user.id, username: user.username, avatar: user.avatar }, { size: "sm" }),
el(
"span",
{ class: "vk-right-name" },
el("span", {
class: `vk-name ${state.speaking.has(user.id) ? "speaking" : ""}`.trim(),
text: user.username
}),
el("span", { class: "vk-sub", text: status })
),
el("span", { class: "vk-right-trail" }, starButton(true, () => removeFavouriteUser(user.id)))
)
);
}
for (const dm of groups) root.append(dmRow(dm));
}
async function refreshFavourites() {
try {
const result = await VK.api.get("/api/favourites");
state.favourites = {
users: (result.users ?? []).map(user => user.id),
channels: result.channels ?? []
};
state.favouriteUsers = result.users ?? [];
} catch {
// the core may be disconnected
}
renderRightFavourites();
renderRightVoice();
}
function handleLiveEvent(event) {
if (event.type === "MESSAGE_CREATE" && event.channelId === state.channelId) {
void loadMessages();
}
if (event.type === "TRANSCRIPT_SEGMENT") {
const list = $("#transcript-list");
if (list) {
list.append(transcriptLine(event.segment));
list.scrollTop = list.scrollHeight;
$("#transcript-status").textContent = `live · ${list.childElementCount} segments`;
}
return;
}
if (event.type === "TRANSCRIPT_TEXT") {
const line = document.querySelector(`[data-segment="${event.id}"] .vk-transcript-text`);
if (line) line.textContent = event.text;
return;
}
if (event.type === "TRANSCRIPT_ERROR") {
toast(event.error, "error");
return;
}
if (event.type === "VOICE_SPEAKING") {
if (event.speaking) beginSpeaking(event.userId);
else endSpeaking(event.userId);
return;
}
if (event.type === "VOICE_STATE_UPDATE") {
// A mute or a channel change ends speaking immediately: a muted member
// never transmits, and the stop event may not arrive.
if (!event.channelId || event.mute || event.selfMute) endSpeaking(event.userId);
const now = Date.now();
if (now - state.voiceReloadAt > 1000) {
state.voiceReloadAt = now;
void loadVoice();
}
}
if (event.type === "CORE_STATUS") {
void refreshHealth();
if (event.coreConnected) void refreshAll();
if (!$("#settings-overlay").hidden) void refreshCoreStatus();
}
if (event.type === "AUTH_QR" && !$("#settings-overlay").hidden) {
void refreshAuth();
}
}
/* Wiring */
function init() {
hydrateIcons();
initTimeZone();
const takeover = $("#takeover");
takeover.checked = state.takeover;
$("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover";
takeover.addEventListener("change", () => {
state.takeover = takeover.checked;
localStorage.setItem("veskslop-takeover", state.takeover ? "1" : "0");
$("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover";
renderComposer();
syncTtsControls();
});
$("#guild-picker").addEventListener("click", () => {
const menu = $("#guild-menu");
menu.hidden = !menu.hidden;
if (menu.hidden) return;
menu.replaceChildren(
...visibleGuildsList().map(guild =>
el("button", {
class: `vk-menu-item ${guild.id === state.guildId ? "active" : ""}`,
type: "button",
text: guild.name,
onclick: () => void selectGuild(guild.id)
})
)
);
});
$("#settings-open").addEventListener("click", openSettings);
$("#settings-close").addEventListener("click", closeSettings);
$("#settings-overlay").addEventListener("click", event => {
if (event.target === $("#settings-overlay")) closeSettings();
});
$("#core-start").addEventListener("click", () => void coreAction("/api/core/start", "Start"));
$("#core-stop").addEventListener("click", () => void coreAction("/api/core/stop", "Stop"));
$("#core-restart").addEventListener("click", () => void coreAction("/api/core/restart", "Restart"));
$("#core-start-banner").addEventListener("click", async event => {
if (state.daemonDown) {
location.reload();
return;
}
const button = event.currentTarget;
button.disabled = true;
button.textContent = "Starting…";
try {
await VK.api.post("/api/core/start");
toast("Starting the client…", "ok");
} catch (error) {
toast(error.message, "error");
}
setTimeout(() => void refreshHealth(), 1000);
setTimeout(() => void refreshHealth(), 5000);
});
$("#auth-start").addEventListener("click", () => void authAction("/api/auth/qr", "QR login started"));
$("#auth-cancel").addEventListener("click", () => void authAction("/api/auth/qr/cancel", "Login cancelled"));
$("#auth-restart").addEventListener("click", async () => {
try {
renderCoreStatus(await VK.api.post("/api/core/restart", { forceToken: true }));
toast("Restarting with the token file", "ok");
} catch (error) {
toast(error.message, "error");
}
});
$("#auth-captcha").addEventListener("submit", async event => {
event.preventDefault();
const key = $("#auth-captcha-key").value.trim();
if (!key) return;
try {
await VK.api.post("/api/auth/qr/captcha", { key });
$("#auth-captcha-key").value = "";
toast("Captcha submitted", "ok");
void refreshAuth();
} catch (error) {
toast(error.message, "error");
}
});
$("#logs-refresh").addEventListener("click", () => void refreshLogs());
$("#console-toggle").addEventListener("click", () => setConsoleOpen(!state.consoleOpen));
for (const tab of $$(".vk-console-tab")) {
tab.addEventListener("click", () => setConsoleTab(tab.dataset.consoleTab));
}
for (const tab of $$(".vk-tab")) {
tab.addEventListener("click", () => setMobileTab(tab.dataset.mobileTab));
}
MOBILE_QUERY.addEventListener("change", syncMobileLayout);
syncMobileLayout();
$("#raw-events").addEventListener("click", () => {
setConsoleTab("events");
if (isMobileLayout()) setMobileTab("activity");
});
$("#transcript-refresh").addEventListener("click", () => void refreshTranscript());
$("#transcript-start").addEventListener("click", () => void setCapture(true));
$("#transcript-stop").addEventListener("click", () => void setCapture(false));
for (const id of ["#vp-echo", "#vp-agc", "#vp-nc", "#vp-bypass"]) {
$(id)?.addEventListener("change", () => void saveVoiceGates());
}
$("#wakeword-select")?.addEventListener("change", () => {
const value = $("#wakeword-select").value;
if (value === "custom") {
$("#wakeword-custom").value = WAKEWORD_CHOICES.includes(state.commands?.wakeword)
? "!"
: state.commands?.wakeword || "!";
$("#wakeword-custom-row").hidden = false;
$("#wakeword-custom").focus();
return;
}
void saveWakeword(value);
});
$("#wakeword-custom")?.addEventListener("change", () => void saveWakeword($("#wakeword-custom").value));
$("#wakeword-save")?.addEventListener("click", () => void saveWakeword($("#wakeword-custom").value));
$("#tts-duck").addEventListener("click", async () => {
const on = !$("#tts-duck").classList.contains("vk-chip-on");
try {
await VK.api.post("/api/preferences", { audio: { duck: on } });
toast(on ? "TTS ducking on" : "TTS ducking off", "ok");
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
});
$("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true }));
$("#message-scroll").addEventListener(
"scroll",
() => {
state.messagesPinned = messagesNearBottom();
},
{ passive: true }
);
// Re-pin while the user is at the bottom and late images grow the list.
$("#message-list").addEventListener(
"load",
() => {
if (state.messagesPinned) pinMessagesToBottom();
},
true
);
$("#guilds-show-all").addEventListener("click", () => {
state.visibleGuilds = null;
renderVisibleGuilds();
renderGuildSwitcher();
renderGuildPicker();
});
$("#guilds-hide-all").addEventListener("click", () => {
state.visibleGuilds = new Set();
renderVisibleGuilds();
renderGuildSwitcher();
renderGuildPicker();
});
$("#guilds-save").addEventListener("click", () => void saveGuildVisibility());
const ttsInput = $("#tts-input");
ttsInput.value = localStorage.getItem(TTS_DRAFT_KEY) ?? "";
ttsInput.addEventListener("input", () => localStorage.setItem(TTS_DRAFT_KEY, ttsInput.value));
const ttsProvider = $("#tts-provider");
ttsProvider.addEventListener("change", () => {
$("#speech-provider").value = ttsProvider.value;
void saveSpeech();
});
for (const id of ["tts-speed", "tts-pitch"]) {
$(`#${id}`).addEventListener("input", syncSpeechSliderLabels);
$(`#${id}`).addEventListener("change", () => void saveSpeech());
}
$("#tts-expression").addEventListener("input", () => {
const slider = $("#tts-expression");
const engineDefault = state.expressionDefault;
const snapped =
typeof engineDefault === "number" && Math.abs(Number(slider.value) - engineDefault) <= 2;
if (snapped) {
slider.value = engineDefault;
slider.dataset.dirty = "";
} else {
slider.dataset.dirty = "1";
}
syncSpeechSliderLabels();
});
$("#tts-expression").addEventListener("change", () => void saveSpeech());
buildProfileSliders();
for (const id of ["tts-highpass", "tts-echo", "tts-word-gap", "tts-volume"]) {
$(`#${id}`).addEventListener("input", () => {
$("#tts-highpass-value").textContent = `${$("#tts-highpass").value}Hz`;
$("#tts-echo-value").textContent = Number($("#tts-echo").value) > 0 ? $("#tts-echo").value : "off";
$("#tts-word-gap-value").textContent = `${$("#tts-word-gap").value}ms`;
$("#tts-volume-value").textContent = `${Number($("#tts-volume").value)}%`;
});
$(`#${id}`).addEventListener("change", () => void saveSpeech());
}
$("#tts-words").addEventListener("change", () => void saveSpeech());
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
$("#tts-vox-clean").addEventListener("change", () => void saveSpeech());
for (const id of [
"#tts-echo-delay",
"#tts-echo-level",
"#tts-crunch",
"#tts-crunch-bits",
"#tts-crunch-rate",
"#tts-stream-words"
]) {
$(id).addEventListener("change", () => void saveSpeech());
}
$("#profile-open").addEventListener("click", () => {
const panel = document.querySelector(".vk-profile-panel");
if (panel) {
panel.style.left = "";
panel.style.top = "";
panel.style.transform = "";
}
$("#profile-overlay").hidden = false;
fillProfileControls();
});
$("#profile-close").addEventListener("click", () => {
$("#profile-overlay").hidden = true;
});
$("#profile-overlay").addEventListener("click", event => {
if (window.__profileDragged) {
window.__profileDragged = false;
return;
}
if (event.target === $("#profile-overlay")) $("#profile-overlay").hidden = true;
});
(() => {
const panel = document.querySelector(".vk-profile-panel");
const handle = $("#profile-drag");
if (!panel || !handle) return;
let dragging = false;
let startX = 0;
let startY = 0;
let originX = 0;
let originY = 0;
handle.addEventListener("mousedown", event => {
const rect = panel.getBoundingClientRect();
dragging = true;
startX = event.clientX;
startY = event.clientY;
originX = rect.left;
originY = rect.top;
panel.style.transform = "none";
panel.style.left = `${originX}px`;
panel.style.top = `${originY}px`;
event.preventDefault();
});
window.addEventListener("mousemove", event => {
if (!dragging) return;
if (Math.abs(event.clientX - startX) + Math.abs(event.clientY - startY) > 3) {
window.__profileDragged = true;
}
panel.style.left = `${originX + event.clientX - startX}px`;
panel.style.top = `${originY + event.clientY - startY}px`;
});
window.addEventListener("mouseup", () => {
dragging = false;
});
})();
for (const slot of ["1", "2"]) {
const input = $(`#tts-test-custom-${slot}`);
input.value = localStorage.getItem(`veskslop-test-line-${slot}`) ?? "";
input.addEventListener("input", () =>
localStorage.setItem(`veskslop-test-line-${slot}`, input.value)
);
}
$$(".vk-profile-tests button").forEach(button => {
button.addEventListener("click", async () => {
let text = button.dataset.test ?? "";
if (button.dataset.testCustom) {
text = $(`#tts-test-custom-${button.dataset.testCustom}`).value.trim();
if (!text) {
toast("Custom line is empty", "error");
return;
}
}
try {
const expressionEl = $("#tts-expression");
const expression =
expressionEl.dataset.dirty === "1"
? Number(expressionEl.value)
: state.speech.expression ?? undefined;
await VK.api.post("/api/audio/speak", {
text,
provider: $("#tts-provider").value,
...effectOptions(),
...(typeof expression === "number" ? { expression } : {})
});
toast(`Playing "${text}"`, "ok");
} catch (error) {
toast(error.message, "error");
}
});
});
$("#tts-profile-slot").addEventListener("change", () => {
const preset = (state.speech.profilePresets ?? [])[Number($("#tts-profile-slot").value)];
$("#tts-profile-name").value = preset?.name ?? "";
});
$("#tts-profile-save").addEventListener("click", async () => {
const slot = Number($("#tts-profile-slot").value);
const presets = (state.speech.profilePresets ?? [null, null, null, null, null]).slice(0, 5);
const name = $("#tts-profile-name").value.trim() || `Profile ${slot + 1}`;
presets[slot] = {
name,
...profileFromControls(),
...(typeof state.speech.expression === "number" ? { expression: state.speech.expression } : {})
};
await saveSpeech({ profilePresets: presets });
toast(`Saved "${name}" to slot ${slot + 1}`, "ok");
});
$("#tts-profile-load").addEventListener("click", async () => {
const slot = Number($("#tts-profile-slot").value);
const preset = (state.speech.profilePresets ?? [])[slot];
if (!preset) {
toast(`Slot ${slot + 1} is empty`, "error");
return;
}
for (const [key] of PROFILE_SPEC) {
const slider = $(`#tts-profile-${key}`);
if (!slider || typeof preset[key] !== "number") continue;
slider.value = preset[key];
slider.dataset.dirty = "1";
}
if (typeof preset.expression === "number") {
$("#tts-expression").value = preset.expression;
$("#tts-expression").dataset.dirty = "1";
}
fillProfileControls();
syncSpeechSliderLabels();
await saveSpeech();
toast(`Loaded "${preset.name ?? `Profile ${slot + 1}`}"`, "ok");
});
$("#tts-speed-presets").addEventListener("click", event => {
const button = event.target.closest("button[data-speed]");
if (!button) return;
$("#tts-speed").value = button.dataset.speed;
syncSpeechSliderLabels();
void saveSpeech();
});
syncSpeechSliderLabels();
$("#speech-provider").addEventListener("change", () => void saveSpeech());
$("#speech-warm").addEventListener("click", () => void warmEngine());
$("#speech-language").addEventListener("change", () => {
$("#speech-voice").value = "";
void saveSpeech();
});
$("#speech-voice").addEventListener("change", () => void saveSpeech());
$("#tts-voice").addEventListener("change", () => {
$("#speech-voice").value = $("#tts-voice").value;
void saveSpeech();
});
$("#scope-guild").addEventListener("click", () => void applyScope("guild"));
$("#scope-dm").addEventListener("click", () => void applyScope("dm"));
$("#tts-close").addEventListener("click", () => {
$("#tts-panel").hidden = true;
});
$("#tts-preview").addEventListener("click", () => void previewSpeech());
$("#tts-speak").addEventListener("click", () => void speakText());
$("#tts-stop").addEventListener("click", async () => {
try {
await VK.api.post("/api/audio/stop");
$("#tts-status").textContent = "stopped";
} catch (error) {
toast(error.message, "error");
}
});
$("#audio-refresh").addEventListener("click", () => void refreshAudio());
$("#audio-connect").addEventListener("click", async () => {
try {
const result = await VK.api.post("/api/audio/connect");
toast(result.message ?? (result.connected ? "Devices connected" : "Could not connect"), result.connected ? "ok" : "error");
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
});
$("#client-audio").addEventListener("click", toggleClientAudio);
$("#browser-grant").addEventListener("click", () => void grantBrowserAudio());
$("#browser-output").addEventListener("change", event => {
state.clientAudio.sinkId = event.currentTarget.value;
localStorage.setItem("veskslop-browser-output", state.clientAudio.sinkId);
applyBrowserSink();
});
$("#audio-capture").addEventListener("click", async () => {
try {
const start = !state.audioRecording;
await VK.api.post("/api/audio/capture", { start });
toast(start ? "Capture started" : "Capture stopped", "ok");
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
});
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
if (!$("#settings-overlay").hidden) closeSettings();
else if (state.consoleOpen) setConsoleOpen(false);
});
VK.bus.on("event", event => {
if (event.type === "hello") {
applyHello(event);
return;
}
addEvent(event);
handleLiveEvent(event);
});
VK.connectEvents();
setConsoleTab("events");
setConsoleOpen(false);
syncConsoleCount();
renderRightFavourites();
void refreshAll();
void refreshHealth();
syncTtsControls();
setInterval(refreshHealth, 5000);
setInterval(() => {
if (!$("#tts-panel").hidden) void refreshAudio();
}, 3000);
setInterval(() => {
if (!$("#settings-overlay").hidden) {
if (!$("#auth-qr").hidden || $("#auth-captcha").hidden === false) void refreshAuth();
}
}, 3000);
}
init();