Files
Void-Homelab/public/views/settings.js
root 3bd8ea399c feat: 2.14.0 — Eithan terminal toolbar, voice UX, Dross improvements framework
- Terminal renamed Eithan: mobile font A−/A+ (per-URL ttyd opts), same-origin
  xterm Copy/Paste buttons, scroll-to-live, touch-default 17px
- Dross voice: no keyboard pop after transcribe (fine-pointer only focus),
  autogrow textarea to ~5 lines, live amplitude meter on the mic while recording
- Dross improvements: propose_improvement tool (CSS layer, exfil-sanitized,
  owner-approved, per-improvement rollback/restore), public /improvements.css,
  Settings panel. External MCP registry unchanged (no tool leak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:35:32 +10:00

289 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #/settings — API tokens, agents, and a placeholder for Orthos Mode.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { iconSetsPanel } from './icon_sets_panel.js';
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
import { drossAvatar } from '../components/dross_avatar.js';
// Theming — colour pickers for the palette, live-preview on input, presets +
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
function themingBody() {
const cur = currentTheme(); // saved overrides (subset of vars)
const grid = el('div', { class: 'theme-grid' });
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
function rebuild() {
mount(grid, THEME_VARS.map(v => {
const inp = el('input', { type: 'color', value: cur[v.key] ? toHex6(cur[v.key]) : effectiveHex(v.key) });
inp.addEventListener('input', () => {
cur[v.key] = inp.value;
document.documentElement.style.setProperty(v.css, inp.value); // live preview
});
return el('label', { class: 'theme-row' }, el('span', {}, v.label), inp);
}));
}
rebuild();
const preset = el('select', { class: 'pm-input', style: { maxWidth: '160px' } },
el('option', { value: '' }, 'Apply preset…'),
...Object.keys(PRESETS).map(n => el('option', { value: n }, n)));
preset.addEventListener('change', () => {
if (!preset.value) return;
clearTheme();
for (const k of Object.keys(cur)) delete cur[k];
Object.assign(cur, PRESETS[preset.value]);
applyTheme(cur);
rebuild();
preset.value = '';
});
const save = el('button', { class: 'primary' }, 'Save theme');
save.onclick = async () => {
try { await saveTheme(cur); out.textContent = 'Saved — applies everywhere.'; }
catch { out.textContent = 'Save failed'; }
};
const reset = el('button', { class: 'ghost' }, 'Reset to Blackflame');
reset.onclick = async () => {
for (const k of Object.keys(cur)) delete cur[k];
clearTheme();
try { await saveTheme({}); rebuild(); out.textContent = 'Reset to default.'; }
catch { out.textContent = 'Reset failed'; }
};
return el('div', { class: 'settings-body' },
grid,
el('div', { class: 'theme-actions' }, preset, save, reset, out));
}
function section(title, sub, bodyEl) {
return el('div', { class: 'card settings-card' },
el('h3', {}, title),
sub ? el('p', { class: 'settings-sub muted' }, sub) : null,
bodyEl);
}
async function renderTokens(c) {
c.replaceChildren(el('div', { class: 'muted' }, 'Loading…'));
let tokens = [], agents = [];
try { tokens = await api.get('/api/agent-tokens'); } catch { /* */ }
try { agents = await api.get('/api/agents'); } catch { /* */ }
c.replaceChildren();
const sel = el('select', { class: 'pm-input', style: { maxWidth: '210px' } }, agents.map(a => el('option', { value: a.id }, `${a.name} (${a.slug})`)));
const label = el('input', { class: 'pm-input', placeholder: 'label (optional)', style: { maxWidth: '170px' } });
const out = el('div', { class: 'token-out' });
const mint = el('button', {
class: 'primary',
onclick: async () => {
if (!sel.value) { out.textContent = 'No agents available'; return; }
try {
const r = await api.post('/api/agents/' + sel.value + '/tokens', { label: label.value.trim() || undefined });
out.replaceChildren(el('div', { class: 'token-reveal' }, el('span', {}, 'New token — copy it now, it wont be shown again: '), el('code', {}, r.token)));
const list = await api.get('/api/agent-tokens'); tokens = list; paint();
} catch (e) { out.textContent = 'failed: ' + e.message; }
}
}, 'Mint token');
const listWrap = el('div', {});
function paint() {
listWrap.replaceChildren();
if (!tokens.length) { listWrap.appendChild(el('div', { class: 'muted' }, 'No tokens yet.')); return; }
for (const t of tokens) {
listWrap.appendChild(el('div', { class: 'settings-row' + (t.revoked_at ? ' revoked' : '') },
el('span', { class: 'settings-label' }, t.agent_name || t.agent_slug),
el('span', { class: 'settings-value muted' }, `${t.label || '—'} · ${t.last_used ? 'used ' + new Date(t.last_used).toLocaleDateString() : 'never used'}`),
t.revoked_at
? el('span', { class: 'muted' }, 'revoked')
: el('button', { class: 'proj-btn danger', onclick: async () => { if (!confirm('Revoke this token?')) return; try { await api.del('/api/agent-tokens/' + t.id); renderTokens(c); } catch (e) { alert(e.message); } } }, 'Revoke')));
}
}
c.appendChild(el('div', { class: 'settings-row settings-create' }, sel, label, mint));
c.appendChild(out);
c.appendChild(listWrap);
paint();
}
function fileBlock(parent, label, content) {
parent.appendChild(el('div', { class: 'agent-file-label' }, label));
parent.appendChild(el('div', { class: 'agent-file-content' }, content));
}
async function renderAgents(c) {
c.replaceChildren(el('div', { class: 'muted' }, 'Loading…'));
let agents = [];
try { agents = await api.get('/api/agents'); } catch { /* */ }
c.replaceChildren();
if (!agents.length) { c.appendChild(el('div', { class: 'muted' }, 'No agents.')); return; }
for (const a of agents) {
const caps = Object.entries(a.capabilities || {}).filter(([, v]) => v).map(([k]) => k).join(', ') || '—';
const row = el('div', { class: 'agent-row' });
const body = el('div', { class: 'agent-body hidden' });
let loaded = false;
const hd = el('div', {
class: 'agent-row-hd',
onclick: async () => {
const open = row.classList.toggle('open');
body.classList.toggle('hidden', !open);
if (open && !loaded) {
loaded = true;
body.replaceChildren(el('div', { class: 'muted' }, 'Loading…'));
try {
const p = await api.get('/api/agents/' + a.id + '/profile');
body.replaceChildren();
if (p.persona) fileBlock(body, 'Soul · persona', p.persona);
else body.appendChild(el('div', { class: 'muted' }, 'No persona defined (config-only agent).'));
fileBlock(body, 'Capabilities', JSON.stringify(p.capabilities || {}, null, 2));
if (p.scopes && Object.keys(p.scopes).length) fileBlock(body, 'Scopes', JSON.stringify(p.scopes, null, 2));
body.appendChild(el('div', { class: 'muted', style: { fontSize: '11px', marginTop: '8px' } },
'Void 2 agents are persona-in-code + DB config — no separate memory files (unlike Void 1).'));
} catch (e) { body.replaceChildren(el('div', { class: 'err' }, 'Error: ' + e.message)); }
}
}
},
el('span', { class: 'agent-nm' }, a.name),
el('span', { class: 'settings-value muted' }, `${a.slug} · ${a.kind} · ${caps}`),
el('span', { class: 'agent-row-chev' }, ''));
row.append(hd, body);
c.appendChild(row);
}
}
function drossBody() {
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review', keepClips: false };
const avatarRow = el('div', { class: 'dross-pick' });
const accent = el('input', { type: 'color', value: cur.accent });
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
const mode = el('select', { class: 'pm-input', style: { maxWidth: '200px' } },
el('option', { value: 'review' }, 'Voice: review then send'),
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
const keep = el('input', { type: 'checkbox' });
const clipsWrap = el('div', { class: 'dross-clips' });
function paintAvatars() {
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
const card = el('button', { class: 'dross-avopt' + (cur.avatar === v ? ' on' : ''), title: v },
drossAvatar(v, 48), el('span', {}, v));
card.style.setProperty('--dross', cur.accent);
card.onclick = () => { cur.avatar = v; paintAvatars(); };
return card;
}));
}
async function loadClips() {
if (!keep.checked) { mount(clipsWrap); return; }
let rows = [];
try { rows = await api.get('/api/voice/clips'); } catch { mount(clipsWrap, el('span', { class: 'muted' }, 'Clips unavailable')); return; }
mount(clipsWrap, el('div', { class: 'st-lbl', style: { margin: '10px 0 4px' } }, `Saved clips (${rows.length})`),
...rows.map(c => {
const audio = el('audio');
const play = el('button', { class: 'ghost' }, '▶');
play.onclick = async () => {
try {
const res = await fetch('/api/voice/clips/' + c.id + '/audio',
{ headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') } });
audio.src = URL.createObjectURL(await res.blob()); audio.play();
} catch { /* */ }
};
const del = el('button', { class: 'ghost dv-ignore' }, '✕');
del.onclick = async () => { try { await api.del('/api/voice/clips/' + c.id); loadClips(); } catch { /* */ } };
return el('div', { class: 'dross-clip' }, play, audio,
el('span', { class: 'dross-clip-txt' }, c.transcript || '(no transcript)'),
el('span', { class: 'muted', style: { fontSize: '10px' } },
new Date(c.created_at).toLocaleString('en-AU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })),
del);
}));
}
(async () => {
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode;
keep.checked = !!cur.keepClips; paintAvatars(); loadClips();
})();
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
keep.addEventListener('change', loadClips);
const save = el('button', { class: 'primary' }, 'Save');
save.onclick = async () => {
try {
await api.put('/api/dross/settings', {
avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value, keepClips: keep.checked
});
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
out.textContent = 'Saved.';
} catch { out.textContent = 'Save failed'; }
};
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
persona,
el('label', { class: 'st-lbl' }, keep, ' Keep voice clips (saves audio + transcript, owner-only)'),
el('div', { class: 'theme-actions' }, mode, save, out),
clipsWrap);
}
export async function render(main) {
const tokensBody = el('div', { class: 'settings-body' });
const agentsBody = el('div', { class: 'settings-body' });
// Icon sets — collapsible; panel is lazy-created on first expand so
// /api/icon-sets is not fetched while the section is collapsed.
let isPanel = null;
const iconSetsWrap = el('div', { class: 'settings-body' });
const isToggle = el('button', { class: 'ghost' }, '▸ Icon sets');
isToggle.addEventListener('click', () => {
if (!isPanel) {
// First expand: create and append the panel.
isPanel = iconSetsPanel();
iconSetsWrap.appendChild(isPanel);
}
const open = isPanel.style.display !== 'none';
isPanel.style.display = open ? 'none' : 'block';
isToggle.textContent = (open ? '▸' : '▾') + ' Icon sets';
});
iconSetsWrap.appendChild(isToggle);
// ---- Dross improvements: versioned CSS changes, approve / rollback / restore ----
const improvementsBody = el('div', {});
const STATUS_BADGE = { pending: '⏳ pending', active: '✅ active', rolled_back: '↩ rolled back', rejected: '✕ rejected' };
async function renderImprovements() {
let rows = [];
try { rows = await api.get('/api/improvements'); } catch { /* fresh DB */ }
const act = (id, verb) => async () => {
try { await api.post(`/api/improvements/${id}/${verb}`, {}); } catch { /* surfaced below */ }
renderImprovements();
// re-pull the live stylesheet so the change lands without a page reload
const link = document.getElementById('dross-improvements');
if (link) link.href = '/improvements.css?v=' + Date.now();
};
mount(improvementsBody,
rows.length === 0 ? el('div', { class: 'muted' }, 'Nothing yet. Ask Dross to improve something — each approved change lands here, individually reversible.') : null,
...rows.map((r) => el('div', { class: 'imp-row' },
el('span', { class: 'imp-status s-' + r.status }, STATUS_BADGE[r.status] ?? r.status),
el('div', { class: 'imp-main' },
el('div', {}, r.summary),
el('small', { class: 'muted' }, `${new Date(r.created_at).toLocaleString()} · ${r.css_len} chars of css`)),
el('span', { class: 'imp-actions' },
r.status === 'pending' ? el('button', { class: 'primary sm', onclick: act(r.id, 'approve') }, 'Approve') : null,
r.status === 'pending' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'reject') }, 'Reject') : null,
r.status === 'active' ? el('button', { class: 'ghost sm danger', onclick: act(r.id, 'rollback') }, 'Roll back') : null,
r.status === 'rolled_back' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'restore') }, 'Restore') : null)))
);
}
renderImprovements();
mount(main,
el('h1', { class: 'view-h1' }, '◆ Settings'),
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
section('Dross improvements', 'Changes Dross has made to the Void itself — each one versioned, owner-approved, and instantly reversible.', improvementsBody),
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
section('Orthos Mode', 'Local-first answering — Orthos answers first, Claude escalates when needed.',
el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).'))
);
renderTokens(tokensBody);
renderAgents(agentsBody);
}