openvibers/openvibe.live Commits

Admin settings: move job state out of site_settings, give PowerChat a section

OP OpenVibers committed 26 Aug 2026 · a539e6f8ad · 8 files, +79 −19

auto_clip_backfill/auto_clip_log/daily_easter_egg/home_hero_moments (plus
home_hero_slogans and the per-user ai_whole_overview_* caches) are machine
state the AI jobs persist across restarts — stashing them in site_settings
put giant editable JSON blobs in the admin panel. They now live in a new
app_state KV table (getState/setState), with an idempotent migration that
moves existing rows over. The admin settings page also stops interleaving
powerchat_* keys alphabetically: integrations render as their own boxed
section.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/public/js/admin.js b/public/js/admin.js
iindex bbe90991..7e1754ce 100644
---- a/public/js/admin.js
++++ b/public/js/admin.js
@@@ -523,9 +523,19 @@ async function loadAdminSettings() {
const data = await api('/admin/settings');
const settings = data.settings || [];
-- c.innerHTML = `
-- <form id="admin-settings-form" onsubmit="saveAdminSettings(event)" style="display:grid;gap:12px;max-width:700px">
-- ${settings.map(s => {
++ // Integrations get their own boxed section instead of interleaving
++ // alphabetically with everything else.
++ const SECTIONS = [
++ { title: 'PowerChat', icon: 'fa-bolt', match: (k) => k.startsWith('powerchat_') },
++ ];
++ const sectioned = new Map(SECTIONS.map(sec => [sec, []]));
++ const general = [];
++ for (const s of settings) {
++ const sec = SECTIONS.find(x => x.match(s.key));
++ if (sec) sectioned.get(sec).push(s); else general.push(s);
++ }
++
++ const renderRow = (s) => {
const id = `setting-${s.key}`;
if (s.type === 'boolean') {
return `
@@@ -577,6 +587,21 @@ async function loadAdminSettings() {
style="margin-top:4px;width:100%;background:var(--bg-input);color:var(--text-primary);border:1px solid var(--border);padding:6px 10px;border-radius:6px">
`}
</div>`;
++ };
++
++ c.innerHTML = `
++ <form id="admin-settings-form" onsubmit="saveAdminSettings(event)" style="display:grid;gap:12px;max-width:700px">
++ ${general.map(renderRow).join('')}
++ ${SECTIONS.map(sec => {
++ const rows = sectioned.get(sec);
++ if (!rows.length) return '';
++ return `
++ <div class="bc-settings-group" style="padding:12px;border:1px solid var(--border);border-radius:8px;margin-top:8px">
++ <h4 style="margin:0 0 4px;display:flex;align-items:center;gap:8px">
++ <i class="fa-solid ${sec.icon}" style="color:var(--accent)"></i> ${esc(sec.title)}
++ </h4>
++ ${rows.map(renderRow).join('')}
++ </div>`;
}).join('')}
<button type="submit" class="btn btn-primary" style="justify-self:start;margin-top:8px">
<i class="fa-solid fa-floppy-disk"></i> Save Settings
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex 0b0ef892..470207a5 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -28,7 +28,7 @@ const TARGET = 4;
const SETTING = 'home_hero_moments';
let _busy = false;
--function _load() { try { return JSON.parse(db.getSetting(SETTING) || '{}') || {}; } catch { return {}; } }
++function _load() { try { return JSON.parse(db.getState(SETTING) || '{}') || {}; } catch { return {}; } }
function _due() { const p = _load(); return !p.updated_at || (Date.now() - p.updated_at) >= INTERVAL_MS; }
const _ADJ = ['wild', 'epic', 'cursed', 'feral', 'unhinged', 'chaotic', 'legendary', 'peak', 'rogue', 'hazy', 'unreal', 'prime'];
@@@ -435,7 +435,7 @@ async function tick(opts = {}) {
const usedLog = [...newUsedVods, ...(prev.usedVods || [])].slice(0, 300);
const sigLog = [...thisSigs, ...(prev.usedSigs || [])].slice(0, 120);
-- db.setSetting(SETTING, JSON.stringify({ moments, usedVods: usedLog, usedSigs: sigLog, updated_at: Date.now() }));
++ db.setState(SETTING, JSON.stringify({ moments, usedVods: usedLog, usedSigs: sigLog, updated_at: Date.now() }));
console.log(`[AI-Moments] ${moments.length} moment(s) from ${chosen.length}/${totalEligible} ranked VODs + pastes created`);
} catch (e) {
console.warn('[AI-Moments] tick error:', e.message);
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex 9f8fa8cd..0602eb52 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -41,14 +41,14 @@ function _clean(t, n) { return String(t || '').replace(/\s+/g, ' ').trim().slice
// ── Auto-clip log (replaces the old clips-table queries) ────
function _clipLog() {
-- try { const l = JSON.parse(db.getSetting(CLIP_LOG_SETTING) || '[]'); return Array.isArray(l) ? l : []; }
++ try { const l = JSON.parse(db.getState(CLIP_LOG_SETTING) || '[]'); return Array.isArray(l) ? l : []; }
catch { return []; }
}
function _logClip(entry) {
try {
const log = _clipLog();
log.unshift({ ...entry, ts: Date.now() });
-- db.setSetting(CLIP_LOG_SETTING, JSON.stringify(log.slice(0, 300)));
++ db.setState(CLIP_LOG_SETTING, JSON.stringify(log.slice(0, 300)));
} catch { /* */ }
}
function _countClipsSince(streamId, minutes) {
@@@ -176,7 +176,7 @@ const BACKFILL_PER_RUN = 3; // a few historical clips…
const BACKFILL_INTERVAL_MS = 6 * 60 * 60 * 1000; // …every 6h (guarantees ≥1 clip / 6h)
function _backfillDue() {
-- try { const p = JSON.parse(db.getSetting(BACKFILL_SETTING) || '{}'); return !p.updated_at || (Date.now() - p.updated_at) >= BACKFILL_INTERVAL_MS; }
++ try { const p = JSON.parse(db.getState(BACKFILL_SETTING) || '{}'); return !p.updated_at || (Date.now() - p.updated_at) >= BACKFILL_INTERVAL_MS; }
catch { return true; }
}
// Historical-backfill candidate pool: most-viewed public VODs from OpenVibe.Media
@@@ -229,7 +229,7 @@ async function _resolveVodSource(vodId) {
async function backfillVodClips({ limit = BACKFILL_PER_RUN, force = false } = {}) {
if (!force && !_backfillDue()) return 0;
let prev = {};
-- try { prev = JSON.parse(db.getSetting(BACKFILL_SETTING) || '{}') || {}; } catch { /* */ }
++ try { prev = JSON.parse(db.getState(BACKFILL_SETTING) || '{}') || {}; } catch { /* */ }
const skip = new Set(prev.skip || []);
const moments = require('./ai-moments-job');
// Over-fetch so skipped/dead VODs don't starve a batch.
@@@ -259,7 +259,7 @@ async function backfillVodClips({ limit = BACKFILL_PER_RUN, force = false } = {}
} catch (e) { newSkip.push(v.vod_id); console.warn(`[AutoClip] backfill VOD ${v.vod_id} failed:`, e.message); }
}
const mergedSkip = [...new Set([...(prev.skip || []), ...newSkip])].slice(-2000);
-- try { db.setSetting(BACKFILL_SETTING, JSON.stringify({ updated_at: Date.now(), lastMade: made, skip: mergedSkip })); } catch { /* */ }
++ try { db.setState(BACKFILL_SETTING, JSON.stringify({ updated_at: Date.now(), lastMade: made, skip: mergedSkip })); } catch { /* */ }
if (made || newSkip.length) console.log(`[AutoClip] Historical backfill: ${made} clip(s) added, ${newSkip.length} VOD(s) skipped (unclippable)`);
return made;
}
ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex 029f91b8..f2a0202c 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -122,7 +122,7 @@ function _combinedOverview(userId, streamerOv, chatIns) {
const srcLen = sOv.length + '|' + cOv.length; // cheap change-detector
let cachedText = null, fresh = false;
try {
-- const raw = db.getSetting(key);
++ const raw = db.getState(key);
if (raw) { const j = JSON.parse(raw); cachedText = j.text ? String(j.text).replace(/^\s*(combined\s+overview|overview)\s*[:\-–]\s*/i, '').trim() : null; fresh = j.src === srcLen && (Date.now() - (j.generated_at || 0) < 24 * 60 * 60 * 1000); }
} catch { /* rebuild */ }
if (cachedText && fresh) return cachedText;
@@@ -133,7 +133,7 @@ function _combinedOverview(userId, streamerOv, chatIns) {
_combinedBusy.add(userId);
const prompt = `You are describing a person on a streaming site by fusing two AI summaries about them into ONE cohesive 2-4 sentence overview of who they are overall — both as a STREAMER and as a CHATTER. Be natural, specific, and not repetitive. Output ONLY the overview prose with no label or prefix.\n\nAS A STREAMER:\n${sOv}\n\nAS A CHATTER:\n${cOv}`;
Promise.resolve(ai.summarizeText(prompt, 320, 'combined_overview'))
-- .then(text => { if (text) { const clean = String(text).replace(/^\s*(combined\s+overview|overview)\s*[:\-–]\s*/i, '').trim(); try { db.setSetting(key, JSON.stringify({ text: clean, generated_at: Date.now(), src: srcLen })); } catch { /* */ } } })
++ .then(text => { if (text) { const clean = String(text).replace(/^\s*(combined\s+overview|overview)\s*[:\-–]\s*/i, '').trim(); try { db.setState(key, JSON.stringify({ text: clean, generated_at: Date.now(), src: srcLen })); } catch { /* */ } } })
.catch(() => { })
.finally(() => _combinedBusy.delete(userId));
}
ddiff --git a/server/ai/easter-egg-job.js b/server/ai/easter-egg-job.js
iindex e4ee8c36..60c66076 100644
---- a/server/ai/easter-egg-job.js
++++ b/server/ai/easter-egg-job.js
@@@ -19,7 +19,7 @@ let _busy = false;
// Local date key (server-local) — one egg per calendar day.
function _today() { return new Date().toISOString().slice(0, 10); }
--function _load() { try { return JSON.parse(db.getSetting(SETTING) || '{}') || {}; } catch { return {}; } }
++function _load() { try { return JSON.parse(db.getState(SETTING) || '{}') || {}; } catch { return {}; } }
function _due() { const p = _load(); return !p.date || p.date !== _today() || !Array.isArray(p.code) || !p.code.length; }
// Deterministic daily fallback (no AI): a seeded 7-token sequence + generic hints.
@@@ -98,7 +98,7 @@ async function tick(opts = {}) {
_busy = true;
try {
const egg = await _generate();
-- db.setSetting(SETTING, JSON.stringify({ date: _today(), ...egg, updated_at: Date.now() }));
++ db.setState(SETTING, JSON.stringify({ date: _today(), ...egg, updated_at: Date.now() }));
console.log(`[EasterEgg] New daily egg "${egg.title}" (${egg.code.length} keys, ${egg.ai ? 'AI' : 'fallback'})`);
} catch (e) {
console.warn('[EasterEgg] tick error:', e.message);
ddiff --git a/server/ai/slogan-job.js b/server/ai/slogan-job.js
iindex 517efaa7..81698500 100644
---- a/server/ai/slogan-job.js
++++ b/server/ai/slogan-job.js
@@@ -52,7 +52,7 @@ function _topUp(fresh, old, cap) {
}
const SLOGAN_FORMAT = 2; // bump to force a one-time regen after a prompt/format change
function _loadPool() {
-- try { const cur = db.getSetting('home_hero_slogans'); const o = typeof cur === 'string' ? JSON.parse(cur) : cur; if (o) return { audiences: o.audiences || [], quips: o.quips || [], updated_at: o.updated_at || 0, v: o.v || 1 }; } catch { /* */ }
++ try { const cur = db.getState('home_hero_slogans'); const o = typeof cur === 'string' ? JSON.parse(cur) : cur; if (o) return { audiences: o.audiences || [], quips: o.quips || [], updated_at: o.updated_at || 0, v: o.v || 1 }; } catch { /* */ }
return { audiences: [], quips: [], updated_at: 0, v: 0 };
}
@@@ -152,7 +152,7 @@ Return ONLY the JSON object.`;
const old = _loadPool();
audiences = _topUp(audiences, old.audiences.map(_stripAudiencePrefix), TARGET);
quips = _topUp(quips, old.quips, TARGET);
-- db.setSetting('home_hero_slogans', JSON.stringify({ v: SLOGAN_FORMAT, audiences, quips, updated_at: Date.now() }));
++ db.setState('home_hero_slogans', JSON.stringify({ v: SLOGAN_FORMAT, audiences, quips, updated_at: Date.now() }));
console.log(`[Slogans] Fresh daily batch: ${audiences.length} words, ${quips.length} slogans (from full AI context)`);
} catch (e) {
console.warn('[Slogans] generation failed:', e.message);
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 3869b984..ef33a4e6 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1065,6 +1065,23 @@ function initDb() {
database.prepare(`UPDATE site_settings SET value = 'alex' WHERE key = 'powerchat_sandbox_username' AND value = 'n8admin'`).run();
} catch (e) { console.warn('[DB] Settings seed:', e.message); }
++ // Internal job/cache state (JSON blobs the AI jobs persist across restarts).
++ // These used to be stashed in site_settings, which made every one of them show up
++ // as an editable "setting" in the admin panel — they're machine state, not config.
++ // app_state is the same KV shape but never surfaced to (or editable by) admins.
++ try {
++ database.exec(`CREATE TABLE IF NOT EXISTS app_state (
++ key TEXT PRIMARY KEY,
++ value TEXT,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ // One-time move of the known state keys out of site_settings (idempotent).
++ const stateCond = `key IN ('auto_clip_backfill','auto_clip_log','daily_easter_egg','home_hero_moments','home_hero_slogans') OR key LIKE 'ai_whole_overview_%'`;
++ database.exec(`INSERT OR IGNORE INTO app_state (key, value, updated_at)
++ SELECT key, value, COALESCE(updated_at, CURRENT_TIMESTAMP) FROM site_settings WHERE ${stateCond}`);
++ database.exec(`DELETE FROM site_settings WHERE ${stateCond}`);
++ } catch (e) { console.warn('[DB] app_state migration:', e.message); }
++
// AI subsystem tables + columns.
try {
database.exec(`CREATE TABLE IF NOT EXISTS stream_memories (
@@@ -5527,6 +5544,23 @@ function deleteSetting(key) {
return run('DELETE FROM site_settings WHERE key = ?', [key]);
}
++// ── Internal job/cache state (app_state) ─────────────────────
++// Same KV shape as site_settings but for machine state (JSON blobs the AI jobs
++// persist across restarts). Never listed in the admin panel — admin-editable
++// config belongs in site_settings, job state belongs here.
++function getState(key) {
++ const row = get('SELECT value FROM app_state WHERE key = ?', [key]);
++ return row ? row.value : null;
++}
++function setState(key, value) {
++ const strVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
++ return run(`INSERT INTO app_state (key, value) VALUES (?, ?)
++ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`, [key, strVal]);
++}
++function deleteState(key) {
++ return run('DELETE FROM app_state WHERE key = ?', [key]);
++}
++
// ── Verification Key helpers ─────────────────────────────────
function createVerificationKey({ key, target_username, note, created_by }) {
@@@ -7651,6 +7685,7 @@ module.exports = {
updateChannelAiBot, touchChannelAiBot, deleteChannelAiBot,
// Site Settings
getSetting, getSettingRow, getAllSettings, setSetting, deleteSetting,
++ getState, setState, deleteState,
// Verification Keys
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
getAllVerificationKeys, redeemVerificationKey, revokeVerificationKey, isUsernameReserved,
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex b4b9c201..b48a4cf0 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -97,7 +97,7 @@ async function heroMedia() {
// AI "crazy moments" — daily-rotated standout frames from the AI memory data, deep-linking
// to the exact VOD timestamp (populated by ai-moments-job).
try {
-- const hm = JSON.parse(db.getSetting('home_hero_moments') || '{}');
++ const hm = JSON.parse(db.getState('home_hero_moments') || '{}');
for (const m of (hm.moments || [])) {
if (!m || !m.vodId) continue;
items.push({
@@@ -114,7 +114,7 @@ async function heroMedia() {
// for the full-bleed hero BACKGROUND that cross-fades through them.
function heroMoments() {
try {
-- const hm = JSON.parse(db.getSetting('home_hero_moments') || '{}');
++ const hm = JSON.parse(db.getState('home_hero_moments') || '{}');
return (hm.moments || [])
.filter(m => m && m.vodId && m.thumbnail)
.map(m => ({ title: m.title || 'AI Moment', thumbnail: m.thumbnail, href: `/vod/${m.vodId}?t=${m.offset || 0}`, username: m.username }));
@@@ -136,7 +136,7 @@ function _cleanAudience(raw) {
function heroSlogans() {
let s = null;
try {
-- s = db.getSetting('home_hero_slogans');
++ s = db.getState('home_hero_slogans');
if (typeof s === 'string') s = JSON.parse(s);
} catch { s = null; }
const aiAud = (s && Array.isArray(s.audiences)) ? s.audiences.map(_cleanAudience).filter(a => a && a.length <= 60) : [];