'Keep voice clips' setting (default off). When on, /api/voice/transcribe saves the audio (0600) to the owner-only ZFS subvol at /var/lib/void/ voice-clips (CT 311 mp0, replicated to Z3) + a voice_clips row (migration 029, transcript+metadata in void-db). New clips list/play/delete API + Settings UI. Storage path is configurable (VOICE_CLIPS_DIR). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
12 KiB
JavaScript
259 lines
12 KiB
JavaScript
// #/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 won’t 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);
|
||
|
||
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('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);
|
||
}
|