openvibers/openvibe.live Commits
AI viewers v3: unified context service, director engine (one structured call per pass), scheduler + threads + activity log, streamer fast path, relayed/anon chat hooks, mod commands, settings schema, per-slot Broadcast section, rebuilt dashboard panel, admin fleet endpoints; shared captureMemoryNow; remove dead ai-chatbot code
Changes
@@@ -13677,3 +13677,41 @@ body.controls-docked .messenger-toggle { z-index: 1100; }.ov-pip-resize { width: 26px; height: 26px; opacity: .8; }.ov-pip-bar { opacity: 1; pointer-events: auto; }}++++/* ── AI Chat Viewers v3 panel ─────────────────────────────── */++.aiv-topline { display:flex; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:8px; }++.aiv-status { font-size:0.75rem; font-weight:700; padding:3px 10px; border-radius:999px; background:var(--bg-tertiary); color:var(--text-secondary); }++.aiv-status.ok { background:color-mix(in srgb, #22c55e 18%, transparent); color:#4ade80; }++.aiv-status.warn { background:color-mix(in srgb, #f59e0b 18%, transparent); color:#fbbf24; }++.aiv-status.off { background:var(--bg-tertiary); color:var(--text-muted); }++.aiv-senses { display:flex; gap:14px; flex-wrap:wrap; font-size:0.78rem; color:var(--text-secondary); padding:8px 10px; border:1px dashed var(--border); border-radius:8px; margin-bottom:10px; }++.aiv-senses i { color:var(--accent); margin-right:4px; }++.aiv-group { border:1px solid var(--border); border-radius:8px; margin-bottom:8px; }++.aiv-group > summary { cursor:pointer; padding:8px 12px; font-weight:600; font-size:0.9rem; list-style:none; }++.aiv-group > summary i { color:var(--accent); width:18px; }++.aiv-group-body { padding:4px 12px 12px; display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:10px 18px; }++.aiv-field { display:flex; flex-direction:column; gap:4px; font-size:0.83rem; }++.aiv-field > span:first-child { font-weight:600; display:flex; justify-content:space-between; gap:8px; }++.aiv-field > span:first-child b { color:var(--accent); font-variant-numeric:tabular-nums; }++.aiv-field input[type=range] { width:100%; accent-color:var(--accent); }++.aiv-field.aiv-inline { flex-direction:row; align-items:center; gap:8px; }++.aiv-field.aiv-inline input[type=range] { width:140px; }++.aiv-field textarea, .aiv-field input[type=text], .aiv-field select { font-size:0.83rem; }++label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }++.aiv-log { display:flex; flex-direction:column; gap:2px; max-height:420px; overflow:auto; border:1px solid var(--border); border-radius:8px; padding:6px; }++.aiv-log-row { display:grid; grid-template-columns:20px 1fr auto; gap:8px; align-items:start; font-size:0.8rem; padding:5px 6px; border-radius:6px; }++.aiv-log-row:nth-child(odd) { background:color-mix(in srgb, var(--bg-tertiary) 60%, transparent); }++.aiv-log-row i { color:var(--text-muted); margin-top:3px; }++.aiv-log-line i { color:var(--accent); } .aiv-log-mention i { color:#f59e0b; } .aiv-log-error i { color:#ef4444; } .aiv-log-tick i { color:#60a5fa; }++.aiv-log-reason { font-size:0.74rem; }++.aiv-log-meta { display:flex; flex-direction:column; align-items:flex-end; font-size:0.72rem; white-space:nowrap; }++.aiv-preview-line { padding:6px 8px; border-left:3px solid var(--accent); margin:6px 0; font-size:0.85rem; background:var(--bg-tertiary); border-radius:0 6px 6px 0; }++.aiv-pre { white-space:pre-wrap; font-size:0.72rem; max-height:320px; overflow:auto; background:var(--bg-tertiary); padding:8px; border-radius:6px; }++.aiv-meter-fill.warn { background:#f59e0b; }++.aiv-bot.muted-bot { opacity:0.7; }++.aiv-slot-head { display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:8px; }++.aiv-slot-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:8px 16px; }++.aiv-slot-grid label { display:flex; flex-direction:column; gap:3px; font-size:0.8rem; }++.aiv-slot-grid label > span { font-weight:600; display:flex; justify-content:space-between; }++.aiv-slot-grid label > span b { color:var(--accent); }++.aiv-slot-grid input[type=range] { accent-color:var(--accent); }@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=136">++ <link rel="stylesheet" href="/css/style.css?v=137"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2">@@@ -1612,37 +1612,25 @@<div class="ai-viewers-tabs ch-tabs" id="aiv-subtabs" role="tablist"><button class="ch-tab active" data-avtab="config" onclick="switchAivTab('config', this)"><i class="fa-solid fa-sliders"></i> Settings</button><button class="ch-tab" data-avtab="roster" onclick="switchAivTab('roster', this)"><i class="fa-solid fa-users"></i> Roster <span class="aiv-count" id="aiv-roster-count"></span></button>++ <button class="ch-tab" data-avtab="activity" onclick="switchAivTab('activity', this)"><i class="fa-solid fa-list-ul"></i> Activity</button><button class="ch-tab" data-avtab="budget" onclick="switchAivTab('budget', this)"><i class="fa-solid fa-gauge"></i> Key & Budget</button></div>-- <!-- Settings -->++ <!-- Settings (rendered from the server's settings schema) --><div class="aiv-panel active" id="aiv-panel-config">-- <div class="ai-bot-grid">-- <label class="staff-inline-toggle"><input type="checkbox" id="aiv-enabled"> <b>Enable AI chat viewers</b></label>-- <div class="ai-bot-row">-- <label>-- <span>Ambient viewers</span>-- <input type="number" id="aiv-num" class="form-input" min="0" max="12" value="3" style="max-width:110px;">-- <span class="ai-bot-hint">Always-on background chatters (0 = clones only).</span>-- </label>-- <label>-- <span>Speak every ~N seconds</span>-- <input type="number" id="aiv-pacing" class="form-input" min="10" max="600" value="45" style="max-width:120px;">-- <span class="ai-bot-hint">Across all bots (lower = chattier).</span>-- </label>-- </div>-- <label>-- <span>Channel personality (optional seed)</span>-- <textarea id="aiv-persona" class="form-input" rows="2" placeholder="e.g. Gremlin energy, backseat gamers, wholesome chaos. Keep it funny, never mean."></textarea>-- </label>-- <label class="staff-inline-toggle"><input type="checkbox" id="aiv-transcribe"> <span>Let them <b>hear</b> the stream (free local transcription) so they react to what you say <i class="fa-solid fa-microphone"></i></span></label>-- <label class="staff-inline-toggle"><input type="checkbox" id="aiv-vision"> <span>Let them <b>see</b> the stream (periodic frames) so they react to what's on screen <i class="fa-solid fa-eye" title="Uses vision tokens — counts against your budget."></i></span></label>-- <div class="ai-bot-actions">-- <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-wand-magic-sparkles"></i> Preview a line</button>-- <button class="btn btn-primary btn-small" onclick="saveAiViewers(this)"><i class="fa-solid fa-floppy-disk"></i> Save settings</button>-- </div>-- <div id="aiv-preview" class="ai-bot-preview" style="display:none;"></div>++ <div id="aiv-settings-root"><p class="muted">Loading…</p></div>++ </div>++++ <!-- Activity: live feed of what the director decided and why -->++ <div class="aiv-panel" id="aiv-panel-activity">++ <div class="aiv-senses" id="aiv-senses-activity"></div>++ <div class="ai-bot-actions" style="margin:6px 0 8px">++ <button class="btn btn-small btn-outline" onclick="aivCommand('pause', this)"><i class="fa-solid fa-pause"></i> Pause</button>++ <button class="btn btn-small btn-outline" onclick="aivCommand('resume', this)"><i class="fa-solid fa-play"></i> Resume</button>++ <button class="btn btn-small btn-outline" onclick="aivCommand('nudge', this)"><i class="fa-solid fa-hand-point-right"></i> Nudge the director</button>++ <span class="muted" id="aiv-hour-stats" style="font-size:0.8rem"></span></div>++ <div class="aiv-log" id="aiv-log"><p class="muted">Loading…</p></div></div><!-- Roster -->@@@ -1679,9 +1667,21 @@<span class="ai-bot-hint" id="aiv-byo-key-status"></span></label><label>-- <span>Chat model</span>++ <span>Provider shape</span>++ <select id="aiv-byo-provider" class="form-input" style="max-width:220px"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option></select>++ </label>++ <label>++ <span>Default model</span><input type="text" id="aiv-byo-model" class="form-input" placeholder="gpt-4o-mini" autocomplete="off" style="max-width:220px;">++ <span class="ai-bot-hint">Used for every role unless overridden below.</span></label>++ <div class="ai-bot-row">++ <label><span>Chat lines model</span><input type="text" id="aiv-byo-model-chat" class="form-input" placeholder="(default)" style="max-width:200px"></label>++ <label><span>Director model</span><input type="text" id="aiv-byo-model-director" class="form-input" placeholder="(default)" style="max-width:200px"><span class="ai-bot-hint">Plans several lines per call; needs JSON output.</span></label>++ <label><span>Vision model</span><input type="text" id="aiv-byo-model-vision" class="form-input" placeholder="(default)" style="max-width:200px"><span class="ai-bot-hint">Only used when the shared site vision is off.</span></label>++ <label><span>Summary model</span><input type="text" id="aiv-byo-model-summary" class="form-input" placeholder="(default)" style="max-width:200px"></label>++ </div>++ <div class="ai-bot-actions"><button class="btn btn-small btn-outline" onclick="testAivByo(this)"><i class="fa-solid fa-plug-circle-check"></i> Test connection</button><span id="aiv-byo-test-out" class="muted" style="font-size:0.8rem"></span></div></div><div class="ai-bot-actions"><button class="btn btn-primary btn-small" onclick="saveAiViewers(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>@@@ -5070,7 +5070,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/settings.js?v=12" defer></script><script src="/js/staff-console.js?v=1" defer></script><script src="/js/dashboard-moderation.js?v=5" defer></script>--<script src="/js/ai-viewers.js?v=1" defer></script>++<script src="/js/ai-viewers.js?v=2" defer></script><script src="/js/powerchat.js?v=8" defer></script><script src="/js/pastes.js?v=14" defer></script><script src="/js/draggable-fab.js?v=2" defer></script>ddeleted file mode 100644@@@ -1,134 +0,0 @@--/**-- * ai-chatbot-settings.js — Dashboard config panel for AI chat viewers.-- * Loads/saves via /api/ai-chatbot. The API token is write-only: the server-- * returns only a masked hint, and a blank token field preserves the stored one.-- */--(function () {-- 'use strict';---- function $(id) { return document.getElementById(id); }---- // When a token is already saved, the field shows this masked sentinel (so it clearly-- // reads as "a key is set" instead of an empty box that looks unsaved). We only send a-- // new api_token when the user actually types one — the sentinel means "keep existing".-- const TOKEN_SENTINEL = '••••••••••••••••';---- // The literal text the user typed into the token field, or '' to mean "no change".-- function currentTokenInput() {-- const el = $('aibot-token');-- if (!el) return '';-- const v = el.value;-- if (v === TOKEN_SENTINEL) return ''; // unchanged → preserve stored token-- return String(v || '').trim();-- }---- function collect() {-- const body = {-- enabled: !!$('aibot-enabled')?.checked,-- base_url: $('aibot-base-url')?.value.trim() || '',-- model: $('aibot-model')?.value.trim() || '',-- num_bots: Number($('aibot-num')?.value || 3),-- post_interval_seconds: Number($('aibot-interval')?.value || 45),-- persona: $('aibot-persona')?.value || '',-- transcribe_enabled: !!$('aibot-transcribe')?.checked,-- transcribe_model: $('aibot-transcribe-model')?.value.trim() || '',-- vision_enabled: !!$('aibot-vision')?.checked,-- };-- const token = currentTokenInput();-- if (token) body.api_token = token; // only send when the user typed a NEW key-- return body;-- }---- window.loadAiChatbotConfig = async function loadAiChatbotConfig() {-- if (!$('dash-aibot-card')) return;-- try {-- const data = await api('/ai-chatbot');-- const c = data.config || {};-- if ($('aibot-enabled')) $('aibot-enabled').checked = !!c.enabled;-- if ($('aibot-base-url')) $('aibot-base-url').value = c.base_url || 'https://api.openai.com/v1';-- if ($('aibot-model')) $('aibot-model').value = c.model || 'gpt-4o-mini';-- if ($('aibot-num')) $('aibot-num').value = c.num_bots ?? 3;-- if ($('aibot-interval')) $('aibot-interval').value = c.post_interval_seconds ?? 45;-- if ($('aibot-persona')) $('aibot-persona').value = c.persona || '';-- if ($('aibot-transcribe')) $('aibot-transcribe').checked = !!c.transcribe_enabled;-- if ($('aibot-transcribe-model')) $('aibot-transcribe-model').value = c.transcribe_model || 'whisper-1';-- if ($('aibot-vision')) $('aibot-vision').checked = !!c.vision_enabled;-- const status = $('aibot-token-status');-- const selfHosted = !!(c.base_url && !/api\.openai\.com/i.test(c.base_url));-- if (status) {-- status.innerHTML = c.has_token-- ? `<span style="color:#53fc18">✓ Key saved</span> (${c.api_token_masked}). The dots below mean it's stored — leave them to keep it, or type a new key to replace.`-- : selfHosted-- ? 'No token needed for a self-hosted server (Ollama / LM Studio) — leave this blank.'-- : 'No key saved yet — paste your API key above.';-- }-- // Show a masked sentinel when a key exists so the field never looks "empty/unsaved".-- const tokenEl = $('aibot-token');-- if (tokenEl) {-- tokenEl.value = c.has_token ? TOKEN_SENTINEL : '';-- tokenEl.dataset.hasToken = c.has_token ? '1' : '';-- if (!tokenEl._sentinelWired) {-- tokenEl._sentinelWired = true;-- // Clear the placeholder-dots when the user goes to type a real key…-- tokenEl.addEventListener('focus', () => { if (tokenEl.value === TOKEN_SENTINEL) tokenEl.value = ''; });-- // …and restore them if they left it untouched (so it still reads as "saved").-- tokenEl.addEventListener('blur', () => { if (!tokenEl.value && tokenEl.dataset.hasToken === '1') tokenEl.value = TOKEN_SENTINEL; });-- }-- }-- } catch (err) {-- // Not logged in / no dashboard — silently ignore-- }-- };---- window.saveAiChatbot = async function saveAiChatbot(btn) {-- if (btn) btn.disabled = true;-- try {-- await api('/ai-chatbot', { method: 'PUT', body: collect() });-- toast('AI chat viewer settings saved', 'success');-- await window.loadAiChatbotConfig();-- } catch (err) {-- toast(err.message || 'Failed to save', 'error');-- } finally {-- if (btn) btn.disabled = false;-- }-- };---- window.testAiChatbot = async function testAiChatbot(btn) {-- if (btn) btn.disabled = true;-- try {-- const b = collect();-- const r = await api('/ai-chatbot/validate', { method: 'POST', body: { base_url: b.base_url, model: b.model, api_token: b.api_token } });-- if (r.ok) toast('AI connection OK ✓', 'success');-- else toast('Connection failed: ' + (r.error || 'unknown error'), 'error');-- } catch (err) {-- toast(err.message || 'Test failed', 'error');-- } finally {-- if (btn) btn.disabled = false;-- }-- };---- window.previewAiChatbot = async function previewAiChatbot(btn) {-- const box = $('aibot-preview');-- if (btn) btn.disabled = true;-- try {-- const b = collect();-- const r = await api('/ai-chatbot/preview', { method: 'POST', body: { base_url: b.base_url, model: b.model, api_token: b.api_token, persona: b.persona } });-- if (box) {-- box.style.display = 'block';-- box.textContent = r.sample ? `💬 “${r.sample}”` : '(empty response)';-- }-- } catch (err) {-- if (box) { box.style.display = 'block'; box.textContent = 'Preview failed: ' + (err.message || 'error'); }-- } finally {-- if (btn) btn.disabled = false;-- }-- };---- // Chain into the dashboard load so config populates when the dashboard opens.-- const _prevLoadDashboard = window.loadDashboard;-- window.loadDashboard = async function aiChatbotLoadDashboard() {-- if (typeof _prevLoadDashboard === 'function') await _prevLoadDashboard();-- try { await window.loadAiChatbotConfig(); } catch { /* ignore */ }-- };-- if (window.loadDashboard) { /* keep global alias consistent */ }--})();@@@ -1,195 +1,345 @@/* ═══════════════════════════════════════════════════════════════-- AI Chat Viewers 2.0 — dashboard management (config, budget, roster, clone)++ AI Chat Viewers v3 — control panel (dashboard card + Broadcast per-slot section)API: /api/ai-viewers · Card: #dash-aibot-card++ Renders every knob from the server's settings schema, shows what the bots can++ currently see/hear, a live activity feed with the director's reasons, roster++ management, and the key/budget/model routing controls.═══════════════════════════════════════════════════════════════ */(function () {'use strict';let _rosterLoaded = false;++ let _cfg = null; // last /config payload++ let _logAfter = 0;++ let _logTimer = null;++ let _statusTimer = null;++ const _dirty = {}; // pending settings edits (key → value)++ let _saveTimer = null;++++ const GROUPS = [++ { id: 'activity', title: 'Activity', icon: 'fa-gauge-high', keys: ['activity', 'lines_per_min', 'director_interval_sec', 'idle_interval_sec', 'lines_per_tick', 'min_gap_sec'] },++ { id: 'social', title: 'Social', icon: 'fa-comments', keys: ['bot_to_bot_ratio', 'reply_probability', 'mention_fast_path_sec', 'reply_to_relay_chat', 'greet_first_timers', 'react_to_sounds', 'react_to_scene_changes', 'max_open_threads', 'max_thread_turns', 'thread_idle_close_sec'] },++ { id: 'senses', title: 'Senses', icon: 'fa-eye', keys: ['hear_enabled', 'hear_window_sec', 'vision_policy', 'vision_max_age_sec', 'vision_periodic_sec'] },++ { id: 'style', title: 'Style', icon: 'fa-palette', keys: ['channel_personality', 'tone', 'language', 'emote_usage', 'max_words', 'topics', 'blocklist', 'channel_memory'] },++ { id: 'behaviour', title: 'Behaviour', icon: 'fa-sliders', keys: ['quiet_from', 'quiet_to', 'quiet_tz', 'quiet_allow_replies', 'tts_enabled', 'powerchat_forward', 'memory_fold_min', 'remember_viewers'] },++ ];++ const LABELS = {++ activity: 'Activity preset', lines_per_min: 'Lines per minute', director_interval_sec: 'Director pass (active) — seconds', idle_interval_sec: 'Director pass (idle) — seconds', lines_per_tick: 'Lines per pass', min_gap_sec: 'Min gap between lines (s)',++ bot_to_bot_ratio: 'Bots talking to each other', reply_probability: 'Reply to viewers', mention_fast_path_sec: 'Streamer reply within (s)', reply_to_relay_chat: 'Reply to relayed chat (Twitch/Kick/YT/RS)', greet_first_timers: 'Greet first-time chatters', react_to_sounds: 'React to sounds', react_to_scene_changes: 'React to scene changes', max_open_threads: 'Open conversations', max_thread_turns: 'Max turns per conversation', thread_idle_close_sec: 'Close idle conversation after (s)',++ hear_enabled: 'Hear the stream (live transcript)', hear_window_sec: 'Hearing window (s)', vision_policy: 'Look at the screen', vision_max_age_sec: 'Screen view is stale after (s)', vision_periodic_sec: 'Periodic look every (s)',++ channel_personality: 'Channel personality', tone: 'Tone', language: 'Language', emote_usage: 'Emotes', max_words: 'Max words per line', topics: 'Favourite topics', blocklist: 'Never mention', channel_memory: 'Running bits (auto-maintained)',++ quiet_from: 'Quiet hours from', quiet_to: 'Quiet hours to', quiet_tz: 'Time zone', quiet_allow_replies: 'Still answer the streamer in quiet hours', tts_enabled: 'TTS for bot lines', powerchat_forward: 'Show on PowerChat overlay', memory_fold_min: 'Consolidate memories every (min)', remember_viewers: 'Use what the site knows about regulars',++ };++ const ENUM_LABELS = {++ activity: { quiet: 'Quiet (~1/min)', normal: 'Normal (~3/min)', lively: 'Lively (~5/min)', chaos: 'Chaos (~8/min)', custom: 'Custom (use sliders)' },++ vision_policy: { off: 'Never', thumbnail: 'Cached thumbnail only', when_addressed: 'Fresh frame when you address chat', periodic: 'Periodically while active' },++ emote_usage: { none: 'None', some: 'Some', lots: 'Lots' },++ };++++ // ── helpers ────────────────────────────────────────────────++ const $ = (id) => document.getElementById(id);++ function ago(ms) { if (ms == null) return '—'; const s = Math.round(ms / 1000); return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.round(s / 60)}m ago` : `${Math.round(s / 3600)}h ago`; }++ function money(n) { return '$' + (Number(n || 0)).toFixed(4); }++ function tsAgo(iso) { if (!iso) return ''; const t = new Date(String(iso).replace(' ', 'T') + (String(iso).endsWith('Z') ? '' : 'Z')).getTime(); return ago(Date.now() - t); }window.switchAivTab = function switchAivTab(tab, btn) {-- document.querySelectorAll('#aiv-subtabs .ch-tab').forEach(b => {-- const on = b === btn || b.dataset.avtab === tab;-- b.classList.toggle('active', on);-- });++ document.querySelectorAll('#aiv-subtabs .ch-tab').forEach(b => b.classList.toggle('active', b === btn || b.dataset.avtab === tab));document.querySelectorAll('#dash-aibot-card .aiv-panel').forEach(p => p.classList.remove('active'));-- const panel = document.getElementById('aiv-panel-' + tab);++ const panel = $('aiv-panel-' + tab);if (panel) panel.classList.add('active');if (tab === 'roster' && !_rosterLoaded) { _rosterLoaded = true; loadAivRoster(); }++ if (tab === 'activity') startActivity(); else stopActivity();};function _aivToggleKeyMode() {-- const mode = document.getElementById('aiv-keymode')?.value || 'shared';-- const byo = document.getElementById('aiv-byo-fields');-- const budgetWrap = document.getElementById('aiv-budget-wrap');++ const mode = $('aiv-keymode')?.value || 'shared';++ const byo = $('aiv-byo-fields'); const budgetWrap = $('aiv-budget-wrap');if (byo) byo.style.display = mode === 'byo' ? '' : 'none';if (budgetWrap) budgetWrap.style.display = mode === 'byo' ? 'none' : '';}window._aivToggleKeyMode = _aivToggleKeyMode;-- function _setUsageMeter(b) {-- const el = document.getElementById('aiv-usage-meter');-- if (!el) return;++ // ── status / meter ─────────────────────────────────────────++ function renderStatus(st, b) {++ const pill = $('aiv-status-pill');++ if (pill) {++ let cls = 'off', txt = 'Off';++ if (st && st.kill_switch) { cls = 'off'; txt = 'Disabled by admin'; }++ else if (st && st.running) { cls = st.paused ? 'warn' : (st.mode === 'normal' ? 'ok' : 'warn'); txt = st.paused ? 'Paused' : `Running · ${st.bots} viewers · ${st.mode.replace('_', ' ')}`; }++ else if (_cfg && _cfg.config && _cfg.config.enabled) { cls = 'warn'; txt = st && st.budget_active === false ? `Idle · ${String(st.budget_reason || st.mode || '').replace(/_/g, ' ')}` : 'Enabled · waiting for a live stream'; }++ pill.className = `aiv-status ${cls}`; pill.textContent = txt;++ }++ const senses = $('aiv-senses');++ if (senses) {++ if (!st || !st.running) senses.innerHTML = `<span class="muted">${st && st.timeline_enabled === false ? 'Live transcript is off site-wide (admin: ai_timeline_enabled) — the bots will only see chat and the screen.' : 'Go live to see what the bots can hear and see.'}</span>`;++ else senses.innerHTML = `++ <span title="Live transcript"><i class="fa-solid fa-ear-listen"></i> heard ${st.senses.heard_lines ? `${st.senses.heard_lines} lines, latest ${ago(st.senses.heard_age_ms)}` : (st.timeline_enabled ? 'nothing recent' : 'off')}</span>++ <span title="Latest screen analysis"><i class="fa-solid fa-eye"></i> seen ${st.senses.seen_age_ms != null ? ago(st.senses.seen_age_ms) : 'never'}${st.senses.seen_text ? ` — ${esc(st.senses.seen_text.slice(0, 90))}` : ''}</span>++ <span title="Cached prompt prefix"><i class="fa-solid fa-bolt"></i> context ${st.senses.stable_prefix_chars ? `${Math.round(st.senses.stable_prefix_chars / 4)} tokens cached` : 'building'}</span>++ <span><i class="fa-solid fa-clock"></i> next pass ${st.next_tick_in_ms != null ? `in ${Math.round(st.next_tick_in_ms / 1000)}s` : '—'} · ${st.lines_last_minute}/min · bot↔bot ${Math.round((st.bot_share || 0) * 100)}%</span>`;++ }++ if (b) _setUsageMeter(b, st);++ }++ function _setUsageMeter(b, st) {++ const el = $('aiv-usage-meter'); if (!el) return;++ const mode = st && st.mode ? st.mode : (b.active ? 'normal' : 'silent');++ const ladder = { normal: 'normal', economy: 'economy (fewer lines)', replies_only: 'replies only', streamer_only: 'streamer replies only', silent: 'silent' }[mode] || mode;if (b.use_shared_key) {-- const cap = b.cap_usd || 0;-- const spent = b.spent_today_usd || 0;++ const cap = b.cap_usd || 0, spent = b.spent_today_usd || 0;const pct = cap > 0 ? Math.min(100, Math.round((spent / cap) * 100)) : 0;-- const over = cap > 0 && spent >= cap;-- el.innerHTML = `-- <div class="aiv-meter-head">-- <span><i class="fa-solid fa-gauge"></i> Today: <b>$${spent.toFixed(4)}</b>${cap > 0 ? ` / $${cap.toFixed(2)}` : ' (no cap)'}</span>-- <span class="aiv-status ${b.active ? 'ok' : 'off'}">${b.active ? 'Active' : (over ? 'Budget reached' : 'Inactive')}</span>-- </div>-- ${cap > 0 ? `<div class="aiv-meter-bar"><div class="aiv-meter-fill ${over ? 'over' : ''}" style="width:${pct}%"></div></div>` : ''}-- ${!b.active && b.reason === 'shared_ai_disabled' ? '<p class="muted" style="font-size:0.8rem;margin-top:6px">Shared AI is currently disabled by the site admin.</p>' : ''}`;++ el.innerHTML = `<div class="aiv-meter-head"><span><i class="fa-solid fa-gauge"></i> Today: <b>${money(spent)}</b>${cap > 0 ? ` / $${cap.toFixed(2)}` : ' (no cap)'} · mode: <b>${ladder}</b></span><span class="aiv-status ${b.active ? 'ok' : 'off'}">${b.active ? 'Active' : (String(b.reason || 'inactive').replace(/_/g, ' '))}</span></div>++ ${cap > 0 ? `<div class="aiv-meter-bar"><div class="aiv-meter-fill ${pct >= 100 ? 'over' : pct >= 80 ? 'warn' : ''}" style="width:${pct}%"></div></div>` : ''}++ <p class="muted" style="font-size:0.75rem;margin-top:6px">As spend approaches the cap the viewers slow down (60%), then only reply (80%), then only answer you (95%), then go quiet.</p>`;} else {-- el.innerHTML = `<div class="aiv-meter-head">-- <span><i class="fa-solid fa-key"></i> Using your own API key (no cap). Today: <b>$${(b.spent_today_usd || 0).toFixed(4)}</b> est.</span>-- <span class="aiv-status ${b.active ? 'ok' : 'off'}">${b.active ? 'Active' : 'Needs a key'}</span>-- </div>`;++ el.innerHTML = `<div class="aiv-meter-head"><span><i class="fa-solid fa-key"></i> Your own key (no cap). Today: <b>${money(b.spent_today_usd)}</b></span><span class="aiv-status ${b.active ? 'ok' : 'off'}">${b.active ? 'Active' : 'Needs a key'}</span></div>`;++ }++ }++++ // ── settings form (schema-driven) ─────────────────────────++ function fieldHtml(sp, val) {++ const key = sp.key, label = LABELS[key] || key, help = sp.help || '';++ const id = `aivs-${key}`;++ if (sp.type === 'bool') return `<label class="staff-inline-toggle aiv-field" title="${esc(help)}"><input type="checkbox" id="${id}" data-skey="${key}" ${val ? 'checked' : ''} onchange="aivSet('${key}', this.checked)"> <span>${esc(label)}</span></label>`;++ if (sp.type === 'enum') {++ const opts = (sp.values || []).filter(v => v !== '' || key === 'engine').map(v => `<option value="${esc(v)}" ${String(val) === String(v) ? 'selected' : ''}>${esc((ENUM_LABELS[key] || {})[v] || v || '(default)')}</option>`).join('');++ return `<label class="aiv-field" title="${esc(help)}"><span>${esc(label)}</span><select id="${id}" data-skey="${key}" class="form-input" onchange="aivSet('${key}', this.value)">${opts}</select></label>`;++ }++ if (sp.type === 'int' || sp.type === 'num') {++ const step = sp.type === 'num' ? (sp.max <= 1 ? 0.05 : 0.5) : 1;++ const pct = key === 'bot_to_bot_ratio' || key === 'reply_probability';++ return `<label class="aiv-field aiv-slider" title="${esc(help)}"><span>${esc(label)} <b id="${id}-val">${pct ? Math.round(val * 100) + '%' : val}</b></span>++ <input type="range" id="${id}" data-skey="${key}" min="${sp.min}" max="${sp.max}" step="${step}" value="${val}" oninput="document.getElementById('${id}-val').textContent=${pct ? "Math.round(this.value*100)+'%'" : 'this.value'}" onchange="aivSet('${key}', ${sp.type === 'num' ? 'parseFloat(this.value)' : 'parseInt(this.value,10)'})"></label>`;}++ if (key === 'channel_personality' || key === 'channel_memory' || key === 'topics' || key === 'blocklist') return `<label class="aiv-field" title="${esc(help)}"><span>${esc(label)}</span><textarea id="${id}" data-skey="${key}" class="form-input" rows="${key === 'channel_personality' ? 3 : 2}" onchange="aivSet('${key}', this.value)">${esc(val || '')}</textarea><span class="ai-bot-hint">${esc(help)}</span></label>`;++ return `<label class="aiv-field" title="${esc(help)}"><span>${esc(label)}</span><input type="text" id="${id}" data-skey="${key}" class="form-input" value="${esc(val == null ? '' : val)}" placeholder="${key.startsWith('quiet_') && key !== 'quiet_tz' ? 'HH:MM' : ''}" onchange="aivSet('${key}', this.value)"></label>`;++ }++ function renderSettings() {++ const root = $('aiv-settings-root'); if (!root || !_cfg) return;++ const s = _cfg.settings || {}; const byKey = {}; (_cfg.schema || []).forEach(sp => { byKey[sp.key] = sp; });++ root.innerHTML = `++ <div class="aiv-topline">++ <label class="staff-inline-toggle"><input type="checkbox" id="aiv-enabled" ${_cfg.config && _cfg.config.enabled ? 'checked' : ''} onchange="aivSetEnabled(this.checked)"> <b>Enable AI chat viewers</b></label>++ <label class="aiv-field aiv-inline"><span>Viewers</span><input type="range" id="aivs-roster_size" min="0" max="${byKey.roster_size ? byKey.roster_size.max : 12}" step="1" value="${s.roster_size ?? 3}" oninput="document.getElementById('aivs-roster_size-val').textContent=this.value" onchange="aivSet('roster_size', parseInt(this.value,10))"><b id="aivs-roster_size-val">${s.roster_size ?? 3}</b></label>++ <span id="aiv-status-pill" class="aiv-status off">…</span>++ </div>++ <div class="aiv-senses" id="aiv-senses"></div>++ ${GROUPS.map((g, i) => `<details class="aiv-group" ${i === 0 ? 'open' : ''}><summary><i class="fa-solid ${g.icon}"></i> ${g.title}</summary><div class="aiv-group-body">${g.keys.filter(k => byKey[k]).map(k => fieldHtml(byKey[k], s[k])).join('')}</div></details>`).join('')}++ <div class="ai-bot-actions">++ <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-wand-magic-sparkles"></i> Preview what they'd say now</button>++ <span class="muted" id="aiv-save-state" style="font-size:0.8rem"></span>++ </div>++ <div id="aiv-preview" class="ai-bot-preview" style="display:none;"></div>`;++ renderStatus(_cfg.status, _cfg.budget);++ }++++ // Debounced write-through save of individual settings.++ window.aivSet = function aivSet(key, value) {++ _dirty[key] = value;++ const st = $('aiv-save-state'); if (st) st.textContent = 'Saving…';++ clearTimeout(_saveTimer);++ _saveTimer = setTimeout(flushSettings, 600);++ };++ async function flushSettings() {++ const settings = { ..._dirty }; for (const k of Object.keys(_dirty)) delete _dirty[k];++ if (!Object.keys(settings).length) return;++ try {++ const data = await api('/ai-viewers/config', { method: 'PUT', body: { settings } });++ _cfg = { ..._cfg, ...data };++ const st = $('aiv-save-state'); if (st) { st.textContent = 'Saved ✓'; setTimeout(() => { if (st.textContent === 'Saved ✓') st.textContent = ''; }, 2500); }++ renderStatus(data.status, data.budget);++ if (settings.activity && settings.activity !== 'custom' && data.settings) { const el = $('aivs-lines_per_min'); if (el) { el.value = data.settings.lines_per_min; const v = $('aivs-lines_per_min-val'); if (v) v.textContent = data.settings.lines_per_min; } }++ } catch (e) { toast(e.message || 'Save failed', 'error'); }++ }++ window.aivSetEnabled = async function aivSetEnabled(on) {++ try { const data = await api('/ai-viewers/config', { method: 'PUT', body: { enabled: on ? 1 : 0 } }); _cfg = { ..._cfg, ...data }; renderStatus(data.status, data.budget); toast(on ? 'AI viewers enabled — they join your next live stream (or now, if you are live).' : 'AI viewers disabled', 'success'); }++ catch (e) { toast(e.message || 'Failed', 'error'); }++ };++++ // ── activity feed ──────────────────────────────────────────++ function logRow(r) {++ const icon = { line: 'fa-comment', tick: 'fa-clapperboard', skip: 'fa-forward', mention: 'fa-bullhorn', fold: 'fa-brain', vision: 'fa-eye', degrade: 'fa-gauge', pause: 'fa-pause', error: 'fa-triangle-exclamation', info: 'fa-circle-info' }[r.event] || 'fa-circle';++ const cost = r.cost_usd != null ? `<span class="muted" title="${r.tokens_in || 0} in (${r.tokens_cached || 0} cached) / ${r.tokens_out || 0} out · ${esc(r.model || '')}">${money(r.cost_usd)}${r.tokens_in ? ` · ${Math.round(((r.tokens_cached || 0) / r.tokens_in) * 100)}% cached` : ''}</span>` : '';++ const who = r.bot_username ? `<b>${esc(r.bot_username)}</b>${r.target ? ` <span class="muted">→ ${esc(r.target)}</span>` : ''}` : `<span class="muted">${esc(r.event)}</span>`;++ return `<div class="aiv-log-row aiv-log-${esc(r.event)}"><i class="fa-solid ${icon}"></i><div><div>${who} ${r.text ? `— ${esc(r.text)}` : ''}</div>${r.reason ? `<div class="muted aiv-log-reason">${esc(r.reason)}</div>` : ''}</div><div class="aiv-log-meta">${cost}<span class="muted">${tsAgo(r.created_at)}</span></div></div>`;++ }++ async function pollLog() {++ const box = $('aiv-log'); if (!box) return;++ try {++ const data = await api(`/ai-viewers/log?after=${_logAfter}&limit=60`);++ const rows = data.rows || [];++ if (rows.length) {++ if (_logAfter === 0) box.innerHTML = '';++ for (const r of rows) { box.insertAdjacentHTML('afterbegin', logRow(r)); _logAfter = Math.max(_logAfter, r.id); }++ while (box.children.length > 120) box.lastChild.remove();++ } else if (_logAfter === 0 && !box.children.length) box.innerHTML = '<p class="muted">Nothing yet — the feed fills in while you are live.</p>';++ } catch { /* */ }}++ async function pollStatus() {++ try { const d = await api('/ai-viewers/status'); renderStatus(d.status, d.budget); const s = $('aiv-hour-stats'); if (s && d.last_hour) s.textContent = `Last hour: ${d.last_hour.lines || 0} lines · ${d.last_hour.ticks || 0} passes · ${d.last_hour.skips || 0} skipped · ${money(d.last_hour.cost_usd)}${d.last_hour.tokens_in ? ` · ${Math.round(((d.last_hour.tokens_cached || 0) / d.last_hour.tokens_in) * 100)}% cached` : ''}`; } catch { /* */ }++ }++ function startActivity() { stopActivity(); pollLog(); pollStatus(); _logTimer = setInterval(pollLog, 5000); _statusTimer = setInterval(pollStatus, 10000); }++ function stopActivity() { clearInterval(_logTimer); clearInterval(_statusTimer); _logTimer = _statusTimer = null; }++ window.aivCommand = async function aivCommand(cmd, btn) {++ if (btn) btn.disabled = true;++ try { const d = await api(`/ai-viewers/${cmd}`, { method: 'POST', body: {} }); toast(d.message || 'ok', 'success'); renderStatus(d.status, null); pollLog(); }++ catch (e) { toast(e.message || 'Failed', 'error'); } finally { if (btn) btn.disabled = false; }++ };++ // ── load / preview ─────────────────────────────────────────window.loadAiViewers = async function loadAiViewers() {-- const card = document.getElementById('dash-aibot-card');-- if (!card) return;++ const card = $('dash-aibot-card'); if (!card) return;try {-- const data = await api('/ai-viewers/config');-- const c = data.config || {};-- const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };-- const chk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = !!v; };-- chk('aiv-enabled', c.enabled);-- set('aiv-num', c.num_ambient_bots ?? 3);-- set('aiv-pacing', c.pacing_seconds ?? 45);-- set('aiv-persona', c.persona || '');-- chk('aiv-transcribe', c.transcribe_enabled);-- chk('aiv-vision', c.vision_enabled);++ _cfg = await api('/ai-viewers/config');++ const c = _cfg.config || {}, s = _cfg.settings || {};++ renderSettings();++ const set = (id, v) => { const el = $(id); if (el) el.value = v; };set('aiv-keymode', c.use_shared_key ? 'shared' : 'byo');set('aiv-budget', ((c.daily_budget_cents ?? 20) / 100).toFixed(2));-- set('aiv-byo-url', c.byo_base_url || '');-- set('aiv-byo-model', c.byo_model || 'gpt-4o-mini');-- const keyStatus = document.getElementById('aiv-byo-key-status');-- if (keyStatus) keyStatus.textContent = c.has_byo_key ? 'A key is saved. Leave blank to keep it.' : '';-- const keyEl = document.getElementById('aiv-byo-key'); if (keyEl) keyEl.value = '';++ set('aiv-byo-url', c.byo_base_url || ''); set('aiv-byo-model', c.byo_model || 'gpt-4o-mini');++ for (const role of ['chat', 'vision', 'director', 'summary']) set(`aiv-byo-model-${role}`, (s.byo && s.byo[`model_${role}`]) || '');++ set('aiv-byo-provider', (s.byo && s.byo.provider) || 'openai');++ const keyStatus = $('aiv-byo-key-status'); if (keyStatus) keyStatus.textContent = c.has_byo_key ? 'A key is saved. Leave blank to keep it.' : '';++ const keyEl = $('aiv-byo-key'); if (keyEl) keyEl.value = '';_aivToggleKeyMode();-- if (data.budget) _setUsageMeter(data.budget);-- // Refresh roster if its tab is already open.if (_rosterLoaded) loadAivRoster();++ if ($('aiv-panel-activity')?.classList.contains('active')) startActivity();} catch (e) { /* card may not be visible */ }};window.saveAiViewers = async function saveAiViewers(btn) {-- const val = (id) => document.getElementById(id);++ const val = (id) => $(id);const mode = val('aiv-keymode')?.value || 'shared';const body = {-- enabled: val('aiv-enabled')?.checked ? 1 : 0,-- num_ambient_bots: parseInt(val('aiv-num')?.value) || 0,-- pacing_seconds: parseInt(val('aiv-pacing')?.value) || 45,-- persona: val('aiv-persona')?.value || '',-- transcribe_enabled: val('aiv-transcribe')?.checked ? 1 : 0,-- vision_enabled: val('aiv-vision')?.checked ? 1 : 0,use_shared_key: mode === 'shared' ? 1 : 0,daily_budget_cents: Math.round((parseFloat(val('aiv-budget')?.value) || 0) * 100),byo_base_url: val('aiv-byo-url')?.value || '',byo_model: val('aiv-byo-model')?.value || 'gpt-4o-mini',++ settings: { byo: { provider: val('aiv-byo-provider')?.value || 'openai' } },};-- const keyVal = val('aiv-byo-key')?.value || '';-- if (keyVal) body.byo_key = keyVal;-- if (btn) { btn.disabled = true; }++ for (const role of ['chat', 'vision', 'director', 'summary']) body.settings.byo[`model_${role}`] = val(`aiv-byo-model-${role}`)?.value || '';++ const keyVal = val('aiv-byo-key')?.value || ''; if (keyVal) body.byo_key = keyVal;++ if (btn) btn.disabled = true;try {const data = await api('/ai-viewers/config', { method: 'PUT', body });-- toast('AI viewers saved', 'success');-- if (data.budget) _setUsageMeter(data.budget);++ _cfg = { ..._cfg, ...data };++ toast('Saved', 'success'); renderStatus(data.status, data.budget);const keyEl = val('aiv-byo-key'); if (keyEl) keyEl.value = '';-- const keyStatus = document.getElementById('aiv-byo-key-status');-- if (keyStatus && data.config) keyStatus.textContent = data.config.has_byo_key ? 'A key is saved. Leave blank to keep it.' : '';++ const keyStatus = $('aiv-byo-key-status'); if (keyStatus && data.config) keyStatus.textContent = data.config.has_byo_key ? 'A key is saved. Leave blank to keep it.' : '';} catch (e) { toast(e.message || 'Save failed', 'error'); }finally { if (btn) btn.disabled = false; }};++ window.testAivByo = async function testAivByo(btn) {++ const out = $('aiv-byo-test-out'); if (btn) btn.disabled = true; if (out) out.textContent = 'Testing…';++ try {++ const d = await api('/ai-viewers/byo/test', { method: 'POST', body: { byo_key: $('aiv-byo-key')?.value || '', byo_base_url: $('aiv-byo-url')?.value || '', byo_model: $('aiv-byo-model')?.value || '' } });++ if (out) out.innerHTML = d.ok ? `<span style="color:#4ade80">● OK — ${esc(d.model || '')} answered in ${d.latencyMs} ms</span>` : `<span style="color:#f87171">● ${esc(d.error || 'failed')}</span>`;++ } catch (e) { if (out) out.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; } finally { if (btn) btn.disabled = false; }++ };window.previewAiViewers = async function previewAiViewers(btn) {-- const box = document.getElementById('aiv-preview');-- if (btn) btn.disabled = true;++ const box = $('aiv-preview'); if (btn) btn.disabled = true;++ if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Asking the director… (this is a dry run — nothing is posted)</span>'; }try {-- const data = await api('/ai-viewers/preview', { method: 'POST', body: {} });-- if (box) { box.style.display = ''; box.innerHTML = `<i class="fa-solid fa-comment"></i> ${esc(data.line || '')}`; }-- } catch (e) {-- if (box) { box.style.display = ''; box.innerHTML = `<span class="muted">${esc(e.message || 'Preview failed')}</span>`; }-- } finally { if (btn) btn.disabled = false; }++ const d = await api('/ai-viewers/preview', { method: 'POST', body: {} });++ if (!box) return;++ if (d.line) { box.innerHTML = `<i class="fa-solid fa-comment"></i> ${esc(d.line)}`; return; }++ const p = d.plan;++ box.innerHTML = `++ <div class="muted" style="font-size:0.78rem">Context: ${d.context ? `${Math.round(d.context.stable_chars / 4)} tokens stable (cached) + ${Math.round(d.context.volatile_chars / 4)} tokens fresh · heard ${d.context.sources.heardAgeMs != null ? ago(d.context.sources.heardAgeMs) : 'nothing'} · seen ${d.context.sources.seenAgeMs != null ? ago(d.context.sources.seenAgeMs) : 'never'} · ${d.context.sources.chatLines} chat lines · ${d.context.sources.people} people known` : ''}${d.cost != null ? ` · this preview cost ${money(d.cost)}${d.usage && d.usage.cached ? ` (${Math.round((d.usage.cached / d.usage.input) * 100)}% cached)` : ''}` : ''}</div>++ ${p && p.lines && p.lines.length ? p.lines.map(l => `<div class="aiv-preview-line"><b>${esc(l.bot)}</b> <span class="muted">→ ${esc(l.target)}${l.reply_to ? ' @' + esc(l.reply_to) : ''} · +${Math.round((l.delay_ms || 0) / 1000)}s</span><br>${esc(l.text)}<br><span class="muted aiv-log-reason">${esc(l.reason || '')}</span></div>`).join('') : `<p class="muted">The director would stay quiet right now${p && p.notes ? ` — ${esc(p.notes)}` : ''}.</p>`}++ <details style="margin-top:6px"><summary class="muted" style="cursor:pointer;font-size:0.78rem">What the bots were shown</summary><pre class="aiv-pre">${esc(d.stable_preview || '')}\n\n---\n\n${esc(d.volatile_preview || '')}</pre></details>`;++ } catch (e) { if (box) box.innerHTML = `<span class="muted">${esc(e.message || 'Preview failed')}</span>`; }++ finally { if (btn) btn.disabled = false; }};-- // ── Roster ────────────────────────────────────────────────++ // ── roster ────────────────────────────────────────────────window.loadAivRoster = async function loadAivRoster() {-- const list = document.getElementById('aiv-roster-list');-- if (!list) return;++ const list = $('aiv-roster-list'); if (!list) return;try {const data = await api('/ai-viewers/roster');const bots = data.bots || [];-- const count = document.getElementById('aiv-roster-count');-- if (count) count.textContent = bots.length ? `(${bots.length})` : '';-- if (!bots.length) { list.innerHTML = '<p class="muted">No bots yet. Enable ambient viewers, or right-click a chatter and “AI Clone” them.</p>'; return; }-- list.innerHTML = bots.map(renderBotRow).join('');++ const count = $('aiv-roster-count'); if (count) count.textContent = bots.length ? `(${bots.length})` : '';++ if (!bots.length) { list.innerHTML = '<p class="muted">No viewers yet. Set the roster size above, or right-click a chatter and “AI Clone” them.</p>'; return; }++ const muted = new Set((((_cfg || {}).settings || {}).runtime || {}).muted || []);++ list.innerHTML = bots.map(b => renderBotRow(b, muted.has(String(b.username || b.display_name).toLowerCase()))).join('');} catch (e) { list.innerHTML = '<p class="muted">Failed to load roster</p>'; }};---- function renderBotRow(b) {-- const src = b.source === 'clone'-- ? `<span class="aiv-badge clone" title="Cloned from ${esc(b.cloned_from || '')}">clone</span>`-- : `<span class="aiv-badge">ambient</span>`;++ function renderBotRow(b, isMuted) {++ const src = b.source === 'clone' ? `<span class="aiv-badge clone" title="Cloned from ${esc(b.cloned_from || '')}">clone</span>` : `<span class="aiv-badge">ambient</span>`;const mem = b.memory ? `<div class="aiv-bot-memory" title="Rolling memory">🧠 ${esc(b.memory)}</div>` : '';const idn = b.identity ? `<div class="aiv-bot-identity">${esc(b.identity)}</div>` : (b.blurb ? `<div class="aiv-bot-identity muted">${esc(b.blurb)}</div>` : '');-- return `-- <div class="aiv-bot ${b.is_active ? '' : 'inactive'}" data-id="${b.id}">++ return `<div class="aiv-bot ${b.is_active ? '' : 'inactive'} ${isMuted ? 'muted-bot' : ''}" data-id="${b.id}"><div class="aiv-bot-head">-- <span class="aiv-dot" style="background:${esc(b.color)}"></span>-- <b>${esc(b.display_name)}</b> ${src}++ <span class="aiv-dot" style="background:${esc(b.color)}"></span><b>${esc(b.display_name)}</b> ${src}${isMuted ? '<span class="aiv-badge" title="Muted by a mod">muted</span>' : ''}<span class="muted aiv-bot-meta">${b.msg_count} msgs</span><span class="aiv-bot-actions"><button class="btn btn-tiny btn-outline" title="Edit identity" onclick="aivEditBot(${b.id})"><i class="fa-solid fa-pen"></i></button>++ <button class="btn btn-tiny btn-outline" title="${isMuted ? 'Unmute' : 'Mute (mods can also /ai mute name)'}" onclick="aivMuteBot(${b.id}, ${isMuted ? 0 : 1})"><i class="fa-solid ${isMuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i></button><button class="btn btn-tiny btn-outline" title="Clear memory" onclick="aivClearBot(${b.id})"><i class="fa-solid fa-eraser"></i></button><button class="btn btn-tiny btn-outline" title="${b.is_active ? 'Deactivate' : 'Activate'}" onclick="aivToggleBot(${b.id}, ${b.is_active ? 0 : 1})"><i class="fa-solid ${b.is_active ? 'fa-pause' : 'fa-play'}"></i></button><button class="btn btn-tiny btn-danger" title="Delete" onclick="aivDeleteBot(${b.id})"><i class="fa-solid fa-trash"></i></button></span>-- </div>-- ${idn}-- ${mem}-- </div>`;++ </div>${idn}${mem}</div>`;}--window.aivEditBot = async function aivEditBot(id) {const row = document.querySelector(`.aiv-bot[data-id="${id}"] .aiv-bot-identity`);-- const cur = row ? row.textContent : '';-- const next = prompt('Edit this bot’s identity / character brief:', cur || '');++ const next = prompt('Edit this viewer’s identity / character brief:', row ? row.textContent : '');if (next === null) return;-- try { await api(`/ai-viewers/bots/${id}`, { method: 'PATCH', body: { identity: next } }); toast('Updated', 'success'); loadAivRoster(); }-- catch (e) { toast(e.message || 'Failed', 'error'); }++ try { await api(`/ai-viewers/bots/${id}`, { method: 'PATCH', body: { identity: next } }); toast('Updated', 'success'); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }++ };++ window.aivMuteBot = async function aivMuteBot(id, mute) {++ try { const d = await api(`/ai-viewers/bots/${id}/${mute ? 'mute' : 'unmute'}`, { method: 'POST', body: {} }); toast(d.message || 'ok', 'success'); _cfg = await api('/ai-viewers/config'); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }};--window.aivClearBot = async function aivClearBot(id) {-- if (!confirm('Clear this bot’s rolling memory? Its personality stays.')) return;-- try { await api(`/ai-viewers/bots/${id}/clear-memory`, { method: 'POST', body: {} }); toast('Memory cleared', 'success'); loadAivRoster(); }-- catch (e) { toast(e.message || 'Failed', 'error'); }++ if (!confirm('Clear this viewer’s rolling memory? Its personality stays.')) return;++ try { await api(`/ai-viewers/bots/${id}/clear-memory`, { method: 'POST', body: {} }); toast('Memory cleared', 'success'); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }};--window.aivToggleBot = async function aivToggleBot(id, active) {-- try { await api(`/ai-viewers/bots/${id}`, { method: 'PATCH', body: { is_active: active } }); loadAivRoster(); }-- catch (e) { toast(e.message || 'Failed', 'error'); }++ try { await api(`/ai-viewers/bots/${id}`, { method: 'PATCH', body: { is_active: active } }); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }};--window.aivDeleteBot = async function aivDeleteBot(id) {-- if (!confirm('Delete this bot for good?')) return;-- try { await api(`/ai-viewers/bots/${id}`, { method: 'DELETE' }); toast('Deleted', 'success'); loadAivRoster(); }-- catch (e) { toast(e.message || 'Failed', 'error'); }++ if (!confirm('Delete this viewer for good?')) return;++ try { await api(`/ai-viewers/bots/${id}`, { method: 'DELETE' }); toast('Deleted', 'success'); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }};---- // ── Clone (called from the chat context menu) ─────────────window.aivCloneChatter = async function aivCloneChatter(kind, ref, displayName) {toast(`Cloning ${displayName || 'chatter'}…`, 'info');++ try { const data = await api('/ai-viewers/clone', { method: 'POST', body: { kind, ref } }); toast(`AI clone of ${data.bot?.display_name || displayName} created`, 'success'); _rosterLoaded = true; loadAivRoster(); }++ catch (e) { toast(e.message || 'Clone failed', 'error'); }++ };++++ // ── Broadcast workspace: compact per-slot section ──────────++ window.aivRenderSlotSection = async function aivRenderSlotSection(container, managedStreamId) {++ if (!container) return;try {-- const data = await api('/ai-viewers/clone', { method: 'POST', body: { kind, ref } });-- toast(`AI clone of ${data.bot?.display_name || displayName} created`, 'success');-- _rosterLoaded = true;-- loadAivRoster();-- } catch (e) { toast(e.message || 'Clone failed', 'error'); }++ const d = await api('/ai-viewers/config');++ _cfg = _cfg || d;++ const s = d.settings || {}, st = d.status || {}, c = d.config || {};++ const slotOn = !(s.slots && s.slots[String(managedStreamId)] === false);++ const pill = st.kill_switch ? ['off', 'Disabled by admin'] : st.running ? [st.paused ? 'warn' : 'ok', st.paused ? 'Paused' : `Running · ${st.bots} viewers · ${String(st.mode).replace('_', ' ')}`] : c.enabled ? ['warn', 'Enabled · joins when live'] : ['off', 'Off'];++ container.innerHTML = `++ <div class="aiv-slot-head"><span class="aiv-status ${pill[0]}">${esc(pill[1])}</span>++ <label class="staff-inline-toggle"><input type="checkbox" ${c.enabled ? 'checked' : ''} onchange="aivSetEnabled(this.checked)"> <b>AI viewers on for my channel</b></label>++ <label class="staff-inline-toggle"><input type="checkbox" ${slotOn ? 'checked' : ''} onchange="aivSetSlot('${managedStreamId}', this.checked)"> <span>…and on this slot</span></label>++ </div>++ <div class="aiv-slot-grid">++ <label><span>Activity</span><select class="form-input form-input-sm" onchange="aivSet('activity', this.value)">${['quiet', 'normal', 'lively', 'chaos', 'custom'].map(v => `<option value="${v}" ${s.activity === v ? 'selected' : ''}>${ENUM_LABELS.activity[v]}</option>`).join('')}</select></label>++ <label><span>Viewers <b id="aivslot-roster-val">${s.roster_size ?? 3}</b></span><input type="range" min="0" max="12" step="1" value="${s.roster_size ?? 3}" oninput="document.getElementById('aivslot-roster-val').textContent=this.value" onchange="aivSet('roster_size', parseInt(this.value,10))"></label>++ <label><span>Bots talking to each other <b id="aivslot-ratio-val">${Math.round((s.bot_to_bot_ratio || 0) * 100)}%</b></span><input type="range" min="0" max="0.8" step="0.05" value="${s.bot_to_bot_ratio ?? 0.3}" oninput="document.getElementById('aivslot-ratio-val').textContent=Math.round(this.value*100)+'%'" onchange="aivSet('bot_to_bot_ratio', parseFloat(this.value))"></label>++ <label><span>Look at the screen</span><select class="form-input form-input-sm" onchange="aivSet('vision_policy', this.value)">${['off', 'thumbnail', 'when_addressed', 'periodic'].map(v => `<option value="${v}" ${s.vision_policy === v ? 'selected' : ''}>${ENUM_LABELS.vision_policy[v]}</option>`).join('')}</select></label>++ </div>++ <div class="bc-ws-toggle-grid" style="margin-top:8px">++ <label class="bc-toggle-label bc-ws-toggle-chip"><input type="checkbox" ${s.hear_enabled ? 'checked' : ''} onchange="aivSet('hear_enabled', this.checked)"><i class="fa-solid fa-ear-listen"></i><span>Hear the stream</span></label>++ <label class="bc-toggle-label bc-ws-toggle-chip"><input type="checkbox" ${s.tts_enabled ? 'checked' : ''} onchange="aivSet('tts_enabled', this.checked)"><i class="fa-solid fa-volume-high"></i><span>TTS</span></label>++ <label class="bc-toggle-label bc-ws-toggle-chip"><input type="checkbox" ${s.powerchat_forward ? 'checked' : ''} onchange="aivSet('powerchat_forward', this.checked)"><i class="fa-solid fa-bolt"></i><span>PowerChat</span></label>++ <label class="bc-toggle-label bc-ws-toggle-chip"><input type="checkbox" ${s.reply_to_relay_chat ? 'checked' : ''} onchange="aivSet('reply_to_relay_chat', this.checked)"><i class="fa-solid fa-tower-broadcast"></i><span>Reply to relayed chat</span></label>++ </div>++ <div class="ai-bot-actions" style="margin-top:8px">++ <button class="btn btn-small btn-outline" onclick="aivCommand('${st.paused ? 'resume' : 'pause'}', this)"><i class="fa-solid ${st.paused ? 'fa-play' : 'fa-pause'}"></i> ${st.paused ? 'Resume' : 'Pause'}</button>++ <button class="btn btn-small btn-outline" onclick="aivCommand('nudge', this)" ${st.running ? '' : 'disabled'}><i class="fa-solid fa-hand-point-right"></i> Nudge</button>++ <a class="btn btn-small btn-outline" href="/dashboard#chatai" onclick="if(typeof navigate==='function'){event.preventDefault();navigate('/dashboard');setTimeout(()=>{const b=document.querySelector('#dash-tabs .ch-tab[data-dtab=chatai]'); if(b) b.click();},300);}"><i class="fa-solid fa-sliders"></i> All settings, roster & activity feed</a>++ </div>++ <div class="muted" style="font-size:0.78rem;margin-top:6px">Mods can type <code>/ai pause</code>, <code>/ai resume</code>, <code>/ai mute name</code> in chat. The bots always answer you when you address chat or say a viewer's name.</div>`;++ } catch (e) { container.innerHTML = `<span class="muted">AI viewers unavailable: ${esc(e.message)}</span>`; }};++ window.aivSetSlot = function aivSetSlot(msId, on) { aivSet('slots', { [String(msId)]: !!on }); };})();@@@ -1010,6 +1010,12 @@ function _wsRenderPanel() {</div></details>++ <!-- AI Chat Viewers (v3) — compact per-slot controls; full panel lives on the dashboard -->++ <details class="bc-ws-slot-settings" id="bc-ws-aiv-details">++ <summary><i class="fa-solid fa-robot"></i> AI Chat Viewers</summary>++ <div class="bc-ws-slot-settings-inner"><div id="bc-ws-aiv" data-ms="${ms.id}"><span class="muted">Loading…</span></div></div>++ </details>++<!-- VOD / Clips Settings --><details class="bc-ws-slot-settings"><summary><i class="fa-solid fa-film"></i> VOD & Clips Settings</summary>@@@ -1158,6 +1164,9 @@ function _wsRenderPanel() {// Load restream destinations for this slot_wsLoadRestreamDests(ms.id);++++ // AI viewers per-slot section (async; the panel HTML above only holds the placeholder).++ try { const aiv = document.getElementById('bc-ws-aiv'); if (aiv && window.aivRenderSlotSection) window.aivRenderSlotSection(aiv, aiv.dataset.ms); } catch { /* */ }}/* ── Tab switching ───────────────────────────────────────────── */@@@ -459,6 +459,45 @@ router.get('/ai/usage', (req, res) => {}});++// AI viewers fleet (admin): running workers + per-channel spend/status, kill controls.++router.get('/ai/viewers/status', (req, res) => {++ try {++ const viewers = require('../ai/viewers');++ const v3 = viewers.v3(); const v2 = viewers.v2();++ const running = [];++ for (const w of v3.workers.values()) {++ const u = db.getUserById(w.userId);++ running.push({ engine: 'v3', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length, mode: w.mode, paused: w.paused, stats: w.stats, started_at: w.startedAt });++ }++ for (const w of v2.workers.values()) {++ const u = db.getUserById(w.userId);++ running.push({ engine: 'v2', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length });++ }++ const channels = db.all(`SELECT c.user_id, u.username, c.enabled, c.use_shared_key, c.daily_budget_cents, c.settings_json,++ (SELECT COUNT(*) FROM channel_ai_bots b WHERE b.channel_user_id = c.user_id AND b.is_active = 1) AS bots++ FROM channel_ai_config c JOIN users u ON u.id = c.user_id ORDER BY c.enabled DESC, u.username`).map(r => {++ let sj = {}; try { sj = JSON.parse(r.settings_json || '{}') || {}; } catch { /* */ }++ let spent = 0; try { spent = db.getAiCostTodayForUser(r.user_id, 'ai_viewers'); } catch { /* */ }++ return { user_id: r.user_id, username: r.username, enabled: !!r.enabled, shared_key: !!r.use_shared_key, cap_usd: (r.daily_budget_cents || 0) / 100, spent_today_usd: spent, bots: r.bots, engine: sj.engine || null, activity: sj.activity || null };++ });++ res.json({ engine_default: viewers.version, kill_switch: db.getSetting('ai_viewers_enabled') === false, global_spend_today_usd: require('../ai/viewers/budget').globalViewerSpendToday(), running, channels });++ } catch (err) { res.status(500).json({ error: err.message }); }++});++router.post('/ai/viewers/:userId/:action', (req, res) => {++ try {++ const viewers = require('../ai/viewers');++ const userId = parseInt(req.params.userId, 10);++ const action = String(req.params.action || '');++ if (action === 'stop') { viewers.stopForUser(userId); db.upsertChannelAiConfig(userId, { enabled: 0 }); return res.json({ ok: true, message: 'Stopped and disabled' }); }++ if (['pause', 'resume', 'nudge'].includes(action)) return res.json({ ok: true, message: viewers.onModCommand(userId, null, [action], { by: `admin:${req.user.username}` }) });++ res.status(400).json({ error: 'Unknown action' });++ } catch (err) { res.status(500).json({ error: err.message }); }++});++router.get('/ai/viewers/:userId/log', (req, res) => {++ try { res.json({ rows: db.getAiViewerLog(parseInt(req.params.userId, 10), { afterId: parseInt(req.query.after, 10) || 0, limit: parseInt(req.query.limit, 10) || 80 }) }); }++ catch (err) { res.status(500).json({ error: err.message }); }++});++// ── AI Explorer + status tools (admin AI tab) ────────────────const aiAnalysis = require('../ai/ai-analysis');nnew file mode 100644@@@ -0,0 +1,286 @@++/**++ * Unified Context Service — everything the site already knows, assembled for an LLM.++ *++ * Nothing here runs a new analysis. It reads the stores other jobs keep warm:++ * stream_timeline_events (live transcript + sounds) stream_memories (frames)++ * streams.ai_overview / ai_title streamer_overviews++ * app_state ai_whole_overview_<uid> (combined blurb) chat_ai_summaries (global/user/relay/anon)++ * chat_messages (channel + relayed + bot lines) follows / subscriptions / stream_first_chats++ * channels (bio, panels) / managed_streams / streams channel_ai_bots (personas, memories)++ * ai_viewer_threads (open conversations)++ *++ * Two halves:++ * STABLE prefix — byte-identical between calls until one of its inputs changes, so the++ * provider's prompt cache hits (Anthropic cache_control / OpenAI prefix).++ * Never put a timestamp, count or anything live in here.++ * VOLATILE tail — deltas: only chat newer than `since`, the last N seconds of speech,++ * the latest frame description with its age, who is in the delta.++ *++ * The only side effect is optional: `wantFreshFrame` may ask stream-memory-job to capture++ * a frame NOW through its normal pipeline (result lands in stream_memories for everyone).++ */++'use strict';++const crypto = require('crypto');++const db = require('../../db/database');++++function clip(str, n) { return (str == null ? '' : String(str)).replace(/\s+/g, ' ').trim().slice(0, n); }++function clipWords(str, n) { const t = clip(str, n * 8); return t.length > n ? t.slice(0, n).replace(/\s+\S*$/, '') + '…' : t; }++function mmss(sec) { sec = Math.max(0, Math.round(Number(sec) || 0)); return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; }++function ago(ms) { if (ms == null || !Number.isFinite(ms)) return 'unknown'; const s = Math.round(ms / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; return `${Math.round(s / 3600)}h ago`; }++function parseTs(v) { if (!v) return null; const t = String(v); const d = new Date(/^\d{4}-\d{2}-\d{2} /.test(t) ? t.replace(' ', 'T') + 'Z' : t); return isNaN(d) ? null : d.getTime(); }++function hash(s) { return crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 16); }++++// Whisper emits markers on silence/noise; never show them to the model.++function scrubSpeech(text) {++ return String(text || '')++ .replace(/[<>]{2,}/g, ' ')++ .replace(/[\[(][^\])]*[\])]/g, ' ')++ .replace(/\b(thanks? for watching|subscribe to my channel)\b[.!]?/gi, ' ')++ .replace(/\s+/g, ' ').trim();++}++function hasWords(t) { return /[a-z0-9]{2,}/i.test(t || ''); }++++// ── Stable prefix blocks ──────────────────────────────────────++function streamerBlock(userId) {++ const parts = [];++ try { const ov = db.getStreamerOverview(userId); if (ov && (ov.overview_short || ov.overview)) parts.push(clip(ov.overview_short || ov.overview, 700)); } catch { /* */ }++ try {++ let w = db.getState(`ai_whole_overview_${userId}`);++ if (typeof w === 'string' && /^\s*\{/.test(w)) { try { w = JSON.parse(w); } catch { /* keep string */ } }++ const t = w && typeof w === 'object' ? w.text : w;++ if (t && typeof t === 'string') parts.push(clip(t, 400));++ } catch { /* */ }++ try {++ const ch = db.getChannelByUserId(userId);++ if (ch) {++ if (ch.description) parts.push(`Channel bio: ${clip(ch.description, 240)}`);++ let panels = []; try { panels = JSON.parse(ch.panels || '[]'); } catch { /* */ }++ const titles = (panels || []).map(p => p && (p.title || p.name)).filter(Boolean).slice(0, 4);++ if (titles.length) parts.push(`Channel panels: ${titles.map(t => clip(t, 40)).join(' · ')}`);++ }++ } catch { /* */ }++ return parts.join('\n');++}++function sessionBlock(stream) {++ if (!stream) return '';++ const parts = [];++ const title = stream.ai_title || stream.title;++ if (title) parts.push(`Stream title: "${clip(title, 140)}"${stream.category ? ` (${clip(stream.category, 40)})` : ''}`);++ if (stream.ai_overview) parts.push(`What this session has been about so far: ${clip(stream.ai_overview, 500)}`);++ return parts.join('\n');++}++function rosterBlock(bots, settings) {++ const muted = new Set(((settings.runtime || {}).muted || []).map(x => String(x).toLowerCase()));++ return bots.map(b => {++ let persona = {}, brain = {};++ try { persona = JSON.parse(b.persona_json || '{}'); } catch { /* */ }++ try { brain = JSON.parse(b.brain_json || '{}'); } catch { /* */ }++ const blurb = (persona.character && persona.character.blurb) || 'a regular viewer';++ const style = (persona.style && persona.style.rules) || 'casual, short';++ const identity = persona.identity || brain.identity || '';++ const memory = brain.memory || '';++ const flags = [];++ if (muted.has(String(b.username).toLowerCase())) flags.push('MUTED — never speaks');++ if (persona.tts === false) flags.push('no tts');++ return `- ${b.username}: ${clip(blurb, 160)}. Style: ${clip(style, 100)}.${identity ? ` About them: ${clip(identity, 240)}.` : ''}${memory ? ` Remembers: ${clip(memory, 300)}` : ''}${flags.length ? ` [${flags.join(', ')}]` : ''}`;++ }).join('\n');++}++function rulesBlock(settings) {++ const parts = [];++ if (settings.channel_personality) parts.push(`Channel personality (from the streamer): ${clip(settings.channel_personality, 1200)}`);++ if (settings.tone) parts.push(`Tone: ${clip(settings.tone, 200)}`);++ if (settings.language && settings.language !== 'auto') parts.push(`Language: ${settings.language}`);++ parts.push(`Emote/emoji usage: ${settings.emote_usage || 'some'}.`);++ if (settings.topics) parts.push(`Topics the viewers enjoy: ${clip(settings.topics, 300)}`);++ if (settings.blocklist) parts.push(`Never mention or engage with: ${clip(settings.blocklist, 500)}`);++ if (settings.channel_memory) parts.push(`Shared running bits in this chat: ${clip(settings.channel_memory, 600)}`);++ parts.push(`Lines are at most ${settings.max_words || 18} words.`);++ return parts.join('\n');++}++function runningBitsBlock(userId) {++ try {++ const closed = db.getRecentClosedAiViewerThreads(userId, 3);++ if (!closed.length) return '';++ return 'Running bits from earlier streams: ' + closed.map(t => clip(t.topic, 80)).join(' · ');++ } catch { return ''; }++}++++/**++ * Build (or reuse) the stable prefix. Returns { text, hash, at }.++ * Cached on `cacheHolder.stable` until its inputs' hash changes or maxAgeMs elapses.++ */++function stablePrefix({ userId, stream, bots, settings, cacheHolder = null, maxAgeMs = 10 * 60 * 1000 }) {++ const text = [++ streamerBlock(userId),++ sessionBlock(stream),++ `The AI viewers in this chat (${bots.length}):\n${rosterBlock(bots, settings)}`,++ rulesBlock(settings),++ runningBitsBlock(userId),++ ].filter(Boolean).join('\n\n');++ const h = hash(text);++ if (cacheHolder && cacheHolder.stable && cacheHolder.stable.hash === h && Date.now() - cacheHolder.stable.at < maxAgeMs) return cacheHolder.stable;++ const out = { text, hash: h, at: Date.now() };++ if (cacheHolder) cacheHolder.stable = out;++ return out;++}++++// ── Volatile blocks ──────────────────────────────────────────++function streamOffsetSec(stream) {++ const started = parseTs(stream && stream.started_at);++ return started ? Math.max(0, Math.round((Date.now() - started) / 1000)) : 0;++}++++function heardBlock(stream, settings) {++ if (!settings.hear_enabled || !stream) return { text: '', ageMs: null, count: 0, sounds: [] };++ const off = streamOffsetSec(stream);++ const from = Math.max(0, off - (settings.hear_window_sec || 90));++ let rows = [];++ try { rows = db.getTimeline(stream.id, { from, to: off + 5, limit: 80 }) || []; } catch { rows = []; }++ const speech = []; const sounds = [];++ let lastEnd = null;++ for (const r of rows) {++ if (r.kind === 'speech') { const t = scrubSpeech(r.text); if (hasWords(t)) { speech.push(`[${mmss(r.start_sec)}] ${t}`); lastEnd = r.end_sec || r.start_sec; } }++ else if (r.kind === 'sound' && r.label) sounds.push({ label: r.label, at: r.start_sec, confidence: r.confidence });++ }++ let text = speech.join('\n');++ if (text.length > 900) text = text.slice(-900).replace(/^[^\[]*/, '');++ const uniqSounds = [...new Set(sounds.map(s => s.label))].slice(0, 6);++ const ageMs = lastEnd != null ? Math.max(0, (off - lastEnd) * 1000) : null;++ return { text, ageMs, count: speech.length, sounds: uniqSounds, latestSound: sounds.length ? sounds[sounds.length - 1] : null };++}++++function seenBlock(stream) {++ if (!stream) return { text: '', ageMs: null, memoryId: null, tags: [] };++ let m = null;++ try { m = db.getLatestStreamMemory(stream.id); } catch { m = null; }++ if (!m) return { text: '', ageMs: null, memoryId: null, tags: [] };++ const desc = String(m.description || '').replace(/\s+—\s+heard:.*$/s, '').trim();++ let tags = []; try { tags = JSON.parse(m.tags || '[]'); } catch { tags = []; }++ const off = streamOffsetSec(stream);++ const ageMs = Math.max(0, (off - (m.offset_seconds || 0)) * 1000);++ return { text: clip(desc, 300), ageMs, memoryId: m.id, tags: Array.isArray(tags) ? tags.slice(0, 6) : [] };++}++++function chatDelta(channelUserId, sinceId, { limit = 40, botNames = new Set() } = {}) {++ let rows = [];++ try { rows = db.getChannelChatSince(channelUserId, sinceId || 0, limit) || []; } catch { rows = []; }++ const lines = rows.map(r => {++ const isBot = r.source_platform === 'ai' || botNames.has(String(r.username || '').toLowerCase());++ const tag = isBot ? ' (AI viewer)' : (r.source_platform && r.source_platform !== 'ai' ? '' : '');++ return { id: r.id, username: r.username, message: clip(r.message, 160), isBot, userId: r.user_id, anonId: r.anon_id, platform: r.source_platform || null, replyTo: r.reply_to_id || null, tag };++ });++ const text = lines.map(l => `${l.username}${l.isBot ? ' (AI viewer)' : ''}: ${l.message}`).join('\n');++ return { lines, text: text.length > 2500 ? text.slice(-2500) : text, maxId: rows.length ? rows[rows.length - 1].id : sinceId || 0 };++}++++function moodBlock() {++ try { const g = require('../chat-ai').getGlobalInsight(); return g && g.overview ? clip(g.overview, 250) : ''; } catch { return ''; }++}++++function personBlock(line, channelUserId, settings) {++ if (!settings.remember_viewers || line.isBot) return '';++ const chatAi = require('../chat-ai');++ let ins = null;++ try {++ if (line.userId) ins = chatAi.getUserInsight(line.userId);++ else if (line.anonId) ins = chatAi.getAnonInsight(line.anonId);++ else if (line.platform && !line.userId) {++ const m = String(line.username || '').match(/^\[[^\]]+\]\s*(.+)$/);++ const ru = db.getRelayUser(line.platform, m ? m[1] : line.username);++ if (ru) ins = chatAi.getRelayUserInsight(ru.id);++ }++ } catch { ins = null; }++ const flags = [];++ try {++ if (line.userId) {++ if (line.userId === channelUserId) flags.push('THE STREAMER');++ else {++ if (db.isActiveSubscriber(line.userId, channelUserId)) flags.push('subscriber');++ else if (db.isFollowing(line.userId, channelUserId)) flags.push('follower');++ }++ }++ const key = line.userId ? `user:${line.username}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);++ if (line.userId !== channelUserId && settings.greet_first_timers && db.isFirstChatInChannel && db.isFirstChatInChannel(key, channelUserId)) flags.push('first time chatting here');++ } catch { /* */ }++ const insight = ins ? clip([ins.overview_24h || ins.overview_alltime, ins.memory].filter(Boolean).join(' — '), 220) : '';++ if (!insight && !flags.length) return '';++ return `- ${line.username}${flags.length ? ` [${flags.join(', ')}]` : ''}${insight ? `: ${insight}` : ''}`;++}++++function threadsBlock(channelUserId, limit) {++ let rows = [];++ try { rows = db.getOpenAiViewerThreads(channelUserId, limit || 5) || []; } catch { rows = []; }++ return {++ rows,++ text: rows.map(t => {++ let who = []; try { who = JSON.parse(t.participants_json || '[]'); } catch { /* */ }++ return `- thread #${t.id} (${t.kind.replace('_', '↔')}, ${who.join(' & ')}${t.topic ? `, about: ${clip(t.topic, 60)}` : ''}, ${t.turns} turns)${t.last_line ? ` last: ${t.last_line_by}: "${clip(t.last_line, 120)}"` : ''}${t.awaiting ? ` — waiting on ${t.awaiting}` : ''}`;++ }).join('\n'),++ };++}++++function viewerCountOf(stream) {++ try { const cs = require('../../chat/chat-server'); if (cs && typeof cs.getStreamViewerCount === 'function') return cs.getStreamViewerCount(stream.id); } catch { /* */ }++ return stream && stream.viewer_count != null ? stream.viewer_count : null;++}++++/**++ * Assemble the volatile tail.++ * @returns {{ text, hash, changed, chatMaxId, delta, sources }}++ */++function volatileTail({ userId, stream, settings, sinceChatId = 0, botNames = new Set(), intents = [], mode = 'normal', linesAllowed = 3, botShare = null }) {++ const heard = heardBlock(stream, settings);++ const seen = seenBlock(stream);++ const chat = chatDelta(userId, sinceChatId, { botNames });++ const mood = moodBlock();++ const threads = threadsBlock(userId, settings.max_open_threads || 3);++ const people = [];++ const seenNames = new Set();++ for (const l of chat.lines) {++ if (l.isBot || seenNames.has(l.username)) continue;++ seenNames.add(l.username);++ if (people.length >= 6) break;++ const p = personBlock(l, userId, settings);++ if (p) people.push(p);++ }++ const vc = viewerCountOf(stream);++ const parts = [];++ parts.push(`Now: ${mmss(streamOffsetSec(stream))} into the stream${vc != null ? `, ${vc} viewers` : ''}${stream && stream.title ? `, title "${clip(stream.title, 100)}"` : ''}.`);++ if (heard.text) parts.push(`Heard on stream (last ${settings.hear_window_sec || 90}s, latest speech ${ago(heard.ageMs)}):\n${heard.text}`);++ else if (settings.hear_enabled) parts.push('Heard on stream: (no speech in the last window)');++ if (heard.sounds.length) parts.push(`Sounds detected: ${heard.sounds.join(', ')}`);++ if (seen.text) parts.push(`On screen (${ago(seen.ageMs)}): ${seen.text}${seen.tags.length ? ` [${seen.tags.join(', ')}]` : ''}`);++ if (mood) parts.push(`Site-wide chat mood: ${mood}`);++ parts.push(chat.text ? `New chat since last look:\n${chat.text}` : 'New chat since last look: (nothing new)');++ if (people.length) parts.push(`About the people in that chat:\n${people.join('\n')}`);++ if (threads.text) parts.push(`Open conversations:\n${threads.text}`);++ if (intents.length) parts.push(`Must handle now:\n${intents.map(i => `- ${i.text}`).join('\n')}`);++ parts.push(`Plan budget: mode ${mode}; up to ${linesAllowed} line(s) this pass${botShare != null ? `; bot-to-bot share so far ${Math.round(botShare * 100)}% (target ≤ ${Math.round((settings.bot_to_bot_ratio || 0) * 100)}%)` : ''}.`);++ const text = parts.join('\n\n');++ const changeKey = hash([heard.text, seen.memoryId, chat.maxId, intents.map(i => i.text).join('|')].join(''));++ return {++ text, hash: changeKey, chatMaxId: chat.maxId,++ delta: { newChat: chat.lines.filter(l => !l.isBot).length, newChatAll: chat.lines.length, speechLines: heard.count, memoryId: seen.memoryId, latestSound: heard.latestSound },++ sources: { heardAgeMs: heard.ageMs, seenAgeMs: seen.ageMs, seenMemoryId: seen.memoryId, chatLines: chat.lines.length, people: people.length, threads: threads.rows.length, mood: !!mood },++ chatLines: chat.lines, threads: threads.rows,++ };++}++++/**++ * Ask the stream-memory pipeline for a fresh frame analysis if the latest one is older++ * than maxAgeSec. The result is stored in stream_memories for everyone (never a private++ * throwaway vision call). Returns true if a capture was started.++ */++async function wantFreshFrame(stream, { maxAgeSec = 180, allowFfmpeg = true } = {}) {++ if (!stream) return false;++ const seen = seenBlock(stream);++ if (seen.ageMs != null && seen.ageMs < maxAgeSec * 1000) return false;++ try {++ const job = require('../stream-memory-job');++ if (typeof job.captureMemoryNow !== 'function') return false;++ await job.captureMemoryNow(stream, { allowFfmpeg, reason: 'ai-viewers' });++ return true;++ } catch { return false; }++}++++module.exports = { stablePrefix, volatileTail, wantFreshFrame, heardBlock, seenBlock, chatDelta, scrubSpeech, clip, clipWords, ago, mmss, hash, streamOffsetSec };@@@ -18,9 +18,28 @@ const _lastSummary = new Map(); // streamId -> { count, at } — throttles the sconst SUMMARY_MIN_NEW = 3; // re-summarize once this many new memories existconst SUMMARY_MAX_AGE_MS = 5 * 60 * 1000; // ...or at least this long since the last rollup--async function _analyzeOne(stream) {++// In-flight capture per stream: the periodic job and on-demand callers (AI viewers asking++// for a fresh look) share ONE ffmpeg + ONE vision call instead of racing.++const _inflight = new Map(); // streamId → Promise++/**++ * Analyze the current frame NOW through the normal pipeline (frame → vision → memory row →++ * overview rollup). Used by the AI viewers so a "look at the screen" is never a private,++ * throwaway vision call — the result is cached for every consumer.++ */++function captureMemoryNow(stream, { allowFfmpeg = true, reason = 'manual' } = {}) {++ if (!stream || !stream.id) return Promise.resolve(false);++ if (!ai.streamMemoryEnabled()) return Promise.resolve(false);++ const existing = _inflight.get(stream.id);++ if (existing) return existing;++ const p = _analyzeOne(stream, { allowFfmpeg, reason }).then(() => true).catch(() => false).finally(() => _inflight.delete(stream.id));++ _inflight.set(stream.id, p);++ _last.set(stream.id, Date.now());++ return p;++}++++async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } = {}) {let image = null;-- try { if (vision && vision.captureFrame) image = await vision.captureFrame(stream); } catch { /* */ }++ if (allowFfmpeg) { try { if (vision && vision.captureFrame) image = await vision.captureFrame(stream); } catch { /* */ } }if (!image) {// Fall back to the freshest thumbnail file on disk.try {@@@ -127,7 +146,8 @@ async function tick() {for (const stream of streams) {if (now - (_last.get(stream.id) || 0) < intervalMs) continue;_last.set(stream.id, now); // set before the async work so we don't double-capture-- _analyzeOne(stream).catch(() => {});++ if (_inflight.has(stream.id)) continue;++ captureMemoryNow(stream, { allowFfmpeg: true, reason: 'periodic' }).catch(() => {});}// GC entries for streams no longer live.if (_last.size > 300 || _lastSummary.size > 300) {@@@ -142,4 +162,4 @@ function start() {console.log('[AI] Stream memory job started (30s poll; captures at the configured interval when enabled)');}--module.exports = { start, tick };++module.exports = { start, tick, captureMemoryNow };@@@ -92,4 +92,36 @@ function byoProvider(cfg) {};}--module.exports = { generate, budgetStatus, byoUsable, byoProvider, SOURCE };++/** Today's shared-key spend on AI viewers across ALL channels (global viewers cap). */++function globalViewerSpendToday() {++ try { return db.get("SELECT COALESCE(SUM(cost_usd),0) AS c FROM ai_usage WHERE source = ? AND COALESCE(provider,'shared') = 'shared' AND created_at >= date('now')", [SOURCE])?.c || 0; } catch { return 0; }++}++++/**++ * v3 status with the degradation ladder. mode ∈ normal | economy | replies_only | streamer_only | silent.++ * normal < 60% of the daily cap spent++ * economy 60–80% (longer cadence, one fewer line per pass)++ * replies_only 80–95% (only replies to real viewers / the streamer)++ * streamer_only 95–100% (only the streamer fast path)++ * silent cap reached, AI disabled, kill switch, or global viewers cap reached++ */++function status(userId) {++ const st = budgetStatus(userId);++ const kill = (() => { const v = db.getSetting('ai_viewers_enabled'); return v === false || v === 'false' || v === 0 || v === '0'; })();++ let mode = 'normal';++ let reason = st.reason;++ if (kill) { mode = 'silent'; reason = 'kill_switch'; }++ else if (!st.active) mode = 'silent';++ else if (st.useShared) {++ const gcap = parseFloat(db.getSetting('ai_viewers_global_cap_usd_per_day')) || 0;++ if (gcap > 0 && globalViewerSpendToday() >= gcap) { mode = 'silent'; reason = 'global_viewers_cap'; }++ else if (st.capUsd > 0) {++ const ratio = st.spentToday / st.capUsd;++ mode = ratio >= 1 ? 'silent' : ratio >= 0.95 ? 'streamer_only' : ratio >= 0.8 ? 'replies_only' : ratio >= 0.6 ? 'economy' : 'normal';++ if (mode === 'silent') reason = 'over_daily_cap';++ }++ }++ return { ...st, mode, reason, active: st.active && mode !== 'silent' };++}++++module.exports = { generate, budgetStatus, status, byoUsable, byoProvider, globalViewerSpendToday, SOURCE };nnew file mode 100644@@@ -0,0 +1,47 @@++/**++ * AI Chat Viewers — engine selector.++ * site_settings.ai_viewers_engine ('v2' | 'v3') picks the default; a channel can override++ * with settings_json.engine. Both engines expose the same lifecycle surface, so the rest++ * of the server (go-live hooks, chat hook, routes) never cares which one is running.++ */++'use strict';++const db = require('../../db/database');++++function _v2() { return require('./engine'); }++function _v3() { return require('./v3/engine'); }++++function engineForUser(userId) {++ let override = '';++ try { override = (JSON.parse(db.getChannelAiConfig(userId).settings_json || '{}') || {}).engine || ''; } catch { /* */ }++ const site = String(db.getSetting('ai_viewers_engine') || 'v2');++ return (override || site) === 'v3' ? _v3() : _v2();++}++function engineForStream(streamId) {++ if (_v3().hasWorker(streamId)) return _v3();++ if (_v2().hasWorker(streamId)) return _v2();++ return null;++}++++module.exports = {++ get version() { return String(db.getSetting('ai_viewers_engine') || 'v2'); },++ engineForUser,++ hasWorker(streamId) { return !!engineForStream(streamId); },++ startForStream(stream) { if (!stream || !stream.user_id) return; return engineForUser(stream.user_id).startForStream(stream); },++ stopForStream(streamId) { const e = engineForStream(streamId); if (e) return e.stopForStream(streamId); },++ stopForUser(userId) { _v2().stopForUser(userId); _v3().stopForUser(userId); },++ applyConfigForUser(userId) {++ // If the channel switched engines, stop the other one first.++ const want = engineForUser(userId);++ const other = want === _v3() ? _v2() : _v3();++ try { other.stopForUser(userId); } catch { /* */ }++ return want.applyConfigForUser(userId);++ },++ reloadForUser(userId) { return this.applyConfigForUser(userId); },++ onRealChatMessage(streamId, ev) { const e = engineForStream(streamId); if (e) return e.onRealChatMessage(streamId, ev); },++ onChatEvent(streamId, ev) { const e = engineForStream(streamId); if (e && e.onChatEvent) return e.onChatEvent(streamId, ev); if (e) return e.onRealChatMessage(streamId, ev); },++ onModCommand(channelUserId, streamId, args, opts) { const e = engineForUser(channelUserId); return e.onModCommand ? e.onModCommand(channelUserId, streamId, args, opts) : 'This channel runs the legacy AI viewers engine — switch to v3 for chat commands.'; },++ status(userId) { const e = engineForUser(userId); return e.status ? e.status(userId) : { engine: 'v2', running: _v2().hasWorker ? [..._v2().workers.values()].some(w => w.userId === userId) : false }; },++ preview(userId) { const e = engineForUser(userId); return e.preview ? e.preview(userId) : Promise.resolve({ error: 'Preview needs the v3 engine' }); },++ nudge(userId) { const e = engineForUser(userId); return e.nudge ? e.nudge(userId) : false; },++ v2: _v2, v3: _v3,++};@@@ -74,17 +74,60 @@ function botSummary(b) {}// ── Config ────────────────────────────────────────────────────++function statusSafe(userId) { try { return engine.status(userId); } catch (e) { return { error: e.message }; } }++router.get('/config', requireAuth, (req, res) => {try {++ const cfg = db.getChannelAiConfig(req.user.id);res.json({-- config: sanitizeConfig(db.getChannelAiConfig(req.user.id)),++ config: sanitizeConfig(cfg),++ settings: settingsMod.getSettings(req.user.id, cfg),++ schema: settingsMod.describe(),budget: budgetSummary(req.user.id),++ status: statusSafe(req.user.id),++ engine: engine.version,});} catch (e) {res.status(500).json({ error: 'Failed to load AI viewer config' });}});++// ── v3 control surface ────────────────────────────────────────++router.get('/status', requireAuth, (req, res) => {++ try {++ const st = statusSafe(req.user.id);++ let stats = null; try { stats = db.getAiViewerLogStats(req.user.id, 60); } catch { /* */ }++ res.json({ status: st, last_hour: stats, budget: budgetSummary(req.user.id) });++ } catch (e) { res.status(500).json({ error: 'Failed to load status' }); }++});++router.get('/log', requireAuth, (req, res) => {++ try {++ const rows = db.getAiViewerLog(req.user.id, { afterId: parseInt(req.query.after, 10) || 0, limit: parseInt(req.query.limit, 10) || 50, streamId: parseInt(req.query.stream_id, 10) || null });++ res.json({ rows });++ } catch (e) { res.status(500).json({ error: 'Failed to load log' }); }++});++router.get('/threads', requireAuth, (req, res) => {++ try { res.json({ open: db.getOpenAiViewerThreads(req.user.id, 10), recent: db.getRecentClosedAiViewerThreads(req.user.id, 10) }); }++ catch (e) { res.status(500).json({ error: 'Failed to load threads' }); }++});++for (const cmd of ['pause', 'resume', 'nudge']) {++ router.post(`/${cmd}`, requireAuth, (req, res) => {++ try { res.json({ ok: true, message: engine.onModCommand(req.user.id, null, [cmd], { by: req.user.username }), status: statusSafe(req.user.id) }); }++ catch (e) { res.status(500).json({ error: e.message }); }++ });++}++router.post('/bots/:id/mute', requireAuth, (req, res) => { const bot = ownBotOr404(req, res); if (!bot) return; res.json({ ok: true, message: engine.onModCommand(req.user.id, null, ['mute', bot.username], { by: req.user.username }) }); });++router.post('/bots/:id/unmute', requireAuth, (req, res) => { const bot = ownBotOr404(req, res); if (!bot) return; res.json({ ok: true, message: engine.onModCommand(req.user.id, null, ['unmute', bot.username], { by: req.user.username }) }); });++router.post('/byo/test', requireAuth, async (req, res) => {++ try {++ const cfg = db.getChannelAiConfig(req.user.id);++ const body = req.body || {};++ const override = budget.byoProvider({ ...cfg, byo_key: (body.byo_key && body.byo_key !== KEY_SENTINEL) ? body.byo_key : cfg.byo_key, byo_base_url: body.byo_base_url ?? cfg.byo_base_url, byo_model: body.byo_model ?? cfg.byo_model });++ const r = await ai.llm.testProvider(override);++ res.json(r);++ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }++});++router.put('/config', requireAuth, (req, res) => {try {const body = req.body || {};@@@ -102,10 +145,15 @@ router.put('/config', requireAuth, (req, res) => {fields.byo_key = body.byo_key;}db.upsertChannelAiConfig(req.user.id, fields);++ // v3 settings blob (partial merge, validated + clamped in settings.js).++ if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);try { engine.applyConfigForUser(req.user.id); } catch { /* */ }++ const cfg = db.getChannelAiConfig(req.user.id);res.json({-- config: sanitizeConfig(db.getChannelAiConfig(req.user.id)),++ config: sanitizeConfig(cfg),++ settings: settingsMod.getSettings(req.user.id, cfg),budget: budgetSummary(req.user.id),++ status: statusSafe(req.user.id),});} catch (e) {res.status(500).json({ error: 'Failed to save AI viewer config' });@@@ -135,18 +183,21 @@ router.patch('/bots/:id', requireAuth, (req, res) => {try {const bot = ownBotOr404(req, res);if (!bot) return;++ const body = req.body || {};const fields = {};-- if (req.body.display_name !== undefined) fields.display_name = req.body.display_name;-- if (req.body.is_active !== undefined) fields.is_active = req.body.is_active ? 1 : 0;-- if (req.body.identity !== undefined) {-- let persona = {};-- try { persona = JSON.parse(bot.persona_json || '{}'); } catch { /* */ }-- persona.identity = String(req.body.identity || '').slice(0, 1200);-- fields.persona_json = persona;-- }-- const updated = db.updateChannelAiBot(bot.id, fields);++ if (body.display_name !== undefined) fields.display_name = String(body.display_name || '').slice(0, 40) || bot.username;++ if (body.is_active !== undefined) fields.is_active = body.is_active ? 1 : 0;++ let persona = {}; try { persona = JSON.parse(bot.persona_json || '{}') || {}; } catch { /* */ }++ let touched = false;++ if (body.identity !== undefined) { persona.identity = String(body.identity || '').slice(0, 1200); touched = true; }++ if (body.blurb !== undefined) { persona.character = { ...(persona.character || {}), blurb: String(body.blurb || '').slice(0, 300) }; touched = true; }++ if (body.style !== undefined) { persona.style = { ...(persona.style || {}), rules: String(body.style || '').slice(0, 300) }; touched = true; }++ if (body.tts !== undefined) { persona.tts = !!body.tts; touched = true; }++ if (body.color !== undefined && /^#[0-9a-f]{3,8}$/i.test(String(body.color))) { persona.color = String(body.color); fields.avatar_color = persona.color; touched = true; }++ if (touched) fields.persona_json = persona;++ if (Object.keys(fields).length) db.updateChannelAiBot(bot.id, fields);try { engine.applyConfigForUser(req.user.id); } catch { /* */ }-- res.json({ bot: botSummary(updated) });++ res.json({ bot: botSummary(db.getChannelAiBot(bot.id)) });} catch (e) {res.status(500).json({ error: 'Failed to update bot' });}@@@ -218,6 +269,11 @@ router.post('/clone', requireAuth, async (req, res) => {// ── Preview one line (no posting) ─────────────────────────────router.post('/preview', requireAuth, async (req, res) => {try {++ if (engine.engineForUser(req.user.id).version === 'v3') {++ const out = await engine.preview(req.user.id);++ if (out && out.error) return res.status(400).json({ error: out.error });++ return res.json(out);++ }const st = budget.budgetStatus(req.user.id);if (!st.active) {const why = st.reason === 'shared_ai_disabled' ? 'Shared AI is currently disabled by the admin.'nnew file mode 100644@@@ -0,0 +1,176 @@++/**++ * AI Chat Viewers — per-channel settings schema.++ *++ * Every knob the streamer can turn lives in channel_ai_config.settings_json (merged over++ * admin defaults from site_settings.ai_viewers_default_settings_json, merged over the++ * built-in defaults below). Ranges are enforced here, server-side, on every save.++ * Legacy columns (num_ambient_bots, pacing_seconds, persona, transcribe_enabled,++ * vision_enabled) are read as fallbacks so existing rows keep working.++ */++'use strict';++const db = require('../../db/database');++++// key → { def, type, min, max, values, max_len, help }++const SCHEMA = {++ engine: { def: '', type: 'enum', values: ['', 'v2', 'v3'], help: 'Engine override (blank = site default)' },++ // Roster++ roster_size: { def: 3, type: 'int', min: 0, max: 12, help: 'How many ambient AI viewers hang out in chat' },++ // Activity++ activity: { def: 'normal', type: 'enum', values: ['quiet', 'normal', 'lively', 'chaos', 'custom'], help: 'Preset for how chatty the bots are' },++ lines_per_min: { def: 3, type: 'num', min: 0.5, max: 12, help: 'Max bot lines per minute (admin ceiling applies)' },++ min_gap_sec: { def: 4, type: 'int', min: 2, max: 60, help: 'Minimum seconds between any two bot lines' },++ director_interval_sec: { def: 20, type: 'int', min: 8, max: 120, help: 'How often the director looks at the stream and plans lines (active)' },++ idle_interval_sec: { def: 60, type: 'int', min: 20, max: 300, help: 'Director cadence when nothing is happening' },++ lines_per_tick: { def: 3, type: 'int', min: 1, max: 6, help: 'Max lines planned per director pass' },++ // Social++ bot_to_bot_ratio: { def: 0.3, type: 'num', min: 0, max: 0.8, help: 'Share of bot lines that are bots talking to each other' },++ reply_probability: { def: 0.6, type: 'num', min: 0, max: 1, help: 'Chance a real viewer message gets a bot reply' },++ mention_fast_path_sec: { def: 6, type: 'int', min: 0, max: 30, help: 'Reply to the streamer within this many seconds even between director passes' },++ react_to_sounds: { def: true, type: 'bool', help: 'React to detected sounds (laughs, music, alarms…)' },++ react_to_scene_changes:{ def: true, type: 'bool', help: 'React when what is on screen changes' },++ greet_first_timers: { def: true, type: 'bool', help: 'Welcome first-time chatters in this channel' },++ reply_to_relay_chat: { def: true, type: 'bool', help: 'Also reply to relayed Twitch/Kick/YouTube/RS chatters' },++ max_open_threads: { def: 3, type: 'int', min: 0, max: 6, help: 'Concurrent conversations the bots keep going' },++ max_thread_turns: { def: 6, type: 'int', min: 2, max: 20, help: 'Turns before a bot conversation naturally ends' },++ thread_idle_close_sec: { def: 240, type: 'int', min: 60, max: 1800, help: 'Close a conversation nobody continued after this long' },++ // Senses++ hear_enabled: { def: true, type: 'bool', help: 'Use the live audio transcript (free, local)' },++ hear_window_sec: { def: 90, type: 'int', min: 30, max: 300, help: 'How many seconds of speech the bots "hear"' },++ vision_policy: { def: 'when_addressed', type: 'enum', values: ['off', 'thumbnail', 'when_addressed', 'periodic'], help: 'When to look at the screen: never / cached thumbnail only / fresh frame when the streamer addresses chat / periodically' },++ vision_max_age_sec: { def: 180, type: 'int', min: 60, max: 900, help: 'Consider the last screen analysis stale after this long' },++ vision_periodic_sec: { def: 300, type: 'int', min: 120, max: 1800, help: 'Periodic screen analysis cadence (periodic policy)' },++ // Style++ language: { def: 'en', type: 'str', max_len: 12, help: 'Language the bots chat in (ISO code or "auto")' },++ tone: { def: '', type: 'str', max_len: 300, help: 'Overall vibe of the bots (e.g. cozy, hype, sarcastic)' },++ emote_usage: { def: 'some', type: 'enum', values: ['none', 'some', 'lots'], help: 'How often bots use emotes/emoji' },++ max_words: { def: 18, type: 'int', min: 4, max: 60, help: 'Longest bot line, in words' },++ topics: { def: '', type: 'str', max_len: 300, help: 'Things the bots like to bring up' },++ blocklist: { def: '', type: 'str', max_len: 500, help: 'Topics/words the bots must avoid' },++ channel_personality: { def: '', type: 'str', max_len: 4000, help: 'Free-form description of your channel for the bots' },++ // Behaviour++ quiet_from: { def: '', type: 'str', max_len: 5, help: 'Quiet hours start (HH:MM, streamer local time)' },++ quiet_to: { def: '', type: 'str', max_len: 5, help: 'Quiet hours end (HH:MM)' },++ quiet_tz: { def: 'UTC', type: 'str', max_len: 48, help: 'IANA time zone for quiet hours' },++ quiet_allow_replies: { def: true, type: 'bool', help: 'Still answer the streamer during quiet hours' },++ tts_enabled: { def: true, type: 'bool', help: 'Read bot lines aloud with TTS (per-bot override in roster)' },++ powerchat_forward: { def: true, type: 'bool', help: 'Show bot lines on your PowerChat overlay' },++ memory_fold_min: { def: 10, type: 'int', min: 3, max: 60, help: 'How often bots consolidate what they remember' },++ remember_viewers: { def: true, type: 'bool', help: 'Let bots use what the site already knows about regulars' },++ channel_memory: { def: '', type: 'str', max_len: 600, help: 'Running bits the bots share (maintained automatically; editable)' },++ // Object-valued (validated separately)++ slots: { def: {}, type: 'obj', help: 'Per-slot enable: { "<managed_stream_id>": true|false }' },++ byo: { def: {}, type: 'obj', help: '{ provider, base_url, model, model_chat, model_vision, model_director, model_summary }' },++ runtime: { def: {}, type: 'obj', help: 'Live mod state: { paused, muted:[bot usernames] }' },++};++++const ACTIVITY_PRESETS = { quiet: 1, normal: 3, lively: 5, chaos: 8 };++++function _parseJson(v) {++ if (!v) return {};++ if (typeof v === 'object') return v;++ try { return JSON.parse(v) || {}; } catch { return {}; }++}++++function adminDefaults() {++ try {++ const raw = db.getSetting('ai_viewers_default_settings_json');++ return _parseJson(raw);++ } catch { return {}; }++}++function adminLimits() {++ const n = (k, d) => { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; };++ return {++ maxRoster: Math.max(0, Math.min(12, n('ai_viewers_max_roster', 12))),++ maxLinesPerMin: Math.max(0.5, Math.min(12, n('ai_viewers_max_lines_per_min', 12))),++ };++}++++/** Coerce one value against the schema; returns undefined when unusable. */++function coerce(key, v, limits) {++ const sp = SCHEMA[key];++ if (!sp) return undefined;++ switch (sp.type) {++ case 'bool': return v === true || v === 'true' || v === 1 || v === '1';++ case 'int': { const n = parseInt(v, 10); if (!Number.isFinite(n)) return undefined; return Math.max(sp.min, Math.min(sp.max, n)); }++ case 'num': { const n = parseFloat(v); if (!Number.isFinite(n)) return undefined; return Math.max(sp.min, Math.min(sp.max, n)); }++ case 'enum': return sp.values.includes(String(v)) ? String(v) : undefined;++ case 'str': return String(v == null ? '' : v).slice(0, sp.max_len || 500);++ case 'obj': return (v && typeof v === 'object' && !Array.isArray(v)) ? v : undefined;++ default: return undefined;++ }++}++++/** Validate + clamp a partial settings object (unknown keys dropped). */++function sanitize(input, limits = adminLimits()) {++ const out = {};++ for (const [k, v] of Object.entries(input || {})) {++ const c = coerce(k, v, limits);++ if (c !== undefined) out[k] = c;++ }++ if (out.roster_size != null) out.roster_size = Math.min(out.roster_size, limits.maxRoster);++ if (out.lines_per_min != null) out.lines_per_min = Math.min(out.lines_per_min, limits.maxLinesPerMin);++ if (out.slots) { const s = {}; for (const [id, en] of Object.entries(out.slots)) if (/^\d+$/.test(id)) s[id] = !!en; out.slots = s; }++ if (out.byo) {++ const b = out.byo; const clean = {};++ for (const k of ['provider', 'base_url', 'model', 'model_chat', 'model_vision', 'model_director', 'model_summary']) if (b[k] != null) clean[k] = String(b[k]).trim().slice(0, 300);++ if (clean.provider && !['openai', 'anthropic'].includes(clean.provider)) delete clean.provider;++ out.byo = clean;++ }++ if (out.runtime) { out.runtime = { paused: !!out.runtime.paused, muted: Array.isArray(out.runtime.muted) ? out.runtime.muted.map(String).slice(0, 24) : [] }; }++ for (const k of ['quiet_from', 'quiet_to']) if (out[k] && !/^\d{2}:\d{2}$/.test(out[k])) out[k] = '';++ return out;++}++++/** Fully-resolved settings for a channel: built-in ← admin defaults ← row (+ legacy columns). */++function getSettings(userId, cfgRow = null) {++ const cfg = cfgRow || db.getChannelAiConfig(userId) || {};++ const limits = adminLimits();++ const base = {};++ for (const [k, sp] of Object.entries(SCHEMA)) base[k] = typeof sp.def === 'object' ? { ...sp.def } : sp.def;++ const admin = sanitize(adminDefaults(), limits);++ const row = sanitize(_parseJson(cfg.settings_json), limits);++ const s = { ...base, ...admin, ...row };++ // Legacy column fallbacks (rows that predate settings_json).++ if (row.roster_size == null && cfg.num_ambient_bots != null) s.roster_size = Math.min(limits.maxRoster, Math.max(0, cfg.num_ambient_bots));++ if (row.channel_personality == null && cfg.persona) s.channel_personality = String(cfg.persona).slice(0, 4000);++ if (row.hear_enabled == null && cfg.transcribe_enabled != null) s.hear_enabled = !!cfg.transcribe_enabled;++ if (row.vision_policy == null && cfg.vision_enabled != null) s.vision_policy = cfg.vision_enabled ? 'when_addressed' : 'off';++ if (s.activity !== 'custom' && ACTIVITY_PRESETS[s.activity]) s.lines_per_min = Math.min(limits.maxLinesPerMin, ACTIVITY_PRESETS[s.activity]);++ s.lines_per_min = Math.min(s.lines_per_min, limits.maxLinesPerMin);++ s.roster_size = Math.min(s.roster_size, limits.maxRoster);++ return s;++}++++/** Persist a partial update (merged into the stored JSON). Returns the resolved settings. */++function updateSettings(userId, partial) {++ const cfg = db.getChannelAiConfig(userId) || {};++ const current = _parseJson(cfg.settings_json);++ const clean = sanitize(partial);++ const merged = { ...current, ...clean };++ if (partial && partial.byo && current.byo) merged.byo = { ...current.byo, ...clean.byo };++ if (partial && partial.slots && current.slots) merged.slots = { ...current.slots, ...clean.slots };++ db.upsertChannelAiConfig(userId, { settings_json: JSON.stringify(merged) });++ return getSettings(userId);++}++++/** Schema description for the UI (defaults resolved with admin overrides). */++function describe() {++ const admin = sanitize(adminDefaults());++ const limits = adminLimits();++ return Object.entries(SCHEMA).map(([key, sp]) => ({++ key, type: sp.type, help: sp.help,++ def: admin[key] !== undefined ? admin[key] : sp.def,++ min: key === 'roster_size' ? 0 : sp.min, max: key === 'roster_size' ? limits.maxRoster : (key === 'lines_per_min' ? limits.maxLinesPerMin : sp.max),++ values: sp.values || null,++ }));++}++++function isQuietNow(s, at = new Date()) {++ if (!s.quiet_from || !s.quiet_to) return false;++ let hm;++ try { hm = new Intl.DateTimeFormat('en-GB', { timeZone: s.quiet_tz || 'UTC', hour: '2-digit', minute: '2-digit', hour12: false }).format(at); } catch { hm = at.toISOString().slice(11, 16); }++ const cur = hm.replace(':', ''); const from = s.quiet_from.replace(':', ''); const to = s.quiet_to.replace(':', '');++ return from <= to ? (cur >= from && cur < to) : (cur >= from || cur < to);++}++++module.exports = { SCHEMA, ACTIVITY_PRESETS, getSettings, updateSettings, sanitize, describe, adminLimits, isQuietNow };nnew file mode 100644@@@ -0,0 +1,103 @@++/**++ * AI viewers v3 — the director.++ *++ * ONE structured LLM call per pass plans up to K lines for the whole roster: who speaks,++ * to whom (a viewer, another bot in a thread, the streamer, or nobody), with what text,++ * after what delay, and why. The stable prefix goes in the (cached) system role; the++ * volatile tail is the user message. Batching lines per call is the single biggest++ * token saving over the old one-call-per-line engine; the cached prefix is the second.++ */++'use strict';++const llm = require('../../llm');++++const PLAN_SCHEMA = {++ type: 'object',++ additionalProperties: false,++ properties: {++ skip: { type: 'boolean', description: 'true when nothing worth saying right now' },++ notes: { type: 'string', description: 'one short line of director reasoning (logged only)' },++ lines: {++ type: 'array',++ items: {++ type: 'object',++ additionalProperties: false,++ properties: {++ bot: { type: 'string', description: 'username of the AI viewer that speaks' },++ target: { type: 'string', enum: ['viewer', 'bot', 'streamer', 'ambient'] },++ reply_to: { type: ['string', 'null'], description: 'username being replied to (viewer or bot), else null' },++ thread: { type: ['string', 'null'], description: 'existing thread id as a string, "new" to start one, or null' },++ topic: { type: ['string', 'null'], description: 'topic label when thread is "new"' },++ delay_ms: { type: 'integer', minimum: 0, maximum: 45000 },++ text: { type: 'string', description: 'the chat line, in character' },++ reason: { type: 'string', description: 'why this bot says this now (<= 80 chars)' },++ },++ required: ['bot', 'target', 'reply_to', 'thread', 'topic', 'delay_ms', 'text', 'reason'],++ },++ },++ threads_close: { type: 'array', items: { type: 'string' }, description: 'thread ids that reached a natural end' },++ },++ required: ['skip', 'notes', 'lines', 'threads_close'],++};++++const DIRECTOR_RULES = `You are the DIRECTOR of a cast of AI viewers in a live stream's chat. Each pass you decide which of them speak next, to whom, and what they say — like an improv coach cueing actors. You never speak yourself.++++Hard rules:++- Every line must sound like a real, casual chat message from that specific viewer, fully in character and in their typing style. Short. No quotation marks, no stage directions, no meta commentary, never mention being an AI, bot, model, or "director".++- If the streamer addressed chat or named an AI viewer, that MUST be answered first (target "streamer"), naturally and specifically about what they said/are doing.++- Reply to real viewers when it makes sense (target "viewer", reply_to = their username). Prefer people flagged first-time / follower / subscriber. Never reply to yourself.++- Bots may talk to each other (target "bot", reply_to = the other bot) to continue an open thread or start one when chat is quiet — keep the bot-to-bot share at or under the target given, and keep each thread moving (a question, a joke callback, a disagreement), then let it end.++- "ambient" lines react to what is happening on stream (heard / seen) — not filler like "nice stream".++- Use the transcript and screen description as the source of truth for what is happening; the timestamps tell you what is recent.++- Vary who speaks; the same viewer never posts two lines in a row; spread delay_ms so lines arrive over the pass, streamer replies first (small delay).++- Respect the channel rules, tone, language, blocklist and the word limit. Plain text only; emoji only per the emote policy.++- Do not repeat anything already said in chat, by anyone. If there is genuinely nothing to add, return skip=true with an empty lines array.`;++++function buildSystem(stableText) {++ // Order: rules (constant) → stable context (per stream, changes rarely). Both cached.++ return [++ { text: DIRECTOR_RULES, cache: true },++ { text: stableText, cache: true },++ ];++}++++/**++ * Plan a pass. Returns { plan, usage, model, cost, latencyMs } or null (quiet).++ * @param {object} args { stableText, volatileText, maxLines, provider (BYO override or null), ownerUserId, cacheKey, temperature }++ */++async function plan({ stableText, volatileText, maxLines = 3, provider = null, ownerUserId = null, cacheKey = null, temperature = 0.9 }) {++ const r = await llm.complete({++ role: 'director',++ system: buildSystem(stableText),++ user: `${volatileText}\n\nPlan the next pass now (at most ${maxLines} lines). Return JSON only.`,++ json: { name: 'plan', schema: PLAN_SCHEMA, strict: true },++ maxTokens: 70 * maxLines + 120,++ temperature,++ cacheKey,++ kind: 'ai_viewers_director', source: 'ai_viewers', ownerUserId,++ provider,++ });++ if (!r) return null;++ let p = r.json;++ if (!p || typeof p !== 'object') p = llm.parseJsonLoose(r.text) || { skip: true, lines: [] };++ const lines = Array.isArray(p.lines) ? p.lines.filter(l => l && typeof l === 'object' && typeof l.text === 'string' && l.bot) : [];++ return { plan: { skip: !!p.skip && !lines.length, notes: String(p.notes || '').slice(0, 200), lines, threads_close: Array.isArray(p.threads_close) ? p.threads_close.map(String) : [] }, usage: r.usage, model: r.model, cost: r.cost, latencyMs: r.latencyMs };++}++++/**++ * Fast path: ONE line from ONE bot answering the streamer right now (no full plan).++ */++async function quickReply({ stableText, situationText, bot, streamerLine, maxWords = 18, provider = null, ownerUserId = null, cacheKey = null }) {++ const r = await llm.complete({++ role: 'chat',++ system: buildSystem(stableText),++ user: `${situationText}\n\nThe streamer just said: "${streamerLine}"\n\nWrite ONE chat line as ${bot.username} answering the streamer directly and specifically (max ${maxWords} words, plain text, in character). Return only the line.`,++ maxTokens: 70,++ temperature: 0.9,++ cacheKey,++ kind: 'ai_viewers_reply', source: 'ai_viewers', ownerUserId,++ provider,++ });++ return r ? { text: r.text, usage: r.usage, model: r.model, cost: r.cost } : null;++}++++module.exports = { plan, quickReply, PLAN_SCHEMA, DIRECTOR_RULES, buildSystem };nnew file mode 100644@@@ -0,0 +1,428 @@++/**++ * AI Chat Viewers v3 — engine.++ *++ * Per live stream: a worker holds the roster, settings, a cached stable prompt prefix,++ * a scheduler, and pending intents. A DIRECTOR pass runs on a cadence (or immediately++ * on a priority intent), assembles the unified context (only deltas), makes ONE++ * structured LLM call that plans several lines, and hands them to the scheduler.++ * Streamer mentions get a guaranteed answer via a fast path when the next pass is far.++ * Nothing runs when nothing changed. Spend degrades gracefully toward the streamer's cap.++ */++'use strict';++const db = require('../../../db/database');++const settingsMod = require('../settings');++const roster = require('../roster');++const budget = require('../budget');++const context = require('../../context');++const director = require('./director');++const poster = require('./poster');++const mention = require('./mention');++const fold = require('./fold');++const { Scheduler } = require('./scheduler');++++const ACTIVE_WINDOW_MS = 45000;++const SKIP_LOG_EVERY_MS = 5 * 60 * 1000;++const STREAMER_QUIET_MS = 8000; // after answering the streamer, hold ambient chatter briefly++++function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }++function norm(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); }++++class AiViewersEngineV3 {++ constructor() { this.workers = new Map(); this.version = 'v3'; }++++ hasWorker(streamId) { return this.workers.has(streamId); }++ workerForUser(userId) { for (const w of this.workers.values()) if (w.userId === userId) return w; return null; }++++ _killSwitch() { const v = db.getSetting('ai_viewers_enabled'); return v === false || v === 'false' || v === 0 || v === '0'; }++++ // ── Lifecycle ─────────────────────────────────────────────++ async startForStream(stream) {++ try {++ if (!stream || !stream.id || !stream.user_id) return;++ if (this.workers.has(stream.id)) return;++ if (this._killSwitch()) return;++ const cfg = db.getChannelAiConfig(stream.user_id);++ if (!cfg.enabled) return;++ const settings = settingsMod.getSettings(stream.user_id, cfg);++ let full = stream;++ try { full = db.getStreamById(stream.id) || stream; } catch { /* */ }++ if (full.managed_stream_id && settings.slots && settings.slots[String(full.managed_stream_id)] === false) {++ console.log(`[AI-Viewers] slot ${full.managed_stream_id} has AI viewers off — not starting for stream ${stream.id}`);++ return;++ }++ const bots = roster.ensureRoster(stream.user_id, settings.roster_size);++ if (!bots.length) return;++++ const worker = {++ streamId: stream.id, userId: stream.user_id, stream: full, cfg, settings, bots,++ stable: null, // cached stable prefix {text,hash,at}++ lastChatId: db.getMaxChatMessageIdForChannel(stream.user_id),++ lastTickAt: 0, nextTickAt: 0, tickTimer: null, foldTimer: null, ticking: false,++ lastRealInputAt: 0, lastStreamerReplyAt: 0, lastSkipLogAt: 0, lastVolatileHash: null,++ intents: [], // [{ kind:'streamer'|'mention'|'sound'|'scene', text, bots:[], at }]++ botLines: new Map(), // botId → recent lines (memory fold)++ recentLines: [], // normalized last 30 bot lines (dedupe)++ lastSeenMemoryId: null, lastPeriodicVisionAt: 0,++ paused: !!(settings.runtime && settings.runtime.paused),++ mode: 'normal',++ stats: { ticks: 0, lines: 0, skips: 0, cost: 0 },++ startedAt: Date.now(), stopped: false,++ };++ worker.scheduler = new Scheduler(worker, {++ post: (line) => this._post(worker, line),++ dropped: (line, why) => poster.log(worker, { event: 'skip', bot_username: line.bot, text: line.text, reason: why }),++ });++ this.workers.set(stream.id, worker);++ poster.log(worker, { event: 'info', reason: `engine v3 started — ${bots.length} viewer(s), ${settings.activity} activity, director every ${settings.director_interval_sec}s` });++ console.log(`[AI-Viewers v3] started for stream ${stream.id} (user ${stream.user_id}, ${bots.length} bots)`);++ this._scheduleTick(worker, 4000);++ worker.foldTimer = setInterval(() => this._fold(worker).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000);++ } catch (e) { console.warn('[AI-Viewers v3] start failed:', e.message); }++ }++++ stopForStream(streamId) {++ const w = this.workers.get(streamId);++ if (!w) return;++ w.stopped = true;++ if (w.tickTimer) clearTimeout(w.tickTimer);++ if (w.foldTimer) clearInterval(w.foldTimer);++ w.scheduler.stop();++ this.workers.delete(streamId);++ try { db.closeAllAiViewerThreads(w.userId); } catch { /* */ }++ this._fold(w).catch(() => {});++ poster.log(w, { event: 'info', reason: `stopped — ${w.stats.lines} line(s), ${w.stats.ticks} pass(es), $${w.stats.cost.toFixed(4)}` });++ console.log(`[AI-Viewers v3] stopped for stream ${streamId}`);++ }++ stopForUser(userId) { for (const [sid, w] of this.workers) if (w.userId === userId) this.stopForStream(sid); }++++ /** Hot-apply config: reload settings/roster on live workers; start/stop as needed. */++ applyConfigForUser(userId) {++ const cfg = db.getChannelAiConfig(userId);++ const settings = settingsMod.getSettings(userId, cfg);++ const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();++ for (const [sid, w] of this.workers) {++ if (w.userId !== userId) continue;++ if (!cfg.enabled || this._killSwitch() || (w.stream.managed_stream_id && settings.slots && settings.slots[String(w.stream.managed_stream_id)] === false)) { this.stopForStream(sid); continue; }++ w.cfg = cfg; w.settings = settings; w.paused = !!(settings.runtime && settings.runtime.paused);++ try { w.bots = roster.ensureRoster(userId, settings.roster_size); } catch { /* */ }++ w.stable = null; // prefix inputs may have changed++ if (w.foldTimer) { clearInterval(w.foldTimer); w.foldTimer = setInterval(() => this._fold(w).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000); }++ poster.log(w, { event: 'info', reason: 'settings applied' });++ }++ if (cfg.enabled && !this._killSwitch()) for (const s of live) if (!this.workers.has(s.id)) this.startForStream(s).catch(() => {});++ }++ reloadForUser(userId) { return this.applyConfigForUser(userId); }++++ // ── Chat events (native, relayed, anon — everything public) ─++ onRealChatMessage(streamId, ev) { return this.onChatEvent(streamId, ev); }++ onChatEvent(streamId, ev = {}) {++ const w = this.workers.get(streamId);++ if (!w || w.stopped) return;++ const uname = String(ev.username || '');++ if (ev.isBot || w.bots.some(b => b.username.toLowerCase() === uname.toLowerCase())) return; // never react to our own lines++ if (ev.platform && !w.settings.reply_to_relay_chat) return;++ const isStreamer = !!ev.isStreamer || (ev.userId && ev.userId === w.userId);++ const text = String(ev.message || '');++ // Streamer/mod shortcut: "!ai pause" etc. also works from relayed chat for the streamer.++ if (/^!ai\b/i.test(text) && (isStreamer || ev.isMod)) {++ try { this.onModCommand(w.userId, streamId, text.split(/\s+/).slice(1), { by: uname }); } catch { /* */ }++ return;++ }++ w.lastRealInputAt = Date.now();++ const det = mention.detect(text, w.bots, { isStreamer });++ if (isStreamer && (det.addressedChat || det.bots.length)) {++ this._pushIntent(w, { kind: 'streamer', text: `The streamer (${uname}) said: "${clip(text, 220)}"${det.bots.length ? ` — addressed to ${det.bots.join(', ')}` : ' — addressed to chat'}. A reply is REQUIRED.`, bots: det.bots, line: text, at: Date.now(), msgId: ev.msgId || null });++ this._fastPath(w, { line: text, bots: det.bots, replyTo: uname, msgId: ev.msgId || null }).catch(() => {});++ return;++ }++ if (det.bots.length) {++ this._pushIntent(w, { kind: 'mention', text: `${uname} addressed ${det.bots.join(', ')}: "${clip(text, 200)}" — that viewer should be answered.`, bots: det.bots, at: Date.now(), replyTo: uname });++ this._scheduleTick(w, 2500);++ return;++ }++ if (isStreamer) { this._scheduleTick(w, Math.min(6000, w.settings.director_interval_sec * 1000)); return; }++ // Regular viewer message: the director decides; reply probability trims how often++ // we even suggest it, and a burst of new chat pulls the next pass closer.++ if (Math.random() < (w.settings.reply_probability == null ? 0.6 : w.settings.reply_probability)) {++ this._pushIntent(w, { kind: 'viewer', text: `${uname} said: "${clip(text, 200)}" — consider replying to them.`, bots: [], at: Date.now(), replyTo: uname, soft: true });++ }++ if (Date.now() - w.lastTickAt > 8000) this._scheduleTick(w, 6000);++ }++ _pushIntent(w, intent) {++ w.intents.push(intent);++ // Hard intents first; keep the queue short so a burst can't stack ten replies.++ w.intents.sort((a, b) => (a.soft ? 1 : 0) - (b.soft ? 1 : 0) || a.at - b.at);++ if (w.intents.length > 6) w.intents = w.intents.slice(0, 6);++ }++++ // ── Mod commands: /ai … or !ai … ─────────────────────────++ onModCommand(channelUserId, streamId, args = [], { by = null } = {}) {++ const [cmd, ...rest] = (args || []).map(a => String(a || '').trim()).filter(Boolean);++ const w = this.workerForUser(channelUserId);++ const settings = settingsMod.getSettings(channelUserId);++ const runtime = { paused: !!(settings.runtime && settings.runtime.paused), muted: (settings.runtime && settings.runtime.muted) || [] };++ const save = () => { settingsMod.updateSettings(channelUserId, { runtime }); if (w) { w.settings = settingsMod.getSettings(channelUserId); w.paused = runtime.paused; w.stable = null; } };++ switch ((cmd || 'status').toLowerCase()) {++ case 'pause': runtime.paused = true; save(); if (w) { w.scheduler.clear(); poster.log(w, { event: 'pause', reason: `paused by ${by || 'mod'}` }); } return 'AI viewers paused.';++ case 'resume': runtime.paused = false; save(); if (w) { poster.log(w, { event: 'pause', reason: `resumed by ${by || 'mod'}` }); this._scheduleTick(w, 3000); } return 'AI viewers resumed.';++ case 'mute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); if (!n) return 'Usage: /ai mute <viewer>'; if (!runtime.muted.includes(n)) runtime.muted.push(n); save(); return `Muted ${n}.`; }++ case 'unmute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); runtime.muted = runtime.muted.filter(x => x !== n); save(); return `Unmuted ${n}.`; }++ case 'nudge': if (w) { this._scheduleTick(w, 500); return 'Nudged the director.'; } return 'AI viewers are not running right now.';++ default: {++ if (!w) return `AI viewers: ${runtime.paused ? 'paused' : 'not running for a live stream'}${runtime.muted.length ? `, muted: ${runtime.muted.join(', ')}` : ''}.`;++ return `AI viewers: ${w.paused ? 'paused' : 'running'} · ${w.bots.length} viewers · mode ${w.mode} · ${w.stats.lines} lines this stream${runtime.muted.length ? ` · muted: ${runtime.muted.join(', ')}` : ''}. Commands: pause, resume, mute <name>, unmute <name>, nudge.`;++ }++ }++ }++++ // ── Director loop ────────────────────────────────────────++ _scheduleTick(w, delayMs) {++ if (w.stopped) return;++ const at = Date.now() + Math.max(250, delayMs);++ if (w.tickTimer && w.nextTickAt <= at) return; // an earlier tick is already armed++ if (w.tickTimer) clearTimeout(w.tickTimer);++ w.nextTickAt = at;++ w.tickTimer = setTimeout(() => { w.tickTimer = null; this._tick(w).catch(e => console.warn('[AI-Viewers v3] tick error:', e.message)); }, at - Date.now());++ }++ _cadenceMs(w) {++ const s = w.settings;++ const active = (Date.now() - w.lastRealInputAt) < ACTIVE_WINDOW_MS;++ let ms = (active ? s.director_interval_sec : s.idle_interval_sec) * 1000;++ if (w.mode === 'economy') ms *= 1.5;++ if (w.mode === 'replies_only') ms *= 2;++ return ms;++ }++ _providerFor(w) { return w.cfg.use_shared_key ? null : budget.byoProvider(w.cfg); }++++ async _tick(w) {++ if (w.stopped || w.ticking) return;++ w.ticking = true;++ const s = w.settings;++ try {++ if (this._killSwitch()) { this.stopForStream(w.streamId); return; }++ try { w.stream = db.getStreamById(w.streamId) || w.stream; } catch { /* */ }++ if (!w.stream || !w.stream.is_live) { this.stopForStream(w.streamId); return; }++ const hardIntents = w.intents.filter(i => !i.soft);++ const st = budget.status(w.userId);++ w.mode = st.mode;++ const quiet = settingsMod.isQuietNow(s);++ if (w.paused || (quiet && !(s.quiet_allow_replies && hardIntents.length)) || st.mode === 'silent' || !st.active) {++ w.intents = [];++ this._logSkip(w, w.paused ? 'paused' : quiet ? 'quiet hours' : (st.reason || st.mode));++ return;++ }++ if (st.mode === 'streamer_only' && !hardIntents.some(i => i.kind === 'streamer')) { w.intents = []; this._logSkip(w, 'budget: streamer replies only'); return; }++ if (st.mode === 'replies_only') w.intents = w.intents.filter(i => !i.soft ? true : i.kind === 'viewer');++++ // Vision: look at the screen through the shared memory pipeline when it is stale.++ await this._maybeVision(w, hardIntents.length > 0);++++ const bots = w.bots.filter(b => !((s.runtime && s.runtime.muted) || []).includes(b.username.toLowerCase()));++ if (!bots.length) { this._logSkip(w, 'all viewers muted'); return; }++ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots, settings: s, cacheHolder: w });++ const linesAllowed = Math.max(1, Math.min(s.lines_per_tick, st.mode === 'economy' ? s.lines_per_tick - 1 : s.lines_per_tick));++ const tail = context.volatileTail({++ userId: w.userId, stream: w.stream, settings: s, sinceChatId: w.lastChatId,++ botNames: new Set(w.bots.map(b => b.username.toLowerCase())),++ intents: w.intents.slice(0, 5), mode: st.mode, linesAllowed, botShare: w.scheduler.botShare(),++ });++ const awaiting = tail.threads.some(t => t.awaiting);++ const sceneChanged = tail.delta.memoryId && tail.delta.memoryId !== w.lastSeenMemoryId && s.react_to_scene_changes;++ const sound = s.react_to_sounds && tail.delta.latestSound && (Date.now() - w.lastTickAt) < 120000 ? tail.delta.latestSound : null;++ const changed = tail.hash !== w.lastVolatileHash;++ if (!changed && !w.intents.length && !awaiting && !sceneChanged) { this._logSkip(w, 'nothing new'); return; }++ if (!tail.delta.newChat && !tail.delta.speechLines && !w.intents.length && !awaiting && !sceneChanged && w.scheduler.pending()) { this._logSkip(w, 'lines still queued'); return; }++ w.lastVolatileHash = tail.hash;++ if (sceneChanged) w.lastSeenMemoryId = tail.delta.memoryId;++ let volatileText = tail.text;++ if (sceneChanged && !w.intents.length) volatileText += '\n\nNote: what is on screen just changed — an ambient reaction is welcome.';++ if (sound) volatileText += `\n\nNote: a "${sound.label}" sound was just detected on stream.`;++++ const consumed = w.intents.splice(0, w.intents.length);++ const res = await director.plan({++ stableText: stable.text, volatileText, maxLines: linesAllowed,++ provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`,++ });++ w.lastTickAt = Date.now();++ w.lastChatId = Math.max(w.lastChatId, tail.chatMaxId || 0);++ w.stats.ticks++;++ if (!res) { poster.log(w, { event: 'error', reason: 'director call failed or provider quiet' }); w.intents.unshift(...consumed.filter(i => !i.soft)); return; }++ w.stats.cost += res.cost || 0;++ const accepted = this._acceptPlan(w, res.plan, bots, consumed);++ poster.log(w, {++ event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),++ text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,++ tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,++ });++ for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { db.closeAiViewerThread(n); } catch { /* */ } } }++ try { db.closeStaleAiViewerThreads(w.userId, s.thread_idle_close_sec, s.max_thread_turns); } catch { /* */ }++ } finally {++ w.ticking = false;++ if (!w.stopped) this._scheduleTick(w, this._cadenceMs(w));++ }++ }++++ _logSkip(w, reason) {++ w.stats.skips++;++ if (Date.now() - w.lastSkipLogAt > SKIP_LOG_EVERY_MS) { w.lastSkipLogAt = Date.now(); poster.log(w, { event: 'skip', reason }); }++ }++++ /** Validate + enqueue a plan. Returns the number of accepted lines. */++ _acceptPlan(w, plan, bots, consumed) {++ if (!plan || plan.skip || !plan.lines.length) return 0;++ const s = w.settings;++ const byName = new Map(bots.map(b => [b.username.toLowerCase(), b]));++ const streamerIntent = consumed.find(i => i.kind === 'streamer');++ const out = [];++ let botTargets = 0;++ let lastBot = null;++ for (const raw of plan.lines) {++ const bot = byName.get(String(raw.bot || '').toLowerCase());++ if (!bot) continue;++ if (lastBot === bot.username) continue;++ let text = poster.clean(raw.text, s.max_words);++ if (!text) continue;++ const n = norm(text);++ if (w.recentLines.includes(n)) continue;++ const mod = poster.moderate(text);++ if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); continue; }++ const target = ['viewer', 'bot', 'streamer', 'ambient'].includes(raw.target) ? raw.target : 'ambient';++ if (target === 'bot') {++ const maxBot = Math.floor((s.bot_to_bot_ratio || 0) * Math.max(1, plan.lines.length) + 0.5);++ if (botTargets >= Math.max(0, maxBot) && (s.bot_to_bot_ratio || 0) < 0.79) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: 'bot-to-bot ratio' }); continue; }++ botTargets++;++ }++ // Threads++ let threadId = null;++ try {++ if (raw.thread === 'new') {++ const kind = target === 'bot' ? 'bot_bot' : target === 'streamer' ? 'bot_streamer' : 'bot_viewer';++ const open = db.getOpenAiViewerThreads(w.userId, 10);++ if (open.length < (s.max_open_threads || 3)) {++ const t = db.createAiViewerThread({ channel_user_id: w.userId, stream_id: w.streamId, kind, participants: [bot.username, raw.reply_to].filter(Boolean), topic: raw.topic ? clip(raw.topic, 80) : null, awaiting: target === 'bot' ? raw.reply_to : null });++ threadId = t && t.id;++ }++ } else if (raw.thread && /^\d+$/.test(String(raw.thread))) threadId = parseInt(raw.thread, 10);++ } catch { threadId = null; }++ lastBot = bot.username;++ w.recentLines.push(n); if (w.recentLines.length > 30) w.recentLines.shift();++ out.push({ bot: bot.username, botRow: bot, text, target, replyTo: raw.reply_to || null, threadId, delay_ms: Math.max(0, Math.min(45000, parseInt(raw.delay_ms, 10) || 0)), reason: clip(raw.reason, 120), replyToId: (target === 'streamer' && streamerIntent) ? streamerIntent.msgId : null });++ }++ if (out.length) w.scheduler.enqueue(out);++ return out.length;++ }++++ _post(w, line) {++ if (w.stopped) return;++ const bot = line.botRow || w.bots.find(b => b.username === line.bot);++ if (!bot) return;++ const id = poster.post(w, bot, line.text, { threadId: line.threadId, replyToId: line.replyToId });++ w.stats.lines++;++ if (line.target === 'streamer') w.lastStreamerReplyAt = Date.now();++ poster.log(w, { event: 'line', bot_username: bot.username, target: line.target + (line.replyTo ? `:${line.replyTo}` : ''), thread_id: line.threadId || null, chat_message_id: id, text: line.text, reason: line.reason });++ const buf = w.botLines.get(bot.id) || []; buf.push(line.text); if (buf.length > 24) buf.shift(); w.botLines.set(bot.id, buf);++ if (line.threadId) {++ try { db.touchAiViewerThread(line.threadId, { line: line.text, by: bot.username, awaiting: line.target === 'bot' ? (line.replyTo || null) : null }); } catch { /* */ }++ // A bot↔bot thread waiting on the other bot pulls the next pass closer.++ if (line.target === 'bot') this._scheduleTick(w, Math.min(this._cadenceMs(w), 12000));++ }++ }++++ // ── Streamer fast path ───────────────────────────────────++ async _fastPath(w, { line, bots: named, replyTo, msgId }) {++ const s = w.settings;++ const waitMs = Math.max(0, (s.mention_fast_path_sec || 0) * 1000);++ const nextIn = w.nextTickAt ? w.nextTickAt - Date.now() : Infinity;++ if (nextIn <= waitMs + 1500) { this._scheduleTick(w, Math.min(nextIn, 1500)); return; } // the director will handle it in time++ const st = budget.status(w.userId);++ if (!st.active || st.mode === 'silent' || w.paused) return;++ const muted = new Set(((s.runtime && s.runtime.muted) || []).map(x => String(x).toLowerCase()));++ const pool = w.bots.filter(b => !muted.has(b.username.toLowerCase()));++ if (!pool.length) return;++ const bot = (named.length && pool.find(b => b.username === named[0])) || pool[Math.floor(Math.random() * pool.length)];++ await this._maybeVision(w, true);++ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: pool, settings: s, cacheHolder: w });++ const heard = context.heardBlock(w.stream, s);++ const seen = context.seenBlock(w.stream);++ const situation = [++ heard.text ? `Heard on stream recently:\n${heard.text.split('\n').slice(-6).join('\n')}` : '',++ seen.text ? `On screen (${context.ago(seen.ageMs)}): ${seen.text}` : '',++ ].filter(Boolean).join('\n\n');++ const r = await director.quickReply({ stableText: stable.text, situationText: situation, bot, streamerLine: clip(line, 220), maxWords: s.max_words, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}` });++ if (!r || !r.text) return;++ const text = poster.clean(r.text, s.max_words);++ const mod = text ? poster.moderate(text) : { ok: false, reason: 'empty' };++ if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); return; }++ w.stats.cost += r.cost || 0;++ w.intents = w.intents.filter(i => i.kind !== 'streamer'); // handled — the next pass must not double-reply++ w.recentLines.push(norm(text)); if (w.recentLines.length > 30) w.recentLines.shift();++ poster.log(w, { event: 'mention', bot_username: bot.username, target: `streamer:${replyTo}`, text, reason: 'fast path — streamer addressed chat', tokens_in: r.usage.input, tokens_cached: r.usage.cached, tokens_out: r.usage.output, cost_usd: r.cost, model: r.model });++ w.scheduler.enqueue([{ bot: bot.username, botRow: bot, text, target: 'streamer', replyTo, threadId: null, delay_ms: 0, reason: 'answering the streamer', replyToId: msgId || null }], { basePrio: 3 });++ }++++ // ── Vision policy ────────────────────────────────────────++ async _maybeVision(w, addressed) {++ const s = w.settings;++ if (s.vision_policy === 'off') return;++ const seen = context.seenBlock(w.stream);++ const stale = seen.ageMs == null || seen.ageMs > (s.vision_max_age_sec || 180) * 1000;++ const periodicDue = s.vision_policy === 'periodic' && (Date.now() - w.lastPeriodicVisionAt) > (s.vision_periodic_sec || 300) * 1000 && (Date.now() - w.lastRealInputAt) < 5 * 60000;++ if (!stale && !periodicDue) return;++ const allowFfmpeg = s.vision_policy !== 'thumbnail';++ if (addressed || periodicDue) {++ if (periodicDue) w.lastPeriodicVisionAt = Date.now();++ const p = context.wantFreshFrame(w.stream, { maxAgeSec: s.vision_max_age_sec, allowFfmpeg });++ // Addressed: wait up to 12s so the answer can reference the screen; otherwise fire and forget.++ if (addressed) { const t = new Promise(res => setTimeout(() => res(false), 12000)); const started = await Promise.race([p, t]); if (started) poster.log(w, { event: 'vision', reason: 'fresh frame analyzed (shared memory pipeline)' }); }++ else p.then(ok => { if (ok) poster.log(w, { event: 'vision', reason: 'periodic frame analyzed' }); }).catch(() => {});++ }++ }++++ // ── Memory fold ──────────────────────────────────────────++ async _fold(w) {++ try {++ const r = await fold.foldAll(w, { provider: this._providerFor(w) });++ if (r) { w.stats.cost += r.cost || 0; poster.log(w, { event: 'fold', reason: `memory updated for ${r.updated} viewer(s)`, tokens_in: r.usage && r.usage.input, tokens_cached: r.usage && r.usage.cached, tokens_out: r.usage && r.usage.output, cost_usd: r.cost, model: r.model });++ for (let i = 0; i < w.bots.length; i++) { const fresh = db.getChannelAiBot(w.bots[i].id); if (fresh) w.bots[i] = fresh; }++ w.stable = null;++ }++ } catch (e) { poster.log(w, { event: 'error', reason: `fold: ${e.message}` }); }++ }++++ // ── Status / preview (control panel) ─────────────────────++ status(userId) {++ const w = this.workerForUser(userId);++ const st = budget.status(userId);++ const timelineOn = (() => { try { return require('../../timeline-job').timelineEnabled(); } catch { return false; } })();++ const base = { engine: 'v3', running: !!w, mode: st.mode, budget_active: st.active, budget_reason: st.reason, spent_today_usd: st.spentToday, cap_usd: st.capUsd, timeline_enabled: timelineOn, kill_switch: this._killSwitch() };++ if (!w) return base;++ const seen = context.seenBlock(w.stream);++ const heard = context.heardBlock(w.stream, w.settings);++ return { ...base, stream_id: w.streamId, paused: w.paused, bots: w.bots.length, stats: w.stats, last_tick_at: w.lastTickAt || null, next_tick_in_ms: w.nextTickAt ? Math.max(0, w.nextTickAt - Date.now()) : null,++ queued: w.scheduler.pending(), bot_share: w.scheduler.botShare(), lines_last_minute: w.scheduler.linesLastMinute(),++ senses: { heard_age_ms: heard.ageMs, heard_lines: heard.count, seen_age_ms: seen.ageMs, seen_text: seen.text, stable_prefix_chars: w.stable ? w.stable.text.length : null, stable_cached_since: w.stable ? w.stable.at : null } };++ }++++ /** Dry run: assemble the real context and ask the director what it would do (nothing posted). */++ async preview(userId) {++ let w = this.workerForUser(userId);++ let temp = false;++ if (!w) {++ const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();++ if (!live.length) return { error: 'Go live first — the preview runs on your real stream context.' };++ const cfg = db.getChannelAiConfig(userId); const settings = settingsMod.getSettings(userId, cfg);++ const bots = roster.ensureRoster(userId, settings.roster_size || 3);++ w = { streamId: live[0].id, userId, stream: db.getStreamById(live[0].id) || live[0], cfg, settings, bots, lastChatId: Math.max(0, db.getMaxChatMessageIdForChannel(userId) - 40), intents: [], scheduler: { botShare: () => 0 } };++ temp = true;++ }++ const s = w.settings;++ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: w.bots, settings: s, cacheHolder: temp ? null : w });++ const tail = context.volatileTail({ userId: w.userId, stream: w.stream, settings: s, sinceChatId: Math.max(0, (w.lastChatId || 0) - 25), botNames: new Set(w.bots.map(b => b.username.toLowerCase())), intents: w.intents, mode: 'normal', linesAllowed: s.lines_per_tick, botShare: w.scheduler.botShare() });++ const res = await director.plan({ stableText: stable.text, volatileText: tail.text, maxLines: s.lines_per_tick, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`, temperature: 0.9 });++ return { plan: res ? res.plan : null, usage: res ? res.usage : null, cost: res ? res.cost : null, model: res ? res.model : null, context: { stable_chars: stable.text.length, volatile_chars: tail.text.length, sources: tail.sources }, stable_preview: stable.text.slice(0, 1200), volatile_preview: tail.text.slice(0, 1500) };++ }++++ nudge(userId) { const w = this.workerForUser(userId); if (!w) return false; this._scheduleTick(w, 500); return true; }++}++++module.exports = new AiViewersEngineV3();nnew file mode 100644@@@ -0,0 +1,73 @@++/**++ * AI viewers v3 — memory fold. One summary-role call per channel folds every bot's recent++ * lines (and the threads they took part in) into their rolling memory, plus a channel-wide++ * "running bits" note. Replaces the per-bot fold of v2 (N calls → 1).++ */++'use strict';++const db = require('../../../db/database');++const llm = require('../../llm');++++function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }++++const FOLD_SCHEMA = {++ type: 'object', additionalProperties: false,++ properties: {++ memories: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { bot: { type: 'string' }, memory: { type: 'string' } }, required: ['bot', 'memory'] } },++ channel_memory: { type: 'string', description: 'running jokes / recurring topics for the whole channel, <= 90 words' },++ },++ required: ['memories', 'channel_memory'],++};++++/**++ * @param {object} worker { userId, bots, botLines: Map<botId, string[]>, settings }++ * @returns {Promise<{updated:number, cost:number}|null>}++ */++async function foldAll(worker, { provider = null } = {}) {++ const entries = [];++ for (const bot of worker.bots) {++ const lines = worker.botLines.get(bot.id) || [];++ if (lines.length < 3) continue;++ let brain = {}; try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }++ entries.push({ bot, lines: lines.slice(-20), memory: brain.memory || '' });++ }++ if (!entries.length) return null;++ let channelMemory = '';++ try { channelMemory = (JSON.parse(db.getChannelAiConfig(worker.userId).settings_json || '{}') || {}).channel_memory || ''; } catch { /* */ }++++ const prompt = [++ 'You maintain the memories of AI chat personas for one streaming channel.',++ channelMemory ? `Current channel memory (running bits): ${clip(channelMemory, 600)}` : '',++ ...entries.map(e => `### ${e.bot.username}\nCurrent memory: ${e.memory ? clip(e.memory, 700) : '(none)'}\nRecent lines:\n- ${e.lines.map(l => clip(l, 160)).join('\n- ')}`),++ 'For each persona, write an updated memory: a tight paragraph (max 90 words) of durable facts — running jokes, who they talk to, opinions they formed, recurring topics. Keep the useful old stuff, drop the trivial. Also update the channel memory (running bits everyone shares).',++ ].filter(Boolean).join('\n\n');++++ const r = await llm.complete({++ role: 'summary', user: prompt, json: { name: 'fold', schema: FOLD_SCHEMA, strict: true },++ maxTokens: 160 * entries.length + 160, temperature: 0.4,++ kind: 'ai_viewers_fold', source: 'ai_viewers', ownerUserId: worker.userId, provider,++ });++ if (!r) return null;++ const out = r.json || llm.parseJsonLoose(r.text);++ if (!out || !Array.isArray(out.memories)) return null;++ let updated = 0;++ for (const m of out.memories) {++ const e = entries.find(x => x.bot.username.toLowerCase() === String(m.bot || '').toLowerCase());++ if (!e || !m.memory || !String(m.memory).trim()) continue;++ let brain = {}; try { brain = JSON.parse(e.bot.brain_json || '{}'); } catch { /* */ }++ brain.memory = clip(m.memory, 900);++ brain.timeline = (brain.timeline || []).slice(-8);++ brain.timeline.push({ n: e.bot.msg_count || 0, note: clip(e.lines[e.lines.length - 1], 80) });++ try { db.updateChannelAiBot(e.bot.id, { brain_json: brain }); worker.botLines.set(e.bot.id, []); updated++; } catch { /* */ }++ }++ if (out.channel_memory && String(out.channel_memory).trim()) {++ try {++ const cfg = db.getChannelAiConfig(worker.userId);++ let sj = {}; try { sj = JSON.parse(cfg.settings_json || '{}') || {}; } catch { /* */ }++ sj.channel_memory = clip(out.channel_memory, 600);++ db.upsertChannelAiConfig(worker.userId, { settings_json: JSON.stringify(sj) });++ } catch { /* */ }++ }++ return { updated, cost: r.cost || 0, usage: r.usage, model: r.model };++}++++module.exports = { foldAll };nnew file mode 100644@@@ -0,0 +1,29 @@++/**++ * AI viewers v3 — who is being addressed?++ * detect(message, bots, { isStreamer }) → { bots: [usernames named], addressedChat, question }++ * A streamer talking to "chat"/"guys"/"everyone" or asking a question addresses everyone;++ * "@name" or a bare bot username/display name addresses that bot (anyone can do that).++ */++'use strict';++++const CHAT_WORDS = /\b(chat|guys|gang|everyone|everybody|yall|y'all|you all|anyone|anybody|viewers|lads|folks|people)\b/i;++++function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }++++function detect(message, bots, { isStreamer = false } = {}) {++ const text = String(message || '');++ const lower = text.toLowerCase();++ const named = [];++ for (const b of bots || []) {++ const names = [b.username, b.display_name].filter(Boolean).map(n => String(n).toLowerCase());++ for (const n of names) {++ if (n.length < 3) continue;++ if (lower.includes('@' + n) || new RegExp(`(^|[^a-z0-9_])${escapeRe(n)}([^a-z0-9_]|$)`, 'i').test(text)) { named.push(b.username); break; }++ }++ }++ const question = /\?\s*$/.test(text.trim()) || /\b(what do you|whatd?ya|do you (guys|all)|should i|thoughts\??|opinions?\??|who here|anyone (here|know|want))\b/i.test(text);++ const addressedChat = isStreamer && (CHAT_WORDS.test(text) || question || /^(so|ok|okay|alright|hey|yo)\b/i.test(text.trim()) && text.length < 120);++ return { bots: [...new Set(named)], addressedChat, question };++}++++module.exports = { detect };nnew file mode 100644@@@ -0,0 +1,106 @@++/**++ * AI viewers v3 — posting a bot line into chat (+ moderation + activity log).++ * Ported from the v2 engine's _inject, with the bits it skipped: word filter, per-bot++ * TTS toggle, PowerChat forward toggle, and a log row for the control panel.++ */++'use strict';++const db = require('../../../db/database');++++function botColor(bot) {++ try { return (JSON.parse(bot.persona_json || '{}').color) || bot.avatar_color || '#8a8aff'; } catch { return bot.avatar_color || '#8a8aff'; }++}++function botPersona(bot) { try { return JSON.parse(bot.persona_json || '{}') || {}; } catch { return {}; } }++++/** Strip quotes/disclaimers; enforce a word cap. Returns '' when the line must be dropped. */++function clean(text, maxWords = 18) {++ let t = String(text || '').trim();++ t = t.replace(/^["'`“”]+|["'`“”]+$/g, '').replace(/\s+/g, ' ').trim();++ if (!t) return '';++ if (/\b(as an ai|language model|i am a bot|i'm a bot|as a bot|openai|anthropic)\b/i.test(t)) return '';++ if (/^\w+:\s/.test(t) && t.length < 80) t = t.replace(/^\w+:\s/, ''); // "botname: text" echo++ const words = t.split(' ');++ if (words.length > maxWords) t = words.slice(0, maxWords).join(' ').replace(/[,;:]$/, '');++ if (t.length > 240) t = t.slice(0, 240);++ return t.trim();++}++++/** Word-filter gate — returns { ok, text, reason }. */++function moderate(text) {++ try {++ const wf = require('../../../chat/word-filter');++ const r = wf.check(text);++ if (!r.safe) return { ok: false, text, reason: `word filter: ${r.matches.slice(0, 3).join(', ')}` };++ if (typeof wf.isSpam === 'function' && wf.isSpam(text)) return { ok: false, text, reason: 'spam pattern' };++ } catch { /* filter unavailable → allow */ }++ return { ok: true, text, reason: null };++}++++/**++ * Post a line. Returns the chat message id (or null).++ * @param {object} worker { streamId, userId, settings }++ */++function post(worker, bot, message, { threadId = null, replyToId = null } = {}) {++ const streamId = worker.streamId;++ const persona = botPersona(bot);++ const color = botColor(bot);++ let id = null;++ try {++ const res = db.saveChatMessage({++ stream_id: streamId,++ channel_user_id: worker.userId,++ user_id: null,++ username: bot.username,++ message,++ message_type: 'chat',++ is_global: false,++ reply_to_id: replyToId || null,++ source_platform: 'ai', // marker → excluded from chat-AI feeds++ metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },++ });++ id = res && res.lastInsertRowid ? Number(res.lastInsertRowid) : null;++ } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }++++ const chatMsg = {++ type: 'chat', id: id || undefined,++ username: bot.username, core_username: null,++ user_id: null, anon_id: null, role: 'user',++ message, stream_id: streamId, is_global: false,++ reply_to_id: replyToId || null,++ avatar_url: null, profile_color: color,++ is_ai: true, source_platform: 'ai',++ filtered: false, timestamp: new Date().toISOString(),++ };++ try {++ const chatServer = require('../../../chat/chat-server');++ chatServer.broadcastToStream(streamId, chatMsg);++ chatServer.forwardToGlobal(streamId, chatMsg);++ const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;++ if (ttsOn) chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);++ } catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }++++ if (worker.settings.powerchat_forward !== false) {++ try {++ const pc = require('../../../integrations/powerchat-platform');++ if (worker.userId && pc.channelRelayEnabled(worker.userId, streamId)) {++ pc.forwardChat(worker.userId, {++ chatterName: bot.username,++ externalChatterId: `ai:${bot.username.toLowerCase()}`,++ message,++ messageId: id ? `ov-${id}` : undefined,++ avatarFallback: '🤖',++ });++ }++ } catch { /* non-critical */ }++ }++ try { db.touchChannelAiBot(bot.id); } catch { /* */ }++ return id;++}++++/** Append a row to ai_viewer_log (best-effort). */++function log(worker, row) {++ try {++ db.addAiViewerLog({ channel_user_id: worker.userId, stream_id: worker.streamId || null, ...row });++ } catch { /* */ }++}++++module.exports = { post, clean, moderate, log, botPersona, botColor };nnew file mode 100644@@@ -0,0 +1,97 @@++/**++ * AI viewers v3 — pacing. Planned lines go into a per-stream queue and are released++ * humanly: a minimum gap between any two bot lines, a per-minute ceiling, slow-mode++ * awareness, no bot twice in a row, and a rolling bot-to-bot share cap.++ */++'use strict';++++function rint(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }++++class Scheduler {++ constructor(worker, deps) {++ this.worker = worker; // { streamId, settings, ... }++ this.deps = deps; // { post(line), now() }++ this.queue = []; // [{ bot, text, target, replyTo, threadId, postAt, prio, reason }]++ this.recent = []; // [{ at, target, bot }] last 40 posted++ this.lastPostAt = 0;++ this.lastBot = null;++ this.timer = null;++ this.stopped = false;++ }++++ settings() { return this.worker.settings || {}; }++++ /** Queue planned lines (from a director pass). */++ enqueue(lines, { basePrio = 1 } = {}) {++ const now = Date.now();++ for (const l of lines) {++ const jitter = rint(0, 2500);++ const prio = l.target === 'streamer' ? 3 : basePrio;++ this.queue.push({ ...l, prio, postAt: now + (prio === 3 ? Math.min(l.delay_ms || 0, 4000) : (l.delay_ms || 0)) + jitter });++ }++ // Keep the queue bounded: newest plans win over stale unposted ambient chatter.++ if (this.queue.length > 12) this.queue = this.queue.sort((a, b) => b.prio - a.prio || a.postAt - b.postAt).slice(0, 12);++ this._arm();++ }++++ /** Drop everything not yet posted (config change / pause / stop). */++ clear() { this.queue = []; if (this.timer) { clearTimeout(this.timer); this.timer = null; } }++++ stop() { this.stopped = true; this.clear(); }++++ pending() { return this.queue.length; }++++ /** Share of bot→bot lines over the last 20 posts (0..1). */++ botShare() {++ const last = this.recent.slice(-20);++ if (!last.length) return 0;++ return last.filter(r => r.target === 'bot').length / last.length;++ }++ linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }++++ _slowModeMs() {++ try { const cs = require('../../../chat/chat-server'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }++ }++++ _arm(delay = null) {++ if (this.stopped || this.timer || !this.queue.length) return;++ const next = Math.min(...this.queue.map(q => q.postAt));++ const wait = delay != null ? delay : Math.max(250, next - Date.now());++ this.timer = setTimeout(() => { this.timer = null; this._drain(); }, wait);++ }++++ _drain() {++ if (this.stopped || !this.queue.length) return;++ const s = this.settings();++ const now = Date.now();++ const minGap = Math.max((s.min_gap_sec || 4) * 1000, this._slowModeMs());++ if (now - this.lastPostAt < minGap) return this._arm(minGap - (now - this.lastPostAt));++ const perMin = Math.max(0.5, s.lines_per_min || 3);++ if (this.linesLastMinute() >= perMin) return this._arm(5000);++++ // Pick the highest-priority due line that doesn't violate the alternation/ratio rules.++ const due = this.queue.filter(q => q.postAt <= now).sort((a, b) => b.prio - a.prio || a.postAt - b.postAt);++ if (!due.length) return this._arm();++ let pick = null;++ for (const q of due) {++ if (q.bot === this.lastBot && due.length > 1) continue;++ if (q.target === 'bot' && this.botShare() >= (s.bot_to_bot_ratio || 0) + 0.05 && q.prio < 3) continue;++ pick = q; break;++ }++ if (!pick) {++ // Everything due is blocked by the ratio/alternation rules — drop stale bot↔bot chatter.++ const stale = due.find(q => q.target === 'bot' && q.prio < 3);++ if (stale) { this.queue = this.queue.filter(q => q !== stale); this.deps.dropped && this.deps.dropped(stale, 'bot-to-bot share cap'); }++ return this._arm(3000);++ }++ this.queue = this.queue.filter(q => q !== pick);++ try { this.deps.post(pick); } catch (e) { console.warn('[AI-Viewers] post failed:', e.message); }++ this.lastPostAt = Date.now();++ this.lastBot = pick.bot;++ this.recent.push({ at: this.lastPostAt, target: pick.target, bot: pick.bot });++ if (this.recent.length > 40) this.recent.shift();++ this._arm();++ }++}++++module.exports = { Scheduler };@@@ -886,10 +886,17 @@ class ChatServer {// Bots inject via broadcastToStream directly, so this path only sees// genuine human messages.try {++ let isMod = false;++ try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }require('../integrations/ai-chatbot-service').onRealChatMessage(client.streamId, {username,message: text,userId: client.user?.id || null,++ anonId: client.user ? null : (client.anonId || null),++ isStreamer: !!(client.user && client.user.id === client.channelUserId),++ isMod,++ msgId: chatMsg && chatMsg.id ? chatMsg.id : null,++ channelUserId: client.channelUserId || null,});} catch { /* non-critical */ }@@@ -1324,6 +1331,16 @@ class ChatServer {const argParts = parts.slice(1);switch (cmd) {++ case 'ai': {++ // /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.++ if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }++ try {++ const engine = require('../integrations/ai-chatbot-service');++ const reply = engine.onModCommand ? engine.onModCommand(client.channelUserId, client.streamId, parts.slice(1), { by: client.user?.username }) : 'AI viewers: command not supported by this engine.';++ this.sendTo(ws, { type: 'system', message: reply || 'ok' });++ } catch (e) { this.sendTo(ws, { type: 'system', message: `AI viewers: ${e.message}` }); }++ return;++ }case 'help':this.sendTo(ws, {type: 'system',@@@ -1818,6 +1818,35 @@ function initDb() {updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`);++ // AI viewers v3: per-channel settings blob, persisted conversations, activity log.++ try {++ const cols = database.prepare('PRAGMA table_info(channel_ai_config)').all().map(c => c.name);++ if (!cols.includes('settings_json')) database.exec("ALTER TABLE channel_ai_config ADD COLUMN settings_json TEXT DEFAULT '{}'");++ database.exec(`CREATE TABLE IF NOT EXISTS ai_viewer_threads (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ channel_user_id INTEGER NOT NULL,++ stream_id INTEGER,++ kind TEXT NOT NULL, -- bot_bot | bot_viewer | bot_streamer++ participants_json TEXT NOT NULL, -- ["botname","viewer"...]++ topic TEXT,++ state TEXT DEFAULT 'open', -- open | closed++ awaiting TEXT, -- bot username expected to speak next (or null)++ turns INTEGER DEFAULT 0,++ last_line TEXT, last_line_by TEXT, last_line_at DATETIME,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ database.exec('CREATE INDEX IF NOT EXISTS idx_aiv_threads_open ON ai_viewer_threads(channel_user_id, state, updated_at)');++ database.exec(`CREATE TABLE IF NOT EXISTS ai_viewer_log (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ channel_user_id INTEGER NOT NULL, stream_id INTEGER,++ event TEXT NOT NULL, -- tick | line | skip | mention | fold | vision | degrade | pause | error | info++ bot_username TEXT, target TEXT, thread_id INTEGER, chat_message_id INTEGER,++ text TEXT, reason TEXT,++ tokens_in INTEGER, tokens_cached INTEGER, tokens_out INTEGER, cost_usd REAL, model TEXT,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP++ )`);++ database.exec('CREATE INDEX IF NOT EXISTS idx_aiv_log_channel ON ai_viewer_log(channel_user_id, id)');++ } catch (e) { console.warn('[DB] ai viewers v3 tables:', e.message); }// One-time migration of existing ai_chatbot_configs rows into the new table.const migrated = database.prepare('SELECT COUNT(*) AS c FROM channel_ai_config').get().c;if (!migrated) {@@@ -5957,6 +5986,7 @@ const CHANNEL_AI_CONFIG_DEFAULTS = {enabled: 0, num_ambient_bots: 3, pacing_seconds: 45, persona: '',transcribe_enabled: 0, vision_enabled: 0, use_shared_key: 1,daily_budget_cents: 20, byo_key: '', byo_base_url: '', byo_model: 'gpt-4o-mini',++ settings_json: '{}',};function getChannelAiConfig(userId) {@@@ -5977,6 +6007,8 @@ function upsertChannelAiConfig(userId, fields) {byo_key: (v) => String(v || '').trim().slice(0, 400),byo_base_url: (v) => String(v || '').trim().slice(0, 500),byo_model: (v) => String(v || '').trim().slice(0, 120) || 'gpt-4o-mini',++ // Validated/clamped by ai/viewers/settings.js before it gets here; just bound the size.++ settings_json: (v) => { const t = typeof v === 'string' ? v : JSON.stringify(v || {}); return t.length > 40000 ? '{}' : t; },};const existing = get('SELECT 1 FROM channel_ai_config WHERE user_id = ?', [userId]);if (existing) {@@@ -6027,6 +6059,63 @@ function getChannelAiBot(id) {return get('SELECT * FROM channel_ai_bots WHERE id = ?', [id]);}++// ── AI viewers v3: threads + activity log ───────────────────++function createAiViewerThread({ channel_user_id, stream_id = null, kind, participants, topic = null, awaiting = null }) {++ const r = run('INSERT INTO ai_viewer_threads (channel_user_id, stream_id, kind, participants_json, topic, awaiting) VALUES (?, ?, ?, ?, ?, ?)',++ [channel_user_id, stream_id, kind, JSON.stringify(participants || []), topic, awaiting]);++ return get('SELECT * FROM ai_viewer_threads WHERE id = ?', [r.lastInsertRowid]);++}++function getOpenAiViewerThreads(channelUserId, limit = 6) {++ return all("SELECT * FROM ai_viewer_threads WHERE channel_user_id = ? AND state = 'open' ORDER BY updated_at DESC LIMIT ?", [channelUserId, limit]);++}++function getRecentClosedAiViewerThreads(channelUserId, limit = 3) {++ return all("SELECT * FROM ai_viewer_threads WHERE channel_user_id = ? AND state = 'closed' AND topic IS NOT NULL ORDER BY updated_at DESC LIMIT ?", [channelUserId, limit]);++}++function touchAiViewerThread(id, { line, by, awaiting = null, topic = undefined }) {++ const sets = ['turns = turns + 1', 'last_line = ?', 'last_line_by = ?', 'last_line_at = CURRENT_TIMESTAMP', 'awaiting = ?', 'updated_at = CURRENT_TIMESTAMP'];++ const params = [String(line || '').slice(0, 300), by || null, awaiting];++ if (topic !== undefined) { sets.push('topic = ?'); params.push(topic); }++ params.push(id);++ return run(`UPDATE ai_viewer_threads SET ${sets.join(', ')} WHERE id = ?`, params);++}++function closeAiViewerThread(id) { return run("UPDATE ai_viewer_threads SET state = 'closed', awaiting = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [id]); }++function closeStaleAiViewerThreads(channelUserId, idleSec, maxTurns) {++ return run(`UPDATE ai_viewer_threads SET state = 'closed', awaiting = NULL, updated_at = CURRENT_TIMESTAMP++ WHERE channel_user_id = ? AND state = 'open' AND (updated_at < datetime('now', ?) OR turns >= ?)`, [channelUserId, `-${Math.max(30, idleSec)} seconds`, maxTurns]).changes;++}++function closeAllAiViewerThreads(channelUserId) { return run("UPDATE ai_viewer_threads SET state = 'closed', awaiting = NULL, updated_at = CURRENT_TIMESTAMP WHERE channel_user_id = ? AND state = 'open'", [channelUserId]).changes; }++function addAiViewerLog(row) {++ return run(`INSERT INTO ai_viewer_log (channel_user_id, stream_id, event, bot_username, target, thread_id, chat_message_id, text, reason, tokens_in, tokens_cached, tokens_out, cost_usd, model)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,++ [row.channel_user_id, row.stream_id || null, row.event, row.bot_username || null, row.target || null, row.thread_id || null, row.chat_message_id || null,++ row.text == null ? null : String(row.text).slice(0, 600), row.reason == null ? null : String(row.reason).slice(0, 300),++ row.tokens_in || null, row.tokens_cached || null, row.tokens_out || null, row.cost_usd || null, row.model || null]);++}++function getAiViewerLog(channelUserId, { afterId = 0, limit = 50, streamId = null } = {}) {++ let sql = 'SELECT * FROM ai_viewer_log WHERE channel_user_id = ?';++ const params = [channelUserId];++ if (afterId) { sql += ' AND id > ?'; params.push(afterId); }++ if (streamId) { sql += ' AND stream_id = ?'; params.push(streamId); }++ sql += ' ORDER BY id DESC LIMIT ?'; params.push(Math.max(1, Math.min(200, limit)));++ return all(sql, params).reverse();++}++function pruneAiViewerLog(days = 7) { return run("DELETE FROM ai_viewer_log WHERE created_at < datetime('now', ?)", [`-${days} days`]).changes; }++function getAiViewerLogStats(channelUserId, sinceMin = 60) {++ return get(`SELECT SUM(CASE WHEN event = 'line' THEN 1 ELSE 0 END) AS lines, SUM(CASE WHEN event = 'tick' THEN 1 ELSE 0 END) AS ticks,++ SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skips, COALESCE(SUM(cost_usd),0) AS cost_usd, COALESCE(SUM(tokens_in),0) AS tokens_in, COALESCE(SUM(tokens_cached),0) AS tokens_cached++ FROM ai_viewer_log WHERE channel_user_id = ? AND created_at > datetime('now', ?)`, [channelUserId, `-${sinceMin} minutes`]);++}++// Channel chat (all surfaces: native, relayed, bot lines) newer than an id — what the bots "see".++function getChannelChatSince(channelUserId, afterId = 0, limit = 60) {++ return all(`SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp++ FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation')++ AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP)++ ORDER BY id DESC LIMIT ?`, [channelUserId, afterId, Math.max(1, Math.min(200, limit))]).reverse();++}++function getMaxChatMessageIdForChannel(channelUserId) {++ return get('SELECT COALESCE(MAX(id),0) AS m FROM chat_messages WHERE channel_user_id = ?', [channelUserId])?.m || 0;++}++function getChannelAiBots(channelUserId, { activeOnly = false } = {}) {let sql = 'SELECT * FROM channel_ai_bots WHERE channel_user_id = ?';if (activeOnly) sql += ' AND is_active = 1';@@@ -7830,6 +7919,8 @@ module.exports = {// Site SettingsgetSetting, getSettingRow, getAllSettings, setSetting, deleteSetting,saveVodTranscriptProgress, getVodTranscriptProgress,++ createAiViewerThread, getOpenAiViewerThreads, getRecentClosedAiViewerThreads, touchAiViewerThread, closeAiViewerThread, closeStaleAiViewerThreads, closeAllAiViewerThreads,++ addAiViewerLog, getAiViewerLog, pruneAiViewerLog, getAiViewerLogStats, getChannelChatSince, getMaxChatMessageIdForChannel,getState, setState, deleteState,// Verification KeyscreateVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,@@@ -989,6 +989,8 @@ async function start() {try { require('./ai/stream-memory-job').start(); } catch (e) { console.warn('[AI] memory job not started:', e.message); }try { (() => { try { const d = require('./ai/transcribe').describe(); console.log(`[AI] whisper ${d.available ? 'available' : 'UNAVAILABLE'} — bin=${d.bin} model=${d.model}${d.modelExists ? '' : ' (MISSING)'} live=${d.modelLive}${d.modelLiveExists ? '' : ' (MISSING)'} vad=${d.vadModel || 'off'} threads=${d.threads}`); } catch (e) { console.warn('[AI] whisper probe failed:', e.message); } })();require('./ai/backfill-job').start(); } catch (e) { console.warn('[AI] backfill job not started:', e.message); }++ // AI viewers activity log: keep a week.++ setInterval(() => { try { const n = db.pruneAiViewerLog(7); if (n) console.log(`[AI-Viewers] pruned ${n} log row(s)`); } catch { /* */ } }, 6 * 3600 * 1000);try { require('./ai/streamer-overview-job').start(); } catch (e) { console.warn('[AI] streamer-overview job not started:', e.message); }try { require('./ai/chat-ai').start(); } catch (e) { console.warn('[AI] chat-ai job not started:', e.message); }try { require('./ai/slogan-job').start(); } catch (e) { console.warn('[AI] slogan job not started:', e.message); }ddeleted file mode 100644@@@ -1,140 +0,0 @@--/**-- * ai-chatbot-routes.js — Per-streamer AI chatbot config API.-- *-- * GET /api/ai-chatbot - Load my config (token masked)-- * PUT /api/ai-chatbot - Save my config (token preserved when blank)-- * POST /api/ai-chatbot/validate - Test the AI credentials-- * POST /api/ai-chatbot/preview - Generate a sample bot line (no posting)-- */--'use strict';--const express = require('express');--const db = require('../db/database');--const { requireAuth } = require('../auth/auth');--const aiProvider = require('../ai/ai-provider');--const aiChatbotService = require('./ai-chatbot-service');----const router = express.Router();----function sanitize(cfg) {-- const token = String(cfg.api_token || '');-- return {-- enabled: !!cfg.enabled,-- base_url: cfg.base_url || 'https://api.openai.com/v1',-- model: cfg.model || 'gpt-4o-mini',-- transcribe_enabled: !!cfg.transcribe_enabled,-- transcribe_model: cfg.transcribe_model || 'whisper-1',-- num_bots: cfg.num_bots ?? 3,-- post_interval_seconds: cfg.post_interval_seconds ?? 45,-- persona: cfg.persona || '',-- vision_enabled: !!cfg.vision_enabled,-- has_token: !!token,-- api_token_masked: token ? `****${token.slice(-4)}` : '',-- last_validated_at: cfg.last_validated_at || null,-- };--}----router.get('/', requireAuth, (req, res) => {-- try {-- res.json({ config: sanitize(db.getAiChatbotConfig(req.user.id)) });-- } catch (err) {-- res.status(500).json({ error: 'Failed to load AI chatbot config' });-- }--});----router.put('/', requireAuth, (req, res) => {-- try {-- const b = req.body || {};-- const fields = {};-- if (b.enabled !== undefined) fields.enabled = !!b.enabled;-- if (b.base_url !== undefined) fields.base_url = b.base_url;-- if (b.model !== undefined) fields.model = b.model;-- if (b.transcribe_enabled !== undefined) fields.transcribe_enabled = !!b.transcribe_enabled;-- if (b.transcribe_model !== undefined) fields.transcribe_model = b.transcribe_model;-- if (b.vision_enabled !== undefined) fields.vision_enabled = !!b.vision_enabled;-- if (b.num_bots !== undefined) fields.num_bots = b.num_bots;-- if (b.post_interval_seconds !== undefined) fields.post_interval_seconds = b.post_interval_seconds;-- if (b.persona !== undefined) fields.persona = b.persona;-- // Preserve the stored token unless a non-blank new one is provided.-- if (b.api_token !== undefined && String(b.api_token).trim()) fields.api_token = String(b.api_token).trim();---- const config = db.upsertAiChatbotConfig(req.user.id, fields);---- // Apply changes to any live stream this user is currently running-- // (starts bots if enabling mid-stream, stops them if disabling).-- try { aiChatbotService.applyConfigForUser(req.user.id); } catch { /* non-critical */ }---- res.json({ config: sanitize(config) });-- } catch (err) {-- console.error('[AI-Bots] save config error:', err);-- res.status(500).json({ error: 'Failed to save AI chatbot config' });-- }--});----router.post('/validate', requireAuth, async (req, res) => {-- try {-- const stored = db.getAiChatbotConfig(req.user.id);-- const b = req.body || {};-- const baseUrl = b.base_url || stored.base_url;-- const apiKey = (b.api_token && String(b.api_token).trim()) || stored.api_token || '';-- // A token is only required for hosted OpenAI; self-hosted servers (Ollama etc.) need none.-- if (!apiKey && !aiProvider.isSelfHostedBaseUrl(baseUrl)) {-- return res.status(400).json({ ok: false, error: 'Enter your API token, or set a self-hosted Base URL (Ollama/LM Studio need no token).' });-- }-- // Fail fast with a helpful message before even trying an unreachable local address.-- if (aiProvider.isLocalOrPrivateHost(baseUrl)) {-- return res.json({ ok: false, error: `That Base URL points at a local/private address, which OpenVibe.Live's servers can't reach (the AI viewers run server-side, not in your browser). Expose your AI server publicly — e.g. a tunnel like ngrok or cloudflared — and use that URL.` });-- }-- const result = await aiProvider.testConnection({-- baseUrl,-- apiKey,-- model: b.model || stored.model,-- });-- if (result.ok) {-- try { db.upsertAiChatbotConfig(req.user.id, { last_validated_at: new Date().toISOString() }); } catch {}-- }-- res.json(result);-- } catch (err) {-- res.status(500).json({ ok: false, error: err.message });-- }--});----router.post('/preview', requireAuth, async (req, res) => {-- try {-- const stored = db.getAiChatbotConfig(req.user.id);-- const b = req.body || {};-- const baseUrl = b.base_url || stored.base_url;-- const apiKey = (b.api_token && String(b.api_token).trim()) || stored.api_token || '';-- if (!apiKey && !aiProvider.isSelfHostedBaseUrl(baseUrl)) {-- return res.status(400).json({ error: 'Save or enter an API token first (or use a self-hosted Base URL).' });-- }-- if (aiProvider.isLocalOrPrivateHost(baseUrl)) {-- return res.status(400).json({ error: `That Base URL is a local/private address our servers can't reach. Use a publicly-reachable URL (e.g. an ngrok/cloudflared tunnel to your Ollama).` });-- }-- const persona = b.persona !== undefined ? String(b.persona) : stored.persona;-- const messages = [-- {-- role: 'system',-- content: [-- 'You are a live viewer typing in a Twitch-style stream chat.',-- persona ? `The streamer wants chat viewers to behave like this: ${persona}` : 'You are a playful troll.',-- 'Write ONE short chat message (max ~20 words), casual and lowercase-ish. Be a troll or argumentative but PG-13: no slurs/hate/threats. No quotes, no name prefix. Output only the message.',-- ].filter(Boolean).join('\n'),-- },-- { role: 'user', content: 'The streamer is playing a game and chat is dead. Type your one chat message:' },-- ];-- const raw = await aiProvider.chatCompletion({-- baseUrl,-- apiKey,-- model: b.model || stored.model,-- messages,-- temperature: 1.05,-- maxTokens: 60,-- });-- const sample = String(raw || '').replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim().slice(0, 200);-- res.json({ sample });-- } catch (err) {-- res.status(502).json({ error: err.message });-- }--});----module.exports = router;@@@ -1,17 +1,8 @@/*** ai-chatbot-service.js — compatibility shim.-- *-- * The AI "fake viewers" system was rewritten as AI Chat Viewers 2.0 (persistent-- * per-channel roster + brains, live-situation context, per-streamer budgeting).-- * The new engine lives in ../ai/viewers/engine.js. This module re-exports that-- * singleton so the existing lifecycle hook points keep working unchanged:-- *-- * startForStream(stream) · stopForStream(streamId) · stopForUser(userId)-- * applyConfigForUser(userId) · reloadForUser(userId) · hasWorker(streamId)-- * onRealChatMessage(streamId, { username, message, userId })-- *-- * Call sites: index.js, streaming/whip-handler.js, streaming/routes.js,-- * chat/chat-server.js.++ * The AI chat viewers engine lives in ../ai/viewers (index.js picks v2/v3 per site setting++ * and per-channel override). Every lifecycle hook + chat hook in the server goes through++ * this module so nothing else needs to know which engine is active.*/'use strict';--module.exports = require('../ai/viewers/engine');++module.exports = require('../ai/viewers');@@@ -423,6 +423,13 @@ class ChatRelayService {} catch {}chatServer.broadcastToStream(bridge.streamId, chatMsg);++ // AI viewers see relayed chat too (they used to only see native chat).++ try {++ require('./ai-chatbot-service').onRealChatMessage(bridge.streamId, {++ username: prefixedUsername, message: chatMsg.message, userId: null, anonId: null,++ platform: bridge.platform, relayUsername: username, isStreamer: false, isMod: false, msgId: chatMsg.id || null,++ });++ } catch { /* non-critical */ }// Also surface on the global / username-only overlay (tags stream_channel)try { chatServer.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }// And to viewers of the streamer's other live slots (cross-slot chat)@@@ -733,6 +733,12 @@ class RobotStreamerService {} catch {}chatServer.broadcastToStream(stream.id, mirrored);++ try {++ require('./ai-chatbot-service').onRealChatMessage(stream.id, {++ username, message: mirrored.message, userId: null, anonId: null,++ platform: 'rs', relayUsername: username, isStreamer: false, isMod: false, msgId: mirrored.id || null,++ });++ } catch { /* non-critical */ }// Also surface on the global / username-only overlay (tags stream_channel)try { chatServer.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }// And to viewers of the streamer's other live slots (cross-slot chat)
@@ -13677,3 +13677,41 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -13677,3 +13677,41 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
.ov-pip-resize { width: 26px; height: 26px; opacity: .8; }
.ov-pip-resize { width: 26px; height: 26px; opacity: .8; }
.ov-pip-bar { opacity: 1; pointer-events: auto; }
.ov-pip-bar { opacity: 1; pointer-events: auto; }
}
}
+
+/* ── AI Chat Viewers v3 panel ─────────────────────────────── */
+.aiv-topline { display:flex; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:8px; }
+.aiv-status { font-size:0.75rem; font-weight:700; padding:3px 10px; border-radius:999px; background:var(--bg-tertiary); color:var(--text-secondary); }
+.aiv-status.ok { background:color-mix(in srgb, #22c55e 18%, transparent); color:#4ade80; }
+.aiv-status.warn { background:color-mix(in srgb, #f59e0b 18%, transparent); color:#fbbf24; }
+.aiv-status.off { background:var(--bg-tertiary); color:var(--text-muted); }
+.aiv-senses { display:flex; gap:14px; flex-wrap:wrap; font-size:0.78rem; color:var(--text-secondary); padding:8px 10px; border:1px dashed var(--border); border-radius:8px; margin-bottom:10px; }
+.aiv-senses i { color:var(--accent); margin-right:4px; }
+.aiv-group { border:1px solid var(--border); border-radius:8px; margin-bottom:8px; }
+.aiv-group > summary { cursor:pointer; padding:8px 12px; font-weight:600; font-size:0.9rem; list-style:none; }
+.aiv-group > summary i { color:var(--accent); width:18px; }
+.aiv-group-body { padding:4px 12px 12px; display:grid; grid-template-columns:repeat(auto-fill, minmax(240px, 1fr)); gap:10px 18px; }
+.aiv-field { display:flex; flex-direction:column; gap:4px; font-size:0.83rem; }
+.aiv-field > span:first-child { font-weight:600; display:flex; justify-content:space-between; gap:8px; }
+.aiv-field > span:first-child b { color:var(--accent); font-variant-numeric:tabular-nums; }
+.aiv-field input[type=range] { width:100%; accent-color:var(--accent); }
+.aiv-field.aiv-inline { flex-direction:row; align-items:center; gap:8px; }
+.aiv-field.aiv-inline input[type=range] { width:140px; }
+.aiv-field textarea, .aiv-field input[type=text], .aiv-field select { font-size:0.83rem; }
+label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
+.aiv-log { display:flex; flex-direction:column; gap:2px; max-height:420px; overflow:auto; border:1px solid var(--border); border-radius:8px; padding:6px; }
+.aiv-log-row { display:grid; grid-template-columns:20px 1fr auto; gap:8px; align-items:start; font-size:0.8rem; padding:5px 6px; border-radius:6px; }
+.aiv-log-row:nth-child(odd) { background:color-mix(in srgb, var(--bg-tertiary) 60%, transparent); }
+.aiv-log-row i { color:var(--text-muted); margin-top:3px; }
+.aiv-log-line i { color:var(--accent); } .aiv-log-mention i { color:#f59e0b; } .aiv-log-error i { color:#ef4444; } .aiv-log-tick i { color:#60a5fa; }
+.aiv-log-reason { font-size:0.74rem; }
+.aiv-log-meta { display:flex; flex-direction:column; align-items:flex-end; font-size:0.72rem; white-space:nowrap; }
+.aiv-preview-line { padding:6px 8px; border-left:3px solid var(--accent); margin:6px 0; font-size:0.85rem; background:var(--bg-tertiary); border-radius:0 6px 6px 0; }
+.aiv-pre { white-space:pre-wrap; font-size:0.72rem; max-height:320px; overflow:auto; background:var(--bg-tertiary); padding:8px; border-radius:6px; }
+.aiv-meter-fill.warn { background:#f59e0b; }
+.aiv-bot.muted-bot { opacity:0.7; }
+.aiv-slot-head { display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:8px; }
+.aiv-slot-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:8px 16px; }
+.aiv-slot-grid label { display:flex; flex-direction:column; gap:3px; font-size:0.8rem; }
+.aiv-slot-grid label > span { font-weight:600; display:flex; justify-content:space-between; }
+.aiv-slot-grid label > span b { color:var(--accent); }
+.aiv-slot-grid input[type=range] { accent-color:var(--accent); }
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=136">
+ <link rel="stylesheet" href="/css/style.css?v=137">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -1612,37 +1612,25 @@
@@ -1612,37 +1612,25 @@
<div class="ai-viewers-tabs ch-tabs" id="aiv-subtabs" role="tablist">
<div class="ai-viewers-tabs ch-tabs" id="aiv-subtabs" role="tablist">
<button class="ch-tab active" data-avtab="config" onclick="switchAivTab('config', this)"><i class="fa-solid fa-sliders"></i> Settings</button>
<button class="ch-tab active" data-avtab="config" onclick="switchAivTab('config', this)"><i class="fa-solid fa-sliders"></i> Settings</button>
<button class="ch-tab" data-avtab="roster" onclick="switchAivTab('roster', this)"><i class="fa-solid fa-users"></i> Roster <span class="aiv-count" id="aiv-roster-count"></span></button>
<button class="ch-tab" data-avtab="roster" onclick="switchAivTab('roster', this)"><i class="fa-solid fa-users"></i> Roster <span class="aiv-count" id="aiv-roster-count"></span></button>
+ <button class="ch-tab" data-avtab="activity" onclick="switchAivTab('activity', this)"><i class="fa-solid fa-list-ul"></i> Activity</button>
<button class="ch-tab" data-avtab="budget" onclick="switchAivTab('budget', this)"><i class="fa-solid fa-gauge"></i> Key & Budget</button>
<button class="ch-tab" data-avtab="budget" onclick="switchAivTab('budget', this)"><i class="fa-solid fa-gauge"></i> Key & Budget</button>
</div>
</div>
- <!-- Settings -->
+ <!-- Settings (rendered from the server's settings schema) -->
<div class="aiv-panel active" id="aiv-panel-config">
<div class="aiv-panel active" id="aiv-panel-config">
- <div class="ai-bot-grid">
- <label class="staff-inline-toggle"><input type="checkbox" id="aiv-enabled"> <b>Enable AI chat viewers</b></label>
- <div class="ai-bot-row">
- <label>
- <span>Ambient viewers</span>
- <input type="number" id="aiv-num" class="form-input" min="0" max="12" value="3" style="max-width:110px;">
- <span class="ai-bot-hint">Always-on background chatters (0 = clones only).</span>
- </label>
- <label>
- <span>Speak every ~N seconds</span>
- <input type="number" id="aiv-pacing" class="form-input" min="10" max="600" value="45" style="max-width:120px;">
- <span class="ai-bot-hint">Across all bots (lower = chattier).</span>
- </label>
- </div>
- <label>
- <span>Channel personality (optional seed)</span>
- <textarea id="aiv-persona" class="form-input" rows="2" placeholder="e.g. Gremlin energy, backseat gamers, wholesome chaos. Keep it funny, never mean."></textarea>
- </label>
- <label class="staff-inline-toggle"><input type="checkbox" id="aiv-transcribe"> <span>Let them <b>hear</b> the stream (free local transcription) so they react to what you say <i class="fa-solid fa-microphone"></i></span></label>
- <label class="staff-inline-toggle"><input type="checkbox" id="aiv-vision"> <span>Let them <b>see</b> the stream (periodic frames) so they react to what's on screen <i class="fa-solid fa-eye" title="Uses vision tokens — counts against your budget."></i></span></label>
- <div class="ai-bot-actions">
- <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-wand-magic-sparkles"></i> Preview a line</button>
- <button class="btn btn-primary btn-small" onclick="saveAiViewers(this)"><i class="fa-solid fa-floppy-disk"></i> Save settings</button>
- </div>
- <div id="aiv-preview" class="ai-bot-preview" style="display:none;"></div>
+ <div id="aiv-settings-root"><p class="muted">Loading…</p></div>
+ </div>
+
+ <!-- Activity: live feed of what the director decided and why -->
+ <div class="aiv-panel" id="aiv-panel-activity">
+ <div class="aiv-senses" id="aiv-senses-activity"></div>
+ <div class="ai-bot-actions" style="margin:6px 0 8px">
+ <button class="btn btn-small btn-outline" onclick="aivCommand('pause', this)"><i class="fa-solid fa-pause"></i> Pause</button>
+ <button class="btn btn-small btn-outline" onclick="aivCommand('resume', this)"><i class="fa-solid fa-play"></i> Resume</button>
+ <button class="btn btn-small btn-outline" onclick="aivCommand('nudge', this)"><i class="fa-solid fa-hand-point-right"></i> Nudge the director</button>
+ <span class="muted" id="aiv-hour-stats" style="font-size:0.8rem"></span>
</div>
</div>
+ <div class="aiv-log" id="aiv-log"><p class="muted">Loading…</p></div>
</div>
</div>
<!-- Roster -->
<!-- Roster -->
@@ -1679,9 +1667,21 @@
@@ -1679,9 +1667,21 @@
<span class="ai-bot-hint" id="aiv-byo-key-status"></span>
<span class="ai-bot-hint" id="aiv-byo-key-status"></span>
</label>
</label>
<label>
<label>
- <span>Chat model</span>
+ <span>Provider shape</span>
+ <select id="aiv-byo-provider" class="form-input" style="max-width:220px"><option value="openai">OpenAI-compatible</option><option value="anthropic">Anthropic</option></select>
+ </label>
+ <label>
+ <span>Default model</span>
<input type="text" id="aiv-byo-model" class="form-input" placeholder="gpt-4o-mini" autocomplete="off" style="max-width:220px;">
<input type="text" id="aiv-byo-model" class="form-input" placeholder="gpt-4o-mini" autocomplete="off" style="max-width:220px;">
+ <span class="ai-bot-hint">Used for every role unless overridden below.</span>
</label>
</label>
+ <div class="ai-bot-row">
+ <label><span>Chat lines model</span><input type="text" id="aiv-byo-model-chat" class="form-input" placeholder="(default)" style="max-width:200px"></label>
+ <label><span>Director model</span><input type="text" id="aiv-byo-model-director" class="form-input" placeholder="(default)" style="max-width:200px"><span class="ai-bot-hint">Plans several lines per call; needs JSON output.</span></label>
+ <label><span>Vision model</span><input type="text" id="aiv-byo-model-vision" class="form-input" placeholder="(default)" style="max-width:200px"><span class="ai-bot-hint">Only used when the shared site vision is off.</span></label>
+ <label><span>Summary model</span><input type="text" id="aiv-byo-model-summary" class="form-input" placeholder="(default)" style="max-width:200px"></label>
+ </div>
+ <div class="ai-bot-actions"><button class="btn btn-small btn-outline" onclick="testAivByo(this)"><i class="fa-solid fa-plug-circle-check"></i> Test connection</button><span id="aiv-byo-test-out" class="muted" style="font-size:0.8rem"></span></div>
</div>
</div>
<div class="ai-bot-actions">
<div class="ai-bot-actions">
<button class="btn btn-primary btn-small" onclick="saveAiViewers(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
<button class="btn btn-primary btn-small" onclick="saveAiViewers(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
@@ -5070,7 +5070,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5070,7 +5070,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/settings.js?v=12" defer></script>
<script src="/js/settings.js?v=12" defer></script>
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
-<script src="/js/ai-viewers.js?v=1" defer></script>
+<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/powerchat.js?v=8" defer></script>
<script src="/js/powerchat.js?v=8" defer></script>
<script src="/js/pastes.js?v=14" defer></script>
<script src="/js/pastes.js?v=14" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
deleted file mode 100644
deleted file mode 100644
@@ -1,134 +0,0 @@
@@ -1,134 +0,0 @@
-/**
- * ai-chatbot-settings.js — Dashboard config panel for AI chat viewers.
- * Loads/saves via /api/ai-chatbot. The API token is write-only: the server
- * returns only a masked hint, and a blank token field preserves the stored one.
- */
-(function () {
- 'use strict';
-
- function $(id) { return document.getElementById(id); }
-
- // When a token is already saved, the field shows this masked sentinel (so it clearly
- // reads as "a key is set" instead of an empty box that looks unsaved). We only send a
- // new api_token when the user actually types one — the sentinel means "keep existing".
- const TOKEN_SENTINEL = '••••••••••••••••';
-
- // The literal text the user typed into the token field, or '' to mean "no change".
- function currentTokenInput() {
- const el = $('aibot-token');
- if (!el) return '';
- const v = el.value;
- if (v === TOKEN_SENTINEL) return ''; // unchanged → preserve stored token
- return String(v || '').trim();
- }
-
- function collect() {
- const body = {
- enabled: !!$('aibot-enabled')?.checked,
- base_url: $('aibot-base-url')?.value.trim() || '',
- model: $('aibot-model')?.value.trim() || '',
- num_bots: Number($('aibot-num')?.value || 3),
- post_interval_seconds: Number($('aibot-interval')?.value || 45),
- persona: $('aibot-persona')?.value || '',
- transcribe_enabled: !!$('aibot-transcribe')?.checked,
- transcribe_model: $('aibot-transcribe-model')?.value.trim() || '',
- vision_enabled: !!$('aibot-vision')?.checked,
- };
- const token = currentTokenInput();
- if (token) body.api_token = token; // only send when the user typed a NEW key
- return body;
- }
-
- window.loadAiChatbotConfig = async function loadAiChatbotConfig() {
- if (!$('dash-aibot-card')) return;
- try {
- const data = await api('/ai-chatbot');
- const c = data.config || {};
- if ($('aibot-enabled')) $('aibot-enabled').checked = !!c.enabled;
- if ($('aibot-base-url')) $('aibot-base-url').value = c.base_url || 'https://api.openai.com/v1';
- if ($('aibot-model')) $('aibot-model').value = c.model || 'gpt-4o-mini';
- if ($('aibot-num')) $('aibot-num').value = c.num_bots ?? 3;
- if ($('aibot-interval')) $('aibot-interval').value = c.post_interval_seconds ?? 45;
- if ($('aibot-persona')) $('aibot-persona').value = c.persona || '';
- if ($('aibot-transcribe')) $('aibot-transcribe').checked = !!c.transcribe_enabled;
- if ($('aibot-transcribe-model')) $('aibot-transcribe-model').value = c.transcribe_model || 'whisper-1';
- if ($('aibot-vision')) $('aibot-vision').checked = !!c.vision_enabled;
- const status = $('aibot-token-status');
- const selfHosted = !!(c.base_url && !/api\.openai\.com/i.test(c.base_url));
- if (status) {
- status.innerHTML = c.has_token
- ? `<span style="color:#53fc18">✓ Key saved</span> (${c.api_token_masked}). The dots below mean it's stored — leave them to keep it, or type a new key to replace.`
- : selfHosted
- ? 'No token needed for a self-hosted server (Ollama / LM Studio) — leave this blank.'
- : 'No key saved yet — paste your API key above.';
- }
- // Show a masked sentinel when a key exists so the field never looks "empty/unsaved".
- const tokenEl = $('aibot-token');
- if (tokenEl) {
- tokenEl.value = c.has_token ? TOKEN_SENTINEL : '';
- tokenEl.dataset.hasToken = c.has_token ? '1' : '';
- if (!tokenEl._sentinelWired) {
- tokenEl._sentinelWired = true;
- // Clear the placeholder-dots when the user goes to type a real key…
- tokenEl.addEventListener('focus', () => { if (tokenEl.value === TOKEN_SENTINEL) tokenEl.value = ''; });
- // …and restore them if they left it untouched (so it still reads as "saved").
- tokenEl.addEventListener('blur', () => { if (!tokenEl.value && tokenEl.dataset.hasToken === '1') tokenEl.value = TOKEN_SENTINEL; });
- }
- }
- } catch (err) {
- // Not logged in / no dashboard — silently ignore
- }
- };
-
- window.saveAiChatbot = async function saveAiChatbot(btn) {
- if (btn) btn.disabled = true;
- try {
- await api('/ai-chatbot', { method: 'PUT', body: collect() });
- toast('AI chat viewer settings saved', 'success');
- await window.loadAiChatbotConfig();
- } catch (err) {
- toast(err.message || 'Failed to save', 'error');
- } finally {
- if (btn) btn.disabled = false;
- }
- };
-
- window.testAiChatbot = async function testAiChatbot(btn) {
- if (btn) btn.disabled = true;
- try {
- const b = collect();
- const r = await api('/ai-chatbot/validate', { method: 'POST', body: { base_url: b.base_url, model: b.model, api_token: b.api_token } });
- if (r.ok) toast('AI connection OK ✓', 'success');
- else toast('Connection failed: ' + (r.error || 'unknown error'), 'error');
- } catch (err) {
- toast(err.message || 'Test failed', 'error');
- } finally {
- if (btn) btn.disabled = false;
- }
- };
-
- window.previewAiChatbot = async function previewAiChatbot(btn) {
- const box = $('aibot-preview');
- if (btn) btn.disabled = true;
- try {
- const b = collect();
- const r = await api('/ai-chatbot/preview', { method: 'POST', body: { base_url: b.base_url, model: b.model, api_token: b.api_token, persona: b.persona } });
- if (box) {
- box.style.display = 'block';
- box.textContent = r.sample ? `💬 “${r.sample}”` : '(empty response)';
- }
- } catch (err) {
- if (box) { box.style.display = 'block'; box.textContent = 'Preview failed: ' + (err.message || 'error'); }
- } finally {
- if (btn) btn.disabled = false;
- }
- };
-
- // Chain into the dashboard load so config populates when the dashboard opens.
- const _prevLoadDashboard = window.loadDashboard;
- window.loadDashboard = async function aiChatbotLoadDashboard() {
- if (typeof _prevLoadDashboard === 'function') await _prevLoadDashboard();
- try { await window.loadAiChatbotConfig(); } catch { /* ignore */ }
- };
- if (window.loadDashboard) { /* keep global alias consistent */ }
-})();
@@ -1,195 +1,345 @@
@@ -1,195 +1,345 @@
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
- AI Chat Viewers 2.0 — dashboard management (config, budget, roster, clone)
+ AI Chat Viewers v3 — control panel (dashboard card + Broadcast per-slot section)
API: /api/ai-viewers · Card: #dash-aibot-card
API: /api/ai-viewers · Card: #dash-aibot-card
+ Renders every knob from the server's settings schema, shows what the bots can
+ currently see/hear, a live activity feed with the director's reasons, roster
+ management, and the key/budget/model routing controls.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
(function () {
(function () {
'use strict';
'use strict';
let _rosterLoaded = false;
let _rosterLoaded = false;
+ let _cfg = null; // last /config payload
+ let _logAfter = 0;
+ let _logTimer = null;
+ let _statusTimer = null;
+ const _dirty = {}; // pending settings edits (key → value)
+ let _saveTimer = null;
+
+ const GROUPS = [
+ { id: 'activity', title: 'Activity', icon: 'fa-gauge-high', keys: ['activity', 'lines_per_min', 'director_interval_sec', 'idle_interval_sec', 'lines_per_tick', 'min_gap_sec'] },
+ { id: 'social', title: 'Social', icon: 'fa-comments', keys: ['bot_to_bot_ratio', 'reply_probability', 'mention_fast_path_sec', 'reply_to_relay_chat', 'greet_first_timers', 'react_to_sounds', 'react_to_scene_changes', 'max_open_threads', 'max_thread_turns', 'thread_idle_close_sec'] },
+ { id: 'senses', title: 'Senses', icon: 'fa-eye', keys: ['hear_enabled', 'hear_window_sec', 'vision_policy', 'vision_max_age_sec', 'vision_periodic_sec'] },
+ { id: 'style', title: 'Style', icon: 'fa-palette', keys: ['channel_personality', 'tone', 'language', 'emote_usage', 'max_words', 'topics', 'blocklist', 'channel_memory'] },
+ { id: 'behaviour', title: 'Behaviour', icon: 'fa-sliders', keys: ['quiet_from', 'quiet_to', 'quiet_tz', 'quiet_allow_replies', 'tts_enabled', 'powerchat_forward', 'memory_fold_min', 'remember_viewers'] },
+ ];
+ const LABELS = {
+ activity: 'Activity preset', lines_per_min: 'Lines per minute', director_interval_sec: 'Director pass (active) — seconds', idle_interval_sec: 'Director pass (idle) — seconds', lines_per_tick: 'Lines per pass', min_gap_sec: 'Min gap between lines (s)',
+ bot_to_bot_ratio: 'Bots talking to each other', reply_probability: 'Reply to viewers', mention_fast_path_sec: 'Streamer reply within (s)', reply_to_relay_chat: 'Reply to relayed chat (Twitch/Kick/YT/RS)', greet_first_timers: 'Greet first-time chatters', react_to_sounds: 'React to sounds', react_to_scene_changes: 'React to scene changes', max_open_threads: 'Open conversations', max_thread_turns: 'Max turns per conversation', thread_idle_close_sec: 'Close idle conversation after (s)',
+ hear_enabled: 'Hear the stream (live transcript)', hear_window_sec: 'Hearing window (s)', vision_policy: 'Look at the screen', vision_max_age_sec: 'Screen view is stale after (s)', vision_periodic_sec: 'Periodic look every (s)',
+ channel_personality: 'Channel personality', tone: 'Tone', language: 'Language', emote_usage: 'Emotes', max_words: 'Max words per line', topics: 'Favourite topics', blocklist: 'Never mention', channel_memory: 'Running bits (auto-maintained)',
+ quiet_from: 'Quiet hours from', quiet_to: 'Quiet hours to', quiet_tz: 'Time zone', quiet_allow_replies: 'Still answer the streamer in quiet hours', tts_enabled: 'TTS for bot lines', powerchat_forward: 'Show on PowerChat overlay', memory_fold_min: 'Consolidate memories every (min)', remember_viewers: 'Use what the site knows about regulars',
+ };
+ const ENUM_LABELS = {
+ activity: { quiet: 'Quiet (~1/min)', normal: 'Normal (~3/min)', lively: 'Lively (~5/min)', chaos: 'Chaos (~8/min)', custom: 'Custom (use sliders)' },
+ vision_policy: { off: 'Never', thumbnail: 'Cached thumbnail only', when_addressed: 'Fresh frame when you address chat', periodic: 'Periodically while active' },
+ emote_usage: { none: 'None', some: 'Some', lots: 'Lots' },
+ };
+
+ // ── helpers ────────────────────────────────────────────────
+ const $ = (id) => document.getElementById(id);
+ function ago(ms) { if (ms == null) return '—'; const s = Math.round(ms / 1000); return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.round(s / 60)}m ago` : `${Math.round(s / 3600)}h ago`; }
+ function money(n) { return '$' + (Number(n || 0)).toFixed(4); }
+ function tsAgo(iso) { if (!iso) return ''; const t = new Date(String(iso).replace(' ', 'T') + (String(iso).endsWith('Z') ? '' : 'Z')).getTime(); return ago(Date.now() - t); }
window.switchAivTab = function switchAivTab(tab, btn) {
window.switchAivTab = function switchAivTab(tab, btn) {
- document.querySelectorAll('#aiv-subtabs .ch-tab').forEach(b => {
- const on = b === btn || b.dataset.avtab === tab;
- b.classList.toggle('active', on);
- });
+ document.querySelectorAll('#aiv-subtabs .ch-tab').forEach(b => b.classList.toggle('active', b === btn || b.dataset.avtab === tab));
document.querySelectorAll('#dash-aibot-card .aiv-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('#dash-aibot-card .aiv-panel').forEach(p => p.classList.remove('active'));
- const panel = document.getElementById('aiv-panel-' + tab);
+ const panel = $('aiv-panel-' + tab);
if (panel) panel.classList.add('active');
if (panel) panel.classList.add('active');
if (tab === 'roster' && !_rosterLoaded) { _rosterLoaded = true; loadAivRoster(); }
if (tab === 'roster' && !_rosterLoaded) { _rosterLoaded = true; loadAivRoster(); }
+ if (tab === 'activity') startActivity(); else stopActivity();
};
};
function _aivToggleKeyMode() {
function _aivToggleKeyMode() {
- const mode = document.getElementById('aiv-keymode')?.value || 'shared';
- const byo = document.getElementById('aiv-byo-fields');
- const budgetWrap = document.getElementById('aiv-budget-wrap');
+ const mode = $('aiv-keymode')?.value || 'shared';
+ const byo = $('aiv-byo-fields'); const budgetWrap = $('aiv-budget-wrap');
if (byo) byo.style.display = mode === 'byo' ? '' : 'none';
if (byo) byo.style.display = mode === 'byo' ? '' : 'none';
if (budgetWrap) budgetWrap.style.display = mode === 'byo' ? 'none' : '';
if (budgetWrap) budgetWrap.style.display = mode === 'byo' ? 'none' : '';
}
}
window._aivToggleKeyMode = _aivToggleKeyMode;
window._aivToggleKeyMode = _aivToggleKeyMode;
- function _setUsageMeter(b) {
- const el = document.getElementById('aiv-usage-meter');
- if (!el) return;
+ // ── status / meter ─────────────────────────────────────────
+ function renderStatus(st, b) {
+ const pill = $('aiv-status-pill');
+ if (pill) {
+ let cls = 'off', txt = 'Off';
+ if (st && st.kill_switch) { cls = 'off'; txt = 'Disabled by admin'; }
+ else if (st && st.running) { cls = st.paused ? 'warn' : (st.mode === 'normal' ? 'ok' : 'warn'); txt = st.paused ? 'Paused' : `Running · ${st.bots} viewers · ${st.mode.replace('_', ' ')}`; }
+ else if (_cfg && _cfg.config && _cfg.config.enabled) { cls = 'warn'; txt = st && st.budget_active === false ? `Idle · ${String(st.budget_reason || st.mode || '').replace(/_/g, ' ')}` : 'Enabled · waiting for a live stream'; }
+ pill.className = `aiv-status ${cls}`; pill.textContent = txt;
+ }
+ const senses = $('aiv-senses');
+ if (senses) {
+ if (!st || !st.running) senses.innerHTML = `<span class="muted">${st && st.timeline_enabled === false ? 'Live transcript is off site-wide (admin: ai_timeline_enabled) — the bots will only see chat and the screen.' : 'Go live to see what the bots can hear and see.'}</span>`;
+ else senses.innerHTML = `
+ <span title="Live transcript"><i class="fa-solid fa-ear-listen"></i> heard ${st.senses.heard_lines ? `${st.senses.heard_lines} lines, latest ${ago(st.senses.heard_age_ms)}` : (st.timeline_enabled ? 'nothing recent' : 'off')}</span>
+ <span title="Latest screen analysis"><i class="fa-solid fa-eye"></i> seen ${st.senses.seen_age_ms != null ? ago(st.senses.seen_age_ms) : 'never'}${st.senses.seen_text ? ` — ${esc(st.senses.seen_text.slice(0, 90))}` : ''}</span>
+ <span title="Cached prompt prefix"><i class="fa-solid fa-bolt"></i> context ${st.senses.stable_prefix_chars ? `${Math.round(st.senses.stable_prefix_chars / 4)} tokens cached` : 'building'}</span>
+ <span><i class="fa-solid fa-clock"></i> next pass ${st.next_tick_in_ms != null ? `in ${Math.round(st.next_tick_in_ms / 1000)}s` : '—'} · ${st.lines_last_minute}/min · bot↔bot ${Math.round((st.bot_share || 0) * 100)}%</span>`;
+ }
+ if (b) _setUsageMeter(b, st);
+ }
+ function _setUsageMeter(b, st) {
+ const el = $('aiv-usage-meter'); if (!el) return;
+ const mode = st && st.mode ? st.mode : (b.active ? 'normal' : 'silent');
+ const ladder = { normal: 'normal', economy: 'economy (fewer lines)', replies_only: 'replies only', streamer_only: 'streamer replies only', silent: 'silent' }[mode] || mode;
if (b.use_shared_key) {
if (b.use_shared_key) {
- const cap = b.cap_usd || 0;
- const spent = b.spent_today_usd || 0;
+ const cap = b.cap_usd || 0, spent = b.spent_today_usd || 0;
const pct = cap > 0 ? Math.min(100, Math.round((spent / cap) * 100)) : 0;
const pct = cap > 0 ? Math.min(100, Math.round((spent / cap) * 100)) : 0;
- const over = cap > 0 && spent >= cap;
- el.innerHTML = `
- <div class="aiv-meter-head">
- <span><i class="fa-solid fa-gauge"></i> Today: <b>$${spent.toFixed(4)}</b>${cap > 0 ? ` / $${cap.toFixed(2)}` : ' (no cap)'}</span>
- <span class="aiv-status ${b.active ? 'ok' : 'off'}">${b.active ? 'Active' : (over ? 'Budget reached' : 'Inactive')}</span>
- </div>
- ${cap > 0 ? `<div class="aiv-meter-bar"><div class="aiv-meter-fill ${over ? 'over' : ''}" style="width:${pct}%"></div></div>` : ''}
- ${!b.active && b.reason === 'shared_ai_disabled' ? '<p class="muted" style="font-size:0.8rem;margin-top:6px">Shared AI is currently disabled by the site admin.</p>' : ''}`;
+ el.innerHTML = `<div class="aiv-meter-head"><span><i class="fa-solid fa-gauge"></i> Today: <b>${money(spent)}</b>${cap > 0 ? ` / $${cap.toFixed(2)}` : ' (no cap)'} · mode: <b>${ladder}</b></span><span class="aiv-status ${b.active ? 'ok' : 'off'}">${b.active ? 'Active' : (String(b.reason || 'inactive').replace(/_/g, ' '))}</span></div>
+ ${cap > 0 ? `<div class="aiv-meter-bar"><div class="aiv-meter-fill ${pct >= 100 ? 'over' : pct >= 80 ? 'warn' : ''}" style="width:${pct}%"></div></div>` : ''}
+ <p class="muted" style="font-size:0.75rem;margin-top:6px">As spend approaches the cap the viewers slow down (60%), then only reply (80%), then only answer you (95%), then go quiet.</p>`;
} else {
} else {
- el.innerHTML = `<div class="aiv-meter-head">
- <span><i class="fa-solid fa-key"></i> Using your own API key (no cap). Today: <b>$${(b.spent_today_usd || 0).toFixed(4)}</b> est.</span>
- <span class="aiv-status ${b.active ? 'ok' : 'off'}">${b.active ? 'Active' : 'Needs a key'}</span>
- </div>`;
+ el.innerHTML = `<div class="aiv-meter-head"><span><i class="fa-solid fa-key"></i> Your own key (no cap). Today: <b>${money(b.spent_today_usd)}</b></span><span class="aiv-status ${b.active ? 'ok' : 'off'}">${b.active ? 'Active' : 'Needs a key'}</span></div>`;
+ }
+ }
+
+ // ── settings form (schema-driven) ─────────────────────────
+ function fieldHtml(sp, val) {
+ const key = sp.key, label = LABELS[key] || key, help = sp.help || '';
+ const id = `aivs-${key}`;
+ if (sp.type === 'bool') return `<label class="staff-inline-toggle aiv-field" title="${esc(help)}"><input type="checkbox" id="${id}" data-skey="${key}" ${val ? 'checked' : ''} onchange="aivSet('${key}', this.checked)"> <span>${esc(label)}</span></label>`;
+ if (sp.type === 'enum') {
+ const opts = (sp.values || []).filter(v => v !== '' || key === 'engine').map(v => `<option value="${esc(v)}" ${String(val) === String(v) ? 'selected' : ''}>${esc((ENUM_LABELS[key] || {})[v] || v || '(default)')}</option>`).join('');
+ return `<label class="aiv-field" title="${esc(help)}"><span>${esc(label)}</span><select id="${id}" data-skey="${key}" class="form-input" onchange="aivSet('${key}', this.value)">${opts}</select></label>`;
+ }
+ if (sp.type === 'int' || sp.type === 'num') {
+ const step = sp.type === 'num' ? (sp.max <= 1 ? 0.05 : 0.5) : 1;
+ const pct = key === 'bot_to_bot_ratio' || key === 'reply_probability';
+ return `<label class="aiv-field aiv-slider" title="${esc(help)}"><span>${esc(label)} <b id="${id}-val">${pct ? Math.round(val * 100) + '%' : val}</b></span>
+ <input type="range" id="${id}" data-skey="${key}" min="${sp.min}" max="${sp.max}" step="${step}" value="${val}" oninput="document.getElementById('${id}-val').textContent=${pct ? "Math.round(this.value*100)+'%'" : 'this.value'}" onchange="aivSet('${key}', ${sp.type === 'num' ? 'parseFloat(this.value)' : 'parseInt(this.value,10)'})"></label>`;
}
}
+ if (key === 'channel_personality' || key === 'channel_memory' || key === 'topics' || key === 'blocklist') return `<label class="aiv-field" title="${esc(help)}"><span>${esc(label)}</span><textarea id="${id}" data-skey="${key}" class="form-input" rows="${key === 'channel_personality' ? 3 : 2}" onchange="aivSet('${key}', this.value)">${esc(val || '')}</textarea><span class="ai-bot-hint">${esc(help)}</span></label>`;
+ return `<label class="aiv-field" title="${esc(help)}"><span>${esc(label)}</span><input type="text" id="${id}" data-skey="${key}" class="form-input" value="${esc(val == null ? '' : val)}" placeholder="${key.startsWith('quiet_') && key !== 'quiet_tz' ? 'HH:MM' : ''}" onchange="aivSet('${key}', this.value)"></label>`;
+ }
+ function renderSettings() {
+ const root = $('aiv-settings-root'); if (!root || !_cfg) return;
+ const s = _cfg.settings || {}; const byKey = {}; (_cfg.schema || []).forEach(sp => { byKey[sp.key] = sp; });
+ root.innerHTML = `
+ <div class="aiv-topline">
+ <label class="staff-inline-toggle"><input type="checkbox" id="aiv-enabled" ${_cfg.config && _cfg.config.enabled ? 'checked' : ''} onchange="aivSetEnabled(this.checked)"> <b>Enable AI chat viewers</b></label>
+ <label class="aiv-field aiv-inline"><span>Viewers</span><input type="range" id="aivs-roster_size" min="0" max="${byKey.roster_size ? byKey.roster_size.max : 12}" step="1" value="${s.roster_size ?? 3}" oninput="document.getElementById('aivs-roster_size-val').textContent=this.value" onchange="aivSet('roster_size', parseInt(this.value,10))"><b id="aivs-roster_size-val">${s.roster_size ?? 3}</b></label>
+ <span id="aiv-status-pill" class="aiv-status off">…</span>
+ </div>
+ <div class="aiv-senses" id="aiv-senses"></div>
+ ${GROUPS.map((g, i) => `<details class="aiv-group" ${i === 0 ? 'open' : ''}><summary><i class="fa-solid ${g.icon}"></i> ${g.title}</summary><div class="aiv-group-body">${g.keys.filter(k => byKey[k]).map(k => fieldHtml(byKey[k], s[k])).join('')}</div></details>`).join('')}
+ <div class="ai-bot-actions">
+ <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-wand-magic-sparkles"></i> Preview what they'd say now</button>
+ <span class="muted" id="aiv-save-state" style="font-size:0.8rem"></span>
+ </div>
+ <div id="aiv-preview" class="ai-bot-preview" style="display:none;"></div>`;
+ renderStatus(_cfg.status, _cfg.budget);
+ }
+
+ // Debounced write-through save of individual settings.
+ window.aivSet = function aivSet(key, value) {
+ _dirty[key] = value;
+ const st = $('aiv-save-state'); if (st) st.textContent = 'Saving…';
+ clearTimeout(_saveTimer);
+ _saveTimer = setTimeout(flushSettings, 600);
+ };
+ async function flushSettings() {
+ const settings = { ..._dirty }; for (const k of Object.keys(_dirty)) delete _dirty[k];
+ if (!Object.keys(settings).length) return;
+ try {
+ const data = await api('/ai-viewers/config', { method: 'PUT', body: { settings } });
+ _cfg = { ..._cfg, ...data };
+ const st = $('aiv-save-state'); if (st) { st.textContent = 'Saved ✓'; setTimeout(() => { if (st.textContent === 'Saved ✓') st.textContent = ''; }, 2500); }
+ renderStatus(data.status, data.budget);
+ if (settings.activity && settings.activity !== 'custom' && data.settings) { const el = $('aivs-lines_per_min'); if (el) { el.value = data.settings.lines_per_min; const v = $('aivs-lines_per_min-val'); if (v) v.textContent = data.settings.lines_per_min; } }
+ } catch (e) { toast(e.message || 'Save failed', 'error'); }
+ }
+ window.aivSetEnabled = async function aivSetEnabled(on) {
+ try { const data = await api('/ai-viewers/config', { method: 'PUT', body: { enabled: on ? 1 : 0 } }); _cfg = { ..._cfg, ...data }; renderStatus(data.status, data.budget); toast(on ? 'AI viewers enabled — they join your next live stream (or now, if you are live).' : 'AI viewers disabled', 'success'); }
+ catch (e) { toast(e.message || 'Failed', 'error'); }
+ };
+
+ // ── activity feed ──────────────────────────────────────────
+ function logRow(r) {
+ const icon = { line: 'fa-comment', tick: 'fa-clapperboard', skip: 'fa-forward', mention: 'fa-bullhorn', fold: 'fa-brain', vision: 'fa-eye', degrade: 'fa-gauge', pause: 'fa-pause', error: 'fa-triangle-exclamation', info: 'fa-circle-info' }[r.event] || 'fa-circle';
+ const cost = r.cost_usd != null ? `<span class="muted" title="${r.tokens_in || 0} in (${r.tokens_cached || 0} cached) / ${r.tokens_out || 0} out · ${esc(r.model || '')}">${money(r.cost_usd)}${r.tokens_in ? ` · ${Math.round(((r.tokens_cached || 0) / r.tokens_in) * 100)}% cached` : ''}</span>` : '';
+ const who = r.bot_username ? `<b>${esc(r.bot_username)}</b>${r.target ? ` <span class="muted">→ ${esc(r.target)}</span>` : ''}` : `<span class="muted">${esc(r.event)}</span>`;
+ return `<div class="aiv-log-row aiv-log-${esc(r.event)}"><i class="fa-solid ${icon}"></i><div><div>${who} ${r.text ? `— ${esc(r.text)}` : ''}</div>${r.reason ? `<div class="muted aiv-log-reason">${esc(r.reason)}</div>` : ''}</div><div class="aiv-log-meta">${cost}<span class="muted">${tsAgo(r.created_at)}</span></div></div>`;
+ }
+ async function pollLog() {
+ const box = $('aiv-log'); if (!box) return;
+ try {
+ const data = await api(`/ai-viewers/log?after=${_logAfter}&limit=60`);
+ const rows = data.rows || [];
+ if (rows.length) {
+ if (_logAfter === 0) box.innerHTML = '';
+ for (const r of rows) { box.insertAdjacentHTML('afterbegin', logRow(r)); _logAfter = Math.max(_logAfter, r.id); }
+ while (box.children.length > 120) box.lastChild.remove();
+ } else if (_logAfter === 0 && !box.children.length) box.innerHTML = '<p class="muted">Nothing yet — the feed fills in while you are live.</p>';
+ } catch { /* */ }
}
}
+ async function pollStatus() {
+ try { const d = await api('/ai-viewers/status'); renderStatus(d.status, d.budget); const s = $('aiv-hour-stats'); if (s && d.last_hour) s.textContent = `Last hour: ${d.last_hour.lines || 0} lines · ${d.last_hour.ticks || 0} passes · ${d.last_hour.skips || 0} skipped · ${money(d.last_hour.cost_usd)}${d.last_hour.tokens_in ? ` · ${Math.round(((d.last_hour.tokens_cached || 0) / d.last_hour.tokens_in) * 100)}% cached` : ''}`; } catch { /* */ }
+ }
+ function startActivity() { stopActivity(); pollLog(); pollStatus(); _logTimer = setInterval(pollLog, 5000); _statusTimer = setInterval(pollStatus, 10000); }
+ function stopActivity() { clearInterval(_logTimer); clearInterval(_statusTimer); _logTimer = _statusTimer = null; }
+ window.aivCommand = async function aivCommand(cmd, btn) {
+ if (btn) btn.disabled = true;
+ try { const d = await api(`/ai-viewers/${cmd}`, { method: 'POST', body: {} }); toast(d.message || 'ok', 'success'); renderStatus(d.status, null); pollLog(); }
+ catch (e) { toast(e.message || 'Failed', 'error'); } finally { if (btn) btn.disabled = false; }
+ };
+ // ── load / preview ─────────────────────────────────────────
window.loadAiViewers = async function loadAiViewers() {
window.loadAiViewers = async function loadAiViewers() {
- const card = document.getElementById('dash-aibot-card');
- if (!card) return;
+ const card = $('dash-aibot-card'); if (!card) return;
try {
try {
- const data = await api('/ai-viewers/config');
- const c = data.config || {};
- const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
- const chk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = !!v; };
- chk('aiv-enabled', c.enabled);
- set('aiv-num', c.num_ambient_bots ?? 3);
- set('aiv-pacing', c.pacing_seconds ?? 45);
- set('aiv-persona', c.persona || '');
- chk('aiv-transcribe', c.transcribe_enabled);
- chk('aiv-vision', c.vision_enabled);
+ _cfg = await api('/ai-viewers/config');
+ const c = _cfg.config || {}, s = _cfg.settings || {};
+ renderSettings();
+ const set = (id, v) => { const el = $(id); if (el) el.value = v; };
set('aiv-keymode', c.use_shared_key ? 'shared' : 'byo');
set('aiv-keymode', c.use_shared_key ? 'shared' : 'byo');
set('aiv-budget', ((c.daily_budget_cents ?? 20) / 100).toFixed(2));
set('aiv-budget', ((c.daily_budget_cents ?? 20) / 100).toFixed(2));
- set('aiv-byo-url', c.byo_base_url || '');
- set('aiv-byo-model', c.byo_model || 'gpt-4o-mini');
- const keyStatus = document.getElementById('aiv-byo-key-status');
- if (keyStatus) keyStatus.textContent = c.has_byo_key ? 'A key is saved. Leave blank to keep it.' : '';
- const keyEl = document.getElementById('aiv-byo-key'); if (keyEl) keyEl.value = '';
+ set('aiv-byo-url', c.byo_base_url || ''); set('aiv-byo-model', c.byo_model || 'gpt-4o-mini');
+ for (const role of ['chat', 'vision', 'director', 'summary']) set(`aiv-byo-model-${role}`, (s.byo && s.byo[`model_${role}`]) || '');
+ set('aiv-byo-provider', (s.byo && s.byo.provider) || 'openai');
+ const keyStatus = $('aiv-byo-key-status'); if (keyStatus) keyStatus.textContent = c.has_byo_key ? 'A key is saved. Leave blank to keep it.' : '';
+ const keyEl = $('aiv-byo-key'); if (keyEl) keyEl.value = '';
_aivToggleKeyMode();
_aivToggleKeyMode();
- if (data.budget) _setUsageMeter(data.budget);
- // Refresh roster if its tab is already open.
if (_rosterLoaded) loadAivRoster();
if (_rosterLoaded) loadAivRoster();
+ if ($('aiv-panel-activity')?.classList.contains('active')) startActivity();
} catch (e) { /* card may not be visible */ }
} catch (e) { /* card may not be visible */ }
};
};
window.saveAiViewers = async function saveAiViewers(btn) {
window.saveAiViewers = async function saveAiViewers(btn) {
- const val = (id) => document.getElementById(id);
+ const val = (id) => $(id);
const mode = val('aiv-keymode')?.value || 'shared';
const mode = val('aiv-keymode')?.value || 'shared';
const body = {
const body = {
- enabled: val('aiv-enabled')?.checked ? 1 : 0,
- num_ambient_bots: parseInt(val('aiv-num')?.value) || 0,
- pacing_seconds: parseInt(val('aiv-pacing')?.value) || 45,
- persona: val('aiv-persona')?.value || '',
- transcribe_enabled: val('aiv-transcribe')?.checked ? 1 : 0,
- vision_enabled: val('aiv-vision')?.checked ? 1 : 0,
use_shared_key: mode === 'shared' ? 1 : 0,
use_shared_key: mode === 'shared' ? 1 : 0,
daily_budget_cents: Math.round((parseFloat(val('aiv-budget')?.value) || 0) * 100),
daily_budget_cents: Math.round((parseFloat(val('aiv-budget')?.value) || 0) * 100),
byo_base_url: val('aiv-byo-url')?.value || '',
byo_base_url: val('aiv-byo-url')?.value || '',
byo_model: val('aiv-byo-model')?.value || 'gpt-4o-mini',
byo_model: val('aiv-byo-model')?.value || 'gpt-4o-mini',
+ settings: { byo: { provider: val('aiv-byo-provider')?.value || 'openai' } },
};
};
- const keyVal = val('aiv-byo-key')?.value || '';
- if (keyVal) body.byo_key = keyVal;
- if (btn) { btn.disabled = true; }
+ for (const role of ['chat', 'vision', 'director', 'summary']) body.settings.byo[`model_${role}`] = val(`aiv-byo-model-${role}`)?.value || '';
+ const keyVal = val('aiv-byo-key')?.value || ''; if (keyVal) body.byo_key = keyVal;
+ if (btn) btn.disabled = true;
try {
try {
const data = await api('/ai-viewers/config', { method: 'PUT', body });
const data = await api('/ai-viewers/config', { method: 'PUT', body });
- toast('AI viewers saved', 'success');
- if (data.budget) _setUsageMeter(data.budget);
+ _cfg = { ..._cfg, ...data };
+ toast('Saved', 'success'); renderStatus(data.status, data.budget);
const keyEl = val('aiv-byo-key'); if (keyEl) keyEl.value = '';
const keyEl = val('aiv-byo-key'); if (keyEl) keyEl.value = '';
- const keyStatus = document.getElementById('aiv-byo-key-status');
- if (keyStatus && data.config) keyStatus.textContent = data.config.has_byo_key ? 'A key is saved. Leave blank to keep it.' : '';
+ const keyStatus = $('aiv-byo-key-status'); if (keyStatus && data.config) keyStatus.textContent = data.config.has_byo_key ? 'A key is saved. Leave blank to keep it.' : '';
} catch (e) { toast(e.message || 'Save failed', 'error'); }
} catch (e) { toast(e.message || 'Save failed', 'error'); }
finally { if (btn) btn.disabled = false; }
finally { if (btn) btn.disabled = false; }
};
};
+ window.testAivByo = async function testAivByo(btn) {
+ const out = $('aiv-byo-test-out'); if (btn) btn.disabled = true; if (out) out.textContent = 'Testing…';
+ try {
+ const d = await api('/ai-viewers/byo/test', { method: 'POST', body: { byo_key: $('aiv-byo-key')?.value || '', byo_base_url: $('aiv-byo-url')?.value || '', byo_model: $('aiv-byo-model')?.value || '' } });
+ if (out) out.innerHTML = d.ok ? `<span style="color:#4ade80">● OK — ${esc(d.model || '')} answered in ${d.latencyMs} ms</span>` : `<span style="color:#f87171">● ${esc(d.error || 'failed')}</span>`;
+ } catch (e) { if (out) out.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; } finally { if (btn) btn.disabled = false; }
+ };
window.previewAiViewers = async function previewAiViewers(btn) {
window.previewAiViewers = async function previewAiViewers(btn) {
- const box = document.getElementById('aiv-preview');
- if (btn) btn.disabled = true;
+ const box = $('aiv-preview'); if (btn) btn.disabled = true;
+ if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Asking the director… (this is a dry run — nothing is posted)</span>'; }
try {
try {
- const data = await api('/ai-viewers/preview', { method: 'POST', body: {} });
- if (box) { box.style.display = ''; box.innerHTML = `<i class="fa-solid fa-comment"></i> ${esc(data.line || '')}`; }
- } catch (e) {
- if (box) { box.style.display = ''; box.innerHTML = `<span class="muted">${esc(e.message || 'Preview failed')}</span>`; }
- } finally { if (btn) btn.disabled = false; }
+ const d = await api('/ai-viewers/preview', { method: 'POST', body: {} });
+ if (!box) return;
+ if (d.line) { box.innerHTML = `<i class="fa-solid fa-comment"></i> ${esc(d.line)}`; return; }
+ const p = d.plan;
+ box.innerHTML = `
+ <div class="muted" style="font-size:0.78rem">Context: ${d.context ? `${Math.round(d.context.stable_chars / 4)} tokens stable (cached) + ${Math.round(d.context.volatile_chars / 4)} tokens fresh · heard ${d.context.sources.heardAgeMs != null ? ago(d.context.sources.heardAgeMs) : 'nothing'} · seen ${d.context.sources.seenAgeMs != null ? ago(d.context.sources.seenAgeMs) : 'never'} · ${d.context.sources.chatLines} chat lines · ${d.context.sources.people} people known` : ''}${d.cost != null ? ` · this preview cost ${money(d.cost)}${d.usage && d.usage.cached ? ` (${Math.round((d.usage.cached / d.usage.input) * 100)}% cached)` : ''}` : ''}</div>
+ ${p && p.lines && p.lines.length ? p.lines.map(l => `<div class="aiv-preview-line"><b>${esc(l.bot)}</b> <span class="muted">→ ${esc(l.target)}${l.reply_to ? ' @' + esc(l.reply_to) : ''} · +${Math.round((l.delay_ms || 0) / 1000)}s</span><br>${esc(l.text)}<br><span class="muted aiv-log-reason">${esc(l.reason || '')}</span></div>`).join('') : `<p class="muted">The director would stay quiet right now${p && p.notes ? ` — ${esc(p.notes)}` : ''}.</p>`}
+ <details style="margin-top:6px"><summary class="muted" style="cursor:pointer;font-size:0.78rem">What the bots were shown</summary><pre class="aiv-pre">${esc(d.stable_preview || '')}\n\n---\n\n${esc(d.volatile_preview || '')}</pre></details>`;
+ } catch (e) { if (box) box.innerHTML = `<span class="muted">${esc(e.message || 'Preview failed')}</span>`; }
+ finally { if (btn) btn.disabled = false; }
};
};
- // ── Roster ────────────────────────────────────────────────
+ // ── roster ────────────────────────────────────────────────
window.loadAivRoster = async function loadAivRoster() {
window.loadAivRoster = async function loadAivRoster() {
- const list = document.getElementById('aiv-roster-list');
- if (!list) return;
+ const list = $('aiv-roster-list'); if (!list) return;
try {
try {
const data = await api('/ai-viewers/roster');
const data = await api('/ai-viewers/roster');
const bots = data.bots || [];
const bots = data.bots || [];
- const count = document.getElementById('aiv-roster-count');
- if (count) count.textContent = bots.length ? `(${bots.length})` : '';
- if (!bots.length) { list.innerHTML = '<p class="muted">No bots yet. Enable ambient viewers, or right-click a chatter and “AI Clone” them.</p>'; return; }
- list.innerHTML = bots.map(renderBotRow).join('');
+ const count = $('aiv-roster-count'); if (count) count.textContent = bots.length ? `(${bots.length})` : '';
+ if (!bots.length) { list.innerHTML = '<p class="muted">No viewers yet. Set the roster size above, or right-click a chatter and “AI Clone” them.</p>'; return; }
+ const muted = new Set((((_cfg || {}).settings || {}).runtime || {}).muted || []);
+ list.innerHTML = bots.map(b => renderBotRow(b, muted.has(String(b.username || b.display_name).toLowerCase()))).join('');
} catch (e) { list.innerHTML = '<p class="muted">Failed to load roster</p>'; }
} catch (e) { list.innerHTML = '<p class="muted">Failed to load roster</p>'; }
};
};
-
- function renderBotRow(b) {
- const src = b.source === 'clone'
- ? `<span class="aiv-badge clone" title="Cloned from ${esc(b.cloned_from || '')}">clone</span>`
- : `<span class="aiv-badge">ambient</span>`;
+ function renderBotRow(b, isMuted) {
+ const src = b.source === 'clone' ? `<span class="aiv-badge clone" title="Cloned from ${esc(b.cloned_from || '')}">clone</span>` : `<span class="aiv-badge">ambient</span>`;
const mem = b.memory ? `<div class="aiv-bot-memory" title="Rolling memory">🧠 ${esc(b.memory)}</div>` : '';
const mem = b.memory ? `<div class="aiv-bot-memory" title="Rolling memory">🧠 ${esc(b.memory)}</div>` : '';
const idn = b.identity ? `<div class="aiv-bot-identity">${esc(b.identity)}</div>` : (b.blurb ? `<div class="aiv-bot-identity muted">${esc(b.blurb)}</div>` : '');
const idn = b.identity ? `<div class="aiv-bot-identity">${esc(b.identity)}</div>` : (b.blurb ? `<div class="aiv-bot-identity muted">${esc(b.blurb)}</div>` : '');
- return `
- <div class="aiv-bot ${b.is_active ? '' : 'inactive'}" data-id="${b.id}">
+ return `<div class="aiv-bot ${b.is_active ? '' : 'inactive'} ${isMuted ? 'muted-bot' : ''}" data-id="${b.id}">
<div class="aiv-bot-head">
<div class="aiv-bot-head">
- <span class="aiv-dot" style="background:${esc(b.color)}"></span>
- <b>${esc(b.display_name)}</b> ${src}
+ <span class="aiv-dot" style="background:${esc(b.color)}"></span><b>${esc(b.display_name)}</b> ${src}${isMuted ? '<span class="aiv-badge" title="Muted by a mod">muted</span>' : ''}
<span class="muted aiv-bot-meta">${b.msg_count} msgs</span>
<span class="muted aiv-bot-meta">${b.msg_count} msgs</span>
<span class="aiv-bot-actions">
<span class="aiv-bot-actions">
<button class="btn btn-tiny btn-outline" title="Edit identity" onclick="aivEditBot(${b.id})"><i class="fa-solid fa-pen"></i></button>
<button class="btn btn-tiny btn-outline" title="Edit identity" onclick="aivEditBot(${b.id})"><i class="fa-solid fa-pen"></i></button>
+ <button class="btn btn-tiny btn-outline" title="${isMuted ? 'Unmute' : 'Mute (mods can also /ai mute name)'}" onclick="aivMuteBot(${b.id}, ${isMuted ? 0 : 1})"><i class="fa-solid ${isMuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i></button>
<button class="btn btn-tiny btn-outline" title="Clear memory" onclick="aivClearBot(${b.id})"><i class="fa-solid fa-eraser"></i></button>
<button class="btn btn-tiny btn-outline" title="Clear memory" onclick="aivClearBot(${b.id})"><i class="fa-solid fa-eraser"></i></button>
<button class="btn btn-tiny btn-outline" title="${b.is_active ? 'Deactivate' : 'Activate'}" onclick="aivToggleBot(${b.id}, ${b.is_active ? 0 : 1})"><i class="fa-solid ${b.is_active ? 'fa-pause' : 'fa-play'}"></i></button>
<button class="btn btn-tiny btn-outline" title="${b.is_active ? 'Deactivate' : 'Activate'}" onclick="aivToggleBot(${b.id}, ${b.is_active ? 0 : 1})"><i class="fa-solid ${b.is_active ? 'fa-pause' : 'fa-play'}"></i></button>
<button class="btn btn-tiny btn-danger" title="Delete" onclick="aivDeleteBot(${b.id})"><i class="fa-solid fa-trash"></i></button>
<button class="btn btn-tiny btn-danger" title="Delete" onclick="aivDeleteBot(${b.id})"><i class="fa-solid fa-trash"></i></button>
</span>
</span>
- </div>
- ${idn}
- ${mem}
- </div>`;
+ </div>${idn}${mem}</div>`;
}
}
-
window.aivEditBot = async function aivEditBot(id) {
window.aivEditBot = async function aivEditBot(id) {
const row = document.querySelector(`.aiv-bot[data-id="${id}"] .aiv-bot-identity`);
const row = document.querySelector(`.aiv-bot[data-id="${id}"] .aiv-bot-identity`);
- const cur = row ? row.textContent : '';
- const next = prompt('Edit this bot’s identity / character brief:', cur || '');
+ const next = prompt('Edit this viewer’s identity / character brief:', row ? row.textContent : '');
if (next === null) return;
if (next === null) return;
- try { await api(`/ai-viewers/bots/${id}`, { method: 'PATCH', body: { identity: next } }); toast('Updated', 'success'); loadAivRoster(); }
- catch (e) { toast(e.message || 'Failed', 'error'); }
+ try { await api(`/ai-viewers/bots/${id}`, { method: 'PATCH', body: { identity: next } }); toast('Updated', 'success'); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }
+ };
+ window.aivMuteBot = async function aivMuteBot(id, mute) {
+ try { const d = await api(`/ai-viewers/bots/${id}/${mute ? 'mute' : 'unmute'}`, { method: 'POST', body: {} }); toast(d.message || 'ok', 'success'); _cfg = await api('/ai-viewers/config'); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }
};
};
-
window.aivClearBot = async function aivClearBot(id) {
window.aivClearBot = async function aivClearBot(id) {
- if (!confirm('Clear this bot’s rolling memory? Its personality stays.')) return;
- try { await api(`/ai-viewers/bots/${id}/clear-memory`, { method: 'POST', body: {} }); toast('Memory cleared', 'success'); loadAivRoster(); }
- catch (e) { toast(e.message || 'Failed', 'error'); }
+ if (!confirm('Clear this viewer’s rolling memory? Its personality stays.')) return;
+ try { await api(`/ai-viewers/bots/${id}/clear-memory`, { method: 'POST', body: {} }); toast('Memory cleared', 'success'); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }
};
};
-
window.aivToggleBot = async function aivToggleBot(id, active) {
window.aivToggleBot = async function aivToggleBot(id, active) {
- try { await api(`/ai-viewers/bots/${id}`, { method: 'PATCH', body: { is_active: active } }); loadAivRoster(); }
- catch (e) { toast(e.message || 'Failed', 'error'); }
+ try { await api(`/ai-viewers/bots/${id}`, { method: 'PATCH', body: { is_active: active } }); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }
};
};
-
window.aivDeleteBot = async function aivDeleteBot(id) {
window.aivDeleteBot = async function aivDeleteBot(id) {
- if (!confirm('Delete this bot for good?')) return;
- try { await api(`/ai-viewers/bots/${id}`, { method: 'DELETE' }); toast('Deleted', 'success'); loadAivRoster(); }
- catch (e) { toast(e.message || 'Failed', 'error'); }
+ if (!confirm('Delete this viewer for good?')) return;
+ try { await api(`/ai-viewers/bots/${id}`, { method: 'DELETE' }); toast('Deleted', 'success'); loadAivRoster(); } catch (e) { toast(e.message || 'Failed', 'error'); }
};
};
-
- // ── Clone (called from the chat context menu) ─────────────
window.aivCloneChatter = async function aivCloneChatter(kind, ref, displayName) {
window.aivCloneChatter = async function aivCloneChatter(kind, ref, displayName) {
toast(`Cloning ${displayName || 'chatter'}…`, 'info');
toast(`Cloning ${displayName || 'chatter'}…`, 'info');
+ try { const data = await api('/ai-viewers/clone', { method: 'POST', body: { kind, ref } }); toast(`AI clone of ${data.bot?.display_name || displayName} created`, 'success'); _rosterLoaded = true; loadAivRoster(); }
+ catch (e) { toast(e.message || 'Clone failed', 'error'); }
+ };
+
+ // ── Broadcast workspace: compact per-slot section ──────────
+ window.aivRenderSlotSection = async function aivRenderSlotSection(container, managedStreamId) {
+ if (!container) return;
try {
try {
- const data = await api('/ai-viewers/clone', { method: 'POST', body: { kind, ref } });
- toast(`AI clone of ${data.bot?.display_name || displayName} created`, 'success');
- _rosterLoaded = true;
- loadAivRoster();
- } catch (e) { toast(e.message || 'Clone failed', 'error'); }
+ const d = await api('/ai-viewers/config');
+ _cfg = _cfg || d;
+ const s = d.settings || {}, st = d.status || {}, c = d.config || {};
+ const slotOn = !(s.slots && s.slots[String(managedStreamId)] === false);
+ const pill = st.kill_switch ? ['off', 'Disabled by admin'] : st.running ? [st.paused ? 'warn' : 'ok', st.paused ? 'Paused' : `Running · ${st.bots} viewers · ${String(st.mode).replace('_', ' ')}`] : c.enabled ? ['warn', 'Enabled · joins when live'] : ['off', 'Off'];
+ container.innerHTML = `
+ <div class="aiv-slot-head"><span class="aiv-status ${pill[0]}">${esc(pill[1])}</span>
+ <label class="staff-inline-toggle"><input type="checkbox" ${c.enabled ? 'checked' : ''} onchange="aivSetEnabled(this.checked)"> <b>AI viewers on for my channel</b></label>
+ <label class="staff-inline-toggle"><input type="checkbox" ${slotOn ? 'checked' : ''} onchange="aivSetSlot('${managedStreamId}', this.checked)"> <span>…and on this slot</span></label>
+ </div>
+ <div class="aiv-slot-grid">
+ <label><span>Activity</span><select class="form-input form-input-sm" onchange="aivSet('activity', this.value)">${['quiet', 'normal', 'lively', 'chaos', 'custom'].map(v => `<option value="${v}" ${s.activity === v ? 'selected' : ''}>${ENUM_LABELS.activity[v]}</option>`).join('')}</select></label>
+ <label><span>Viewers <b id="aivslot-roster-val">${s.roster_size ?? 3}</b></span><input type="range" min="0" max="12" step="1" value="${s.roster_size ?? 3}" oninput="document.getElementById('aivslot-roster-val').textContent=this.value" onchange="aivSet('roster_size', parseInt(this.value,10))"></label>
+ <label><span>Bots talking to each other <b id="aivslot-ratio-val">${Math.round((s.bot_to_bot_ratio || 0) * 100)}%</b></span><input type="range" min="0" max="0.8" step="0.05" value="${s.bot_to_bot_ratio ?? 0.3}" oninput="document.getElementById('aivslot-ratio-val').textContent=Math.round(this.value*100)+'%'" onchange="aivSet('bot_to_bot_ratio', parseFloat(this.value))"></label>
+ <label><span>Look at the screen</span><select class="form-input form-input-sm" onchange="aivSet('vision_policy', this.value)">${['off', 'thumbnail', 'when_addressed', 'periodic'].map(v => `<option value="${v}" ${s.vision_policy === v ? 'selected' : ''}>${ENUM_LABELS.vision_policy[v]}</option>`).join('')}</select></label>
+ </div>
+ <div class="bc-ws-toggle-grid" style="margin-top:8px">
+ <label class="bc-toggle-label bc-ws-toggle-chip"><input type="checkbox" ${s.hear_enabled ? 'checked' : ''} onchange="aivSet('hear_enabled', this.checked)"><i class="fa-solid fa-ear-listen"></i><span>Hear the stream</span></label>
+ <label class="bc-toggle-label bc-ws-toggle-chip"><input type="checkbox" ${s.tts_enabled ? 'checked' : ''} onchange="aivSet('tts_enabled', this.checked)"><i class="fa-solid fa-volume-high"></i><span>TTS</span></label>
+ <label class="bc-toggle-label bc-ws-toggle-chip"><input type="checkbox" ${s.powerchat_forward ? 'checked' : ''} onchange="aivSet('powerchat_forward', this.checked)"><i class="fa-solid fa-bolt"></i><span>PowerChat</span></label>
+ <label class="bc-toggle-label bc-ws-toggle-chip"><input type="checkbox" ${s.reply_to_relay_chat ? 'checked' : ''} onchange="aivSet('reply_to_relay_chat', this.checked)"><i class="fa-solid fa-tower-broadcast"></i><span>Reply to relayed chat</span></label>
+ </div>
+ <div class="ai-bot-actions" style="margin-top:8px">
+ <button class="btn btn-small btn-outline" onclick="aivCommand('${st.paused ? 'resume' : 'pause'}', this)"><i class="fa-solid ${st.paused ? 'fa-play' : 'fa-pause'}"></i> ${st.paused ? 'Resume' : 'Pause'}</button>
+ <button class="btn btn-small btn-outline" onclick="aivCommand('nudge', this)" ${st.running ? '' : 'disabled'}><i class="fa-solid fa-hand-point-right"></i> Nudge</button>
+ <a class="btn btn-small btn-outline" href="/dashboard#chatai" onclick="if(typeof navigate==='function'){event.preventDefault();navigate('/dashboard');setTimeout(()=>{const b=document.querySelector('#dash-tabs .ch-tab[data-dtab=chatai]'); if(b) b.click();},300);}"><i class="fa-solid fa-sliders"></i> All settings, roster & activity feed</a>
+ </div>
+ <div class="muted" style="font-size:0.78rem;margin-top:6px">Mods can type <code>/ai pause</code>, <code>/ai resume</code>, <code>/ai mute name</code> in chat. The bots always answer you when you address chat or say a viewer's name.</div>`;
+ } catch (e) { container.innerHTML = `<span class="muted">AI viewers unavailable: ${esc(e.message)}</span>`; }
};
};
+ window.aivSetSlot = function aivSetSlot(msId, on) { aivSet('slots', { [String(msId)]: !!on }); };
})();
})();
@@ -1010,6 +1010,12 @@ function _wsRenderPanel() {
@@ -1010,6 +1010,12 @@ function _wsRenderPanel() {
</div>
</div>
</details>
</details>
+ <!-- AI Chat Viewers (v3) — compact per-slot controls; full panel lives on the dashboard -->
+ <details class="bc-ws-slot-settings" id="bc-ws-aiv-details">
+ <summary><i class="fa-solid fa-robot"></i> AI Chat Viewers</summary>
+ <div class="bc-ws-slot-settings-inner"><div id="bc-ws-aiv" data-ms="${ms.id}"><span class="muted">Loading…</span></div></div>
+ </details>
+
<!-- VOD / Clips Settings -->
<!-- VOD / Clips Settings -->
<details class="bc-ws-slot-settings">
<details class="bc-ws-slot-settings">
<summary><i class="fa-solid fa-film"></i> VOD & Clips Settings</summary>
<summary><i class="fa-solid fa-film"></i> VOD & Clips Settings</summary>
@@ -1158,6 +1164,9 @@ function _wsRenderPanel() {
@@ -1158,6 +1164,9 @@ function _wsRenderPanel() {
// Load restream destinations for this slot
// Load restream destinations for this slot
_wsLoadRestreamDests(ms.id);
_wsLoadRestreamDests(ms.id);
+
+ // AI viewers per-slot section (async; the panel HTML above only holds the placeholder).
+ try { const aiv = document.getElementById('bc-ws-aiv'); if (aiv && window.aivRenderSlotSection) window.aivRenderSlotSection(aiv, aiv.dataset.ms); } catch { /* */ }
}
}
/* ── Tab switching ───────────────────────────────────────────── */
/* ── Tab switching ───────────────────────────────────────────── */
@@ -459,6 +459,45 @@ router.get('/ai/usage', (req, res) => {
@@ -459,6 +459,45 @@ router.get('/ai/usage', (req, res) => {
}
}
});
});
+// AI viewers fleet (admin): running workers + per-channel spend/status, kill controls.
+router.get('/ai/viewers/status', (req, res) => {
+ try {
+ const viewers = require('../ai/viewers');
+ const v3 = viewers.v3(); const v2 = viewers.v2();
+ const running = [];
+ for (const w of v3.workers.values()) {
+ const u = db.getUserById(w.userId);
+ running.push({ engine: 'v3', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length, mode: w.mode, paused: w.paused, stats: w.stats, started_at: w.startedAt });
+ }
+ for (const w of v2.workers.values()) {
+ const u = db.getUserById(w.userId);
+ running.push({ engine: 'v2', user_id: w.userId, username: u && u.username, stream_id: w.streamId, bots: w.bots.length });
+ }
+ const channels = db.all(`SELECT c.user_id, u.username, c.enabled, c.use_shared_key, c.daily_budget_cents, c.settings_json,
+ (SELECT COUNT(*) FROM channel_ai_bots b WHERE b.channel_user_id = c.user_id AND b.is_active = 1) AS bots
+ FROM channel_ai_config c JOIN users u ON u.id = c.user_id ORDER BY c.enabled DESC, u.username`).map(r => {
+ let sj = {}; try { sj = JSON.parse(r.settings_json || '{}') || {}; } catch { /* */ }
+ let spent = 0; try { spent = db.getAiCostTodayForUser(r.user_id, 'ai_viewers'); } catch { /* */ }
+ return { user_id: r.user_id, username: r.username, enabled: !!r.enabled, shared_key: !!r.use_shared_key, cap_usd: (r.daily_budget_cents || 0) / 100, spent_today_usd: spent, bots: r.bots, engine: sj.engine || null, activity: sj.activity || null };
+ });
+ res.json({ engine_default: viewers.version, kill_switch: db.getSetting('ai_viewers_enabled') === false, global_spend_today_usd: require('../ai/viewers/budget').globalViewerSpendToday(), running, channels });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+router.post('/ai/viewers/:userId/:action', (req, res) => {
+ try {
+ const viewers = require('../ai/viewers');
+ const userId = parseInt(req.params.userId, 10);
+ const action = String(req.params.action || '');
+ if (action === 'stop') { viewers.stopForUser(userId); db.upsertChannelAiConfig(userId, { enabled: 0 }); return res.json({ ok: true, message: 'Stopped and disabled' }); }
+ if (['pause', 'resume', 'nudge'].includes(action)) return res.json({ ok: true, message: viewers.onModCommand(userId, null, [action], { by: `admin:${req.user.username}` }) });
+ res.status(400).json({ error: 'Unknown action' });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+router.get('/ai/viewers/:userId/log', (req, res) => {
+ try { res.json({ rows: db.getAiViewerLog(parseInt(req.params.userId, 10), { afterId: parseInt(req.query.after, 10) || 0, limit: parseInt(req.query.limit, 10) || 80 }) }); }
+ catch (err) { res.status(500).json({ error: err.message }); }
+});
+
// ── AI Explorer + status tools (admin AI tab) ────────────────
// ── AI Explorer + status tools (admin AI tab) ────────────────
const aiAnalysis = require('../ai/ai-analysis');
const aiAnalysis = require('../ai/ai-analysis');
new file mode 100644
new file mode 100644
@@ -0,0 +1,286 @@
@@ -0,0 +1,286 @@
+/**
+ * Unified Context Service — everything the site already knows, assembled for an LLM.
+ *
+ * Nothing here runs a new analysis. It reads the stores other jobs keep warm:
+ * stream_timeline_events (live transcript + sounds) stream_memories (frames)
+ * streams.ai_overview / ai_title streamer_overviews
+ * app_state ai_whole_overview_<uid> (combined blurb) chat_ai_summaries (global/user/relay/anon)
+ * chat_messages (channel + relayed + bot lines) follows / subscriptions / stream_first_chats
+ * channels (bio, panels) / managed_streams / streams channel_ai_bots (personas, memories)
+ * ai_viewer_threads (open conversations)
+ *
+ * Two halves:
+ * STABLE prefix — byte-identical between calls until one of its inputs changes, so the
+ * provider's prompt cache hits (Anthropic cache_control / OpenAI prefix).
+ * Never put a timestamp, count or anything live in here.
+ * VOLATILE tail — deltas: only chat newer than `since`, the last N seconds of speech,
+ * the latest frame description with its age, who is in the delta.
+ *
+ * The only side effect is optional: `wantFreshFrame` may ask stream-memory-job to capture
+ * a frame NOW through its normal pipeline (result lands in stream_memories for everyone).
+ */
+'use strict';
+const crypto = require('crypto');
+const db = require('../../db/database');
+
+function clip(str, n) { return (str == null ? '' : String(str)).replace(/\s+/g, ' ').trim().slice(0, n); }
+function clipWords(str, n) { const t = clip(str, n * 8); return t.length > n ? t.slice(0, n).replace(/\s+\S*$/, '') + '…' : t; }
+function mmss(sec) { sec = Math.max(0, Math.round(Number(sec) || 0)); return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; }
+function ago(ms) { if (ms == null || !Number.isFinite(ms)) return 'unknown'; const s = Math.round(ms / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; return `${Math.round(s / 3600)}h ago`; }
+function parseTs(v) { if (!v) return null; const t = String(v); const d = new Date(/^\d{4}-\d{2}-\d{2} /.test(t) ? t.replace(' ', 'T') + 'Z' : t); return isNaN(d) ? null : d.getTime(); }
+function hash(s) { return crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 16); }
+
+// Whisper emits markers on silence/noise; never show them to the model.
+function scrubSpeech(text) {
+ return String(text || '')
+ .replace(/[<>]{2,}/g, ' ')
+ .replace(/[\[(][^\])]*[\])]/g, ' ')
+ .replace(/\b(thanks? for watching|subscribe to my channel)\b[.!]?/gi, ' ')
+ .replace(/\s+/g, ' ').trim();
+}
+function hasWords(t) { return /[a-z0-9]{2,}/i.test(t || ''); }
+
+// ── Stable prefix blocks ──────────────────────────────────────
+function streamerBlock(userId) {
+ const parts = [];
+ try { const ov = db.getStreamerOverview(userId); if (ov && (ov.overview_short || ov.overview)) parts.push(clip(ov.overview_short || ov.overview, 700)); } catch { /* */ }
+ try {
+ let w = db.getState(`ai_whole_overview_${userId}`);
+ if (typeof w === 'string' && /^\s*\{/.test(w)) { try { w = JSON.parse(w); } catch { /* keep string */ } }
+ const t = w && typeof w === 'object' ? w.text : w;
+ if (t && typeof t === 'string') parts.push(clip(t, 400));
+ } catch { /* */ }
+ try {
+ const ch = db.getChannelByUserId(userId);
+ if (ch) {
+ if (ch.description) parts.push(`Channel bio: ${clip(ch.description, 240)}`);
+ let panels = []; try { panels = JSON.parse(ch.panels || '[]'); } catch { /* */ }
+ const titles = (panels || []).map(p => p && (p.title || p.name)).filter(Boolean).slice(0, 4);
+ if (titles.length) parts.push(`Channel panels: ${titles.map(t => clip(t, 40)).join(' · ')}`);
+ }
+ } catch { /* */ }
+ return parts.join('\n');
+}
+function sessionBlock(stream) {
+ if (!stream) return '';
+ const parts = [];
+ const title = stream.ai_title || stream.title;
+ if (title) parts.push(`Stream title: "${clip(title, 140)}"${stream.category ? ` (${clip(stream.category, 40)})` : ''}`);
+ if (stream.ai_overview) parts.push(`What this session has been about so far: ${clip(stream.ai_overview, 500)}`);
+ return parts.join('\n');
+}
+function rosterBlock(bots, settings) {
+ const muted = new Set(((settings.runtime || {}).muted || []).map(x => String(x).toLowerCase()));
+ return bots.map(b => {
+ let persona = {}, brain = {};
+ try { persona = JSON.parse(b.persona_json || '{}'); } catch { /* */ }
+ try { brain = JSON.parse(b.brain_json || '{}'); } catch { /* */ }
+ const blurb = (persona.character && persona.character.blurb) || 'a regular viewer';
+ const style = (persona.style && persona.style.rules) || 'casual, short';
+ const identity = persona.identity || brain.identity || '';
+ const memory = brain.memory || '';
+ const flags = [];
+ if (muted.has(String(b.username).toLowerCase())) flags.push('MUTED — never speaks');
+ if (persona.tts === false) flags.push('no tts');
+ return `- ${b.username}: ${clip(blurb, 160)}. Style: ${clip(style, 100)}.${identity ? ` About them: ${clip(identity, 240)}.` : ''}${memory ? ` Remembers: ${clip(memory, 300)}` : ''}${flags.length ? ` [${flags.join(', ')}]` : ''}`;
+ }).join('\n');
+}
+function rulesBlock(settings) {
+ const parts = [];
+ if (settings.channel_personality) parts.push(`Channel personality (from the streamer): ${clip(settings.channel_personality, 1200)}`);
+ if (settings.tone) parts.push(`Tone: ${clip(settings.tone, 200)}`);
+ if (settings.language && settings.language !== 'auto') parts.push(`Language: ${settings.language}`);
+ parts.push(`Emote/emoji usage: ${settings.emote_usage || 'some'}.`);
+ if (settings.topics) parts.push(`Topics the viewers enjoy: ${clip(settings.topics, 300)}`);
+ if (settings.blocklist) parts.push(`Never mention or engage with: ${clip(settings.blocklist, 500)}`);
+ if (settings.channel_memory) parts.push(`Shared running bits in this chat: ${clip(settings.channel_memory, 600)}`);
+ parts.push(`Lines are at most ${settings.max_words || 18} words.`);
+ return parts.join('\n');
+}
+function runningBitsBlock(userId) {
+ try {
+ const closed = db.getRecentClosedAiViewerThreads(userId, 3);
+ if (!closed.length) return '';
+ return 'Running bits from earlier streams: ' + closed.map(t => clip(t.topic, 80)).join(' · ');
+ } catch { return ''; }
+}
+
+/**
+ * Build (or reuse) the stable prefix. Returns { text, hash, at }.
+ * Cached on `cacheHolder.stable` until its inputs' hash changes or maxAgeMs elapses.
+ */
+function stablePrefix({ userId, stream, bots, settings, cacheHolder = null, maxAgeMs = 10 * 60 * 1000 }) {
+ const text = [
+ streamerBlock(userId),
+ sessionBlock(stream),
+ `The AI viewers in this chat (${bots.length}):\n${rosterBlock(bots, settings)}`,
+ rulesBlock(settings),
+ runningBitsBlock(userId),
+ ].filter(Boolean).join('\n\n');
+ const h = hash(text);
+ if (cacheHolder && cacheHolder.stable && cacheHolder.stable.hash === h && Date.now() - cacheHolder.stable.at < maxAgeMs) return cacheHolder.stable;
+ const out = { text, hash: h, at: Date.now() };
+ if (cacheHolder) cacheHolder.stable = out;
+ return out;
+}
+
+// ── Volatile blocks ──────────────────────────────────────────
+function streamOffsetSec(stream) {
+ const started = parseTs(stream && stream.started_at);
+ return started ? Math.max(0, Math.round((Date.now() - started) / 1000)) : 0;
+}
+
+function heardBlock(stream, settings) {
+ if (!settings.hear_enabled || !stream) return { text: '', ageMs: null, count: 0, sounds: [] };
+ const off = streamOffsetSec(stream);
+ const from = Math.max(0, off - (settings.hear_window_sec || 90));
+ let rows = [];
+ try { rows = db.getTimeline(stream.id, { from, to: off + 5, limit: 80 }) || []; } catch { rows = []; }
+ const speech = []; const sounds = [];
+ let lastEnd = null;
+ for (const r of rows) {
+ if (r.kind === 'speech') { const t = scrubSpeech(r.text); if (hasWords(t)) { speech.push(`[${mmss(r.start_sec)}] ${t}`); lastEnd = r.end_sec || r.start_sec; } }
+ else if (r.kind === 'sound' && r.label) sounds.push({ label: r.label, at: r.start_sec, confidence: r.confidence });
+ }
+ let text = speech.join('\n');
+ if (text.length > 900) text = text.slice(-900).replace(/^[^\[]*/, '');
+ const uniqSounds = [...new Set(sounds.map(s => s.label))].slice(0, 6);
+ const ageMs = lastEnd != null ? Math.max(0, (off - lastEnd) * 1000) : null;
+ return { text, ageMs, count: speech.length, sounds: uniqSounds, latestSound: sounds.length ? sounds[sounds.length - 1] : null };
+}
+
+function seenBlock(stream) {
+ if (!stream) return { text: '', ageMs: null, memoryId: null, tags: [] };
+ let m = null;
+ try { m = db.getLatestStreamMemory(stream.id); } catch { m = null; }
+ if (!m) return { text: '', ageMs: null, memoryId: null, tags: [] };
+ const desc = String(m.description || '').replace(/\s+—\s+heard:.*$/s, '').trim();
+ let tags = []; try { tags = JSON.parse(m.tags || '[]'); } catch { tags = []; }
+ const off = streamOffsetSec(stream);
+ const ageMs = Math.max(0, (off - (m.offset_seconds || 0)) * 1000);
+ return { text: clip(desc, 300), ageMs, memoryId: m.id, tags: Array.isArray(tags) ? tags.slice(0, 6) : [] };
+}
+
+function chatDelta(channelUserId, sinceId, { limit = 40, botNames = new Set() } = {}) {
+ let rows = [];
+ try { rows = db.getChannelChatSince(channelUserId, sinceId || 0, limit) || []; } catch { rows = []; }
+ const lines = rows.map(r => {
+ const isBot = r.source_platform === 'ai' || botNames.has(String(r.username || '').toLowerCase());
+ const tag = isBot ? ' (AI viewer)' : (r.source_platform && r.source_platform !== 'ai' ? '' : '');
+ return { id: r.id, username: r.username, message: clip(r.message, 160), isBot, userId: r.user_id, anonId: r.anon_id, platform: r.source_platform || null, replyTo: r.reply_to_id || null, tag };
+ });
+ const text = lines.map(l => `${l.username}${l.isBot ? ' (AI viewer)' : ''}: ${l.message}`).join('\n');
+ return { lines, text: text.length > 2500 ? text.slice(-2500) : text, maxId: rows.length ? rows[rows.length - 1].id : sinceId || 0 };
+}
+
+function moodBlock() {
+ try { const g = require('../chat-ai').getGlobalInsight(); return g && g.overview ? clip(g.overview, 250) : ''; } catch { return ''; }
+}
+
+function personBlock(line, channelUserId, settings) {
+ if (!settings.remember_viewers || line.isBot) return '';
+ const chatAi = require('../chat-ai');
+ let ins = null;
+ try {
+ if (line.userId) ins = chatAi.getUserInsight(line.userId);
+ else if (line.anonId) ins = chatAi.getAnonInsight(line.anonId);
+ else if (line.platform && !line.userId) {
+ const m = String(line.username || '').match(/^\[[^\]]+\]\s*(.+)$/);
+ const ru = db.getRelayUser(line.platform, m ? m[1] : line.username);
+ if (ru) ins = chatAi.getRelayUserInsight(ru.id);
+ }
+ } catch { ins = null; }
+ const flags = [];
+ try {
+ if (line.userId) {
+ if (line.userId === channelUserId) flags.push('THE STREAMER');
+ else {
+ if (db.isActiveSubscriber(line.userId, channelUserId)) flags.push('subscriber');
+ else if (db.isFollowing(line.userId, channelUserId)) flags.push('follower');
+ }
+ }
+ const key = line.userId ? `user:${line.username}` : (line.anonId ? `anon:${line.anonId}` : `ext:${line.username}`);
+ if (line.userId !== channelUserId && settings.greet_first_timers && db.isFirstChatInChannel && db.isFirstChatInChannel(key, channelUserId)) flags.push('first time chatting here');
+ } catch { /* */ }
+ const insight = ins ? clip([ins.overview_24h || ins.overview_alltime, ins.memory].filter(Boolean).join(' — '), 220) : '';
+ if (!insight && !flags.length) return '';
+ return `- ${line.username}${flags.length ? ` [${flags.join(', ')}]` : ''}${insight ? `: ${insight}` : ''}`;
+}
+
+function threadsBlock(channelUserId, limit) {
+ let rows = [];
+ try { rows = db.getOpenAiViewerThreads(channelUserId, limit || 5) || []; } catch { rows = []; }
+ return {
+ rows,
+ text: rows.map(t => {
+ let who = []; try { who = JSON.parse(t.participants_json || '[]'); } catch { /* */ }
+ return `- thread #${t.id} (${t.kind.replace('_', '↔')}, ${who.join(' & ')}${t.topic ? `, about: ${clip(t.topic, 60)}` : ''}, ${t.turns} turns)${t.last_line ? ` last: ${t.last_line_by}: "${clip(t.last_line, 120)}"` : ''}${t.awaiting ? ` — waiting on ${t.awaiting}` : ''}`;
+ }).join('\n'),
+ };
+}
+
+function viewerCountOf(stream) {
+ try { const cs = require('../../chat/chat-server'); if (cs && typeof cs.getStreamViewerCount === 'function') return cs.getStreamViewerCount(stream.id); } catch { /* */ }
+ return stream && stream.viewer_count != null ? stream.viewer_count : null;
+}
+
+/**
+ * Assemble the volatile tail.
+ * @returns {{ text, hash, changed, chatMaxId, delta, sources }}
+ */
+function volatileTail({ userId, stream, settings, sinceChatId = 0, botNames = new Set(), intents = [], mode = 'normal', linesAllowed = 3, botShare = null }) {
+ const heard = heardBlock(stream, settings);
+ const seen = seenBlock(stream);
+ const chat = chatDelta(userId, sinceChatId, { botNames });
+ const mood = moodBlock();
+ const threads = threadsBlock(userId, settings.max_open_threads || 3);
+ const people = [];
+ const seenNames = new Set();
+ for (const l of chat.lines) {
+ if (l.isBot || seenNames.has(l.username)) continue;
+ seenNames.add(l.username);
+ if (people.length >= 6) break;
+ const p = personBlock(l, userId, settings);
+ if (p) people.push(p);
+ }
+ const vc = viewerCountOf(stream);
+ const parts = [];
+ parts.push(`Now: ${mmss(streamOffsetSec(stream))} into the stream${vc != null ? `, ${vc} viewers` : ''}${stream && stream.title ? `, title "${clip(stream.title, 100)}"` : ''}.`);
+ if (heard.text) parts.push(`Heard on stream (last ${settings.hear_window_sec || 90}s, latest speech ${ago(heard.ageMs)}):\n${heard.text}`);
+ else if (settings.hear_enabled) parts.push('Heard on stream: (no speech in the last window)');
+ if (heard.sounds.length) parts.push(`Sounds detected: ${heard.sounds.join(', ')}`);
+ if (seen.text) parts.push(`On screen (${ago(seen.ageMs)}): ${seen.text}${seen.tags.length ? ` [${seen.tags.join(', ')}]` : ''}`);
+ if (mood) parts.push(`Site-wide chat mood: ${mood}`);
+ parts.push(chat.text ? `New chat since last look:\n${chat.text}` : 'New chat since last look: (nothing new)');
+ if (people.length) parts.push(`About the people in that chat:\n${people.join('\n')}`);
+ if (threads.text) parts.push(`Open conversations:\n${threads.text}`);
+ if (intents.length) parts.push(`Must handle now:\n${intents.map(i => `- ${i.text}`).join('\n')}`);
+ parts.push(`Plan budget: mode ${mode}; up to ${linesAllowed} line(s) this pass${botShare != null ? `; bot-to-bot share so far ${Math.round(botShare * 100)}% (target ≤ ${Math.round((settings.bot_to_bot_ratio || 0) * 100)}%)` : ''}.`);
+ const text = parts.join('\n\n');
+ const changeKey = hash([heard.text, seen.memoryId, chat.maxId, intents.map(i => i.text).join('|')].join(''));
+ return {
+ text, hash: changeKey, chatMaxId: chat.maxId,
+ delta: { newChat: chat.lines.filter(l => !l.isBot).length, newChatAll: chat.lines.length, speechLines: heard.count, memoryId: seen.memoryId, latestSound: heard.latestSound },
+ sources: { heardAgeMs: heard.ageMs, seenAgeMs: seen.ageMs, seenMemoryId: seen.memoryId, chatLines: chat.lines.length, people: people.length, threads: threads.rows.length, mood: !!mood },
+ chatLines: chat.lines, threads: threads.rows,
+ };
+}
+
+/**
+ * Ask the stream-memory pipeline for a fresh frame analysis if the latest one is older
+ * than maxAgeSec. The result is stored in stream_memories for everyone (never a private
+ * throwaway vision call). Returns true if a capture was started.
+ */
+async function wantFreshFrame(stream, { maxAgeSec = 180, allowFfmpeg = true } = {}) {
+ if (!stream) return false;
+ const seen = seenBlock(stream);
+ if (seen.ageMs != null && seen.ageMs < maxAgeSec * 1000) return false;
+ try {
+ const job = require('../stream-memory-job');
+ if (typeof job.captureMemoryNow !== 'function') return false;
+ await job.captureMemoryNow(stream, { allowFfmpeg, reason: 'ai-viewers' });
+ return true;
+ } catch { return false; }
+}
+
+module.exports = { stablePrefix, volatileTail, wantFreshFrame, heardBlock, seenBlock, chatDelta, scrubSpeech, clip, clipWords, ago, mmss, hash, streamOffsetSec };
@@ -18,9 +18,28 @@ const _lastSummary = new Map(); // streamId -> { count, at } — throttles the s
@@ -18,9 +18,28 @@ const _lastSummary = new Map(); // streamId -> { count, at } — throttles the s
const SUMMARY_MIN_NEW = 3; // re-summarize once this many new memories exist
const SUMMARY_MIN_NEW = 3; // re-summarize once this many new memories exist
const SUMMARY_MAX_AGE_MS = 5 * 60 * 1000; // ...or at least this long since the last rollup
const SUMMARY_MAX_AGE_MS = 5 * 60 * 1000; // ...or at least this long since the last rollup
-async function _analyzeOne(stream) {
+// In-flight capture per stream: the periodic job and on-demand callers (AI viewers asking
+// for a fresh look) share ONE ffmpeg + ONE vision call instead of racing.
+const _inflight = new Map(); // streamId → Promise
+/**
+ * Analyze the current frame NOW through the normal pipeline (frame → vision → memory row →
+ * overview rollup). Used by the AI viewers so a "look at the screen" is never a private,
+ * throwaway vision call — the result is cached for every consumer.
+ */
+function captureMemoryNow(stream, { allowFfmpeg = true, reason = 'manual' } = {}) {
+ if (!stream || !stream.id) return Promise.resolve(false);
+ if (!ai.streamMemoryEnabled()) return Promise.resolve(false);
+ const existing = _inflight.get(stream.id);
+ if (existing) return existing;
+ const p = _analyzeOne(stream, { allowFfmpeg, reason }).then(() => true).catch(() => false).finally(() => _inflight.delete(stream.id));
+ _inflight.set(stream.id, p);
+ _last.set(stream.id, Date.now());
+ return p;
+}
+
+async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } = {}) {
let image = null;
let image = null;
- try { if (vision && vision.captureFrame) image = await vision.captureFrame(stream); } catch { /* */ }
+ if (allowFfmpeg) { try { if (vision && vision.captureFrame) image = await vision.captureFrame(stream); } catch { /* */ } }
if (!image) {
if (!image) {
// Fall back to the freshest thumbnail file on disk.
// Fall back to the freshest thumbnail file on disk.
try {
try {
@@ -127,7 +146,8 @@ async function tick() {
@@ -127,7 +146,8 @@ async function tick() {
for (const stream of streams) {
for (const stream of streams) {
if (now - (_last.get(stream.id) || 0) < intervalMs) continue;
if (now - (_last.get(stream.id) || 0) < intervalMs) continue;
_last.set(stream.id, now); // set before the async work so we don't double-capture
_last.set(stream.id, now); // set before the async work so we don't double-capture
- _analyzeOne(stream).catch(() => {});
+ if (_inflight.has(stream.id)) continue;
+ captureMemoryNow(stream, { allowFfmpeg: true, reason: 'periodic' }).catch(() => {});
}
}
// GC entries for streams no longer live.
// GC entries for streams no longer live.
if (_last.size > 300 || _lastSummary.size > 300) {
if (_last.size > 300 || _lastSummary.size > 300) {
@@ -142,4 +162,4 @@ function start() {
@@ -142,4 +162,4 @@ function start() {
console.log('[AI] Stream memory job started (30s poll; captures at the configured interval when enabled)');
console.log('[AI] Stream memory job started (30s poll; captures at the configured interval when enabled)');
}
}
-module.exports = { start, tick };
+module.exports = { start, tick, captureMemoryNow };
@@ -92,4 +92,36 @@ function byoProvider(cfg) {
@@ -92,4 +92,36 @@ function byoProvider(cfg) {
};
};
}
}
-module.exports = { generate, budgetStatus, byoUsable, byoProvider, SOURCE };
+/** Today's shared-key spend on AI viewers across ALL channels (global viewers cap). */
+function globalViewerSpendToday() {
+ try { return db.get("SELECT COALESCE(SUM(cost_usd),0) AS c FROM ai_usage WHERE source = ? AND COALESCE(provider,'shared') = 'shared' AND created_at >= date('now')", [SOURCE])?.c || 0; } catch { return 0; }
+}
+
+/**
+ * v3 status with the degradation ladder. mode ∈ normal | economy | replies_only | streamer_only | silent.
+ * normal < 60% of the daily cap spent
+ * economy 60–80% (longer cadence, one fewer line per pass)
+ * replies_only 80–95% (only replies to real viewers / the streamer)
+ * streamer_only 95–100% (only the streamer fast path)
+ * silent cap reached, AI disabled, kill switch, or global viewers cap reached
+ */
+function status(userId) {
+ const st = budgetStatus(userId);
+ const kill = (() => { const v = db.getSetting('ai_viewers_enabled'); return v === false || v === 'false' || v === 0 || v === '0'; })();
+ let mode = 'normal';
+ let reason = st.reason;
+ if (kill) { mode = 'silent'; reason = 'kill_switch'; }
+ else if (!st.active) mode = 'silent';
+ else if (st.useShared) {
+ const gcap = parseFloat(db.getSetting('ai_viewers_global_cap_usd_per_day')) || 0;
+ if (gcap > 0 && globalViewerSpendToday() >= gcap) { mode = 'silent'; reason = 'global_viewers_cap'; }
+ else if (st.capUsd > 0) {
+ const ratio = st.spentToday / st.capUsd;
+ mode = ratio >= 1 ? 'silent' : ratio >= 0.95 ? 'streamer_only' : ratio >= 0.8 ? 'replies_only' : ratio >= 0.6 ? 'economy' : 'normal';
+ if (mode === 'silent') reason = 'over_daily_cap';
+ }
+ }
+ return { ...st, mode, reason, active: st.active && mode !== 'silent' };
+}
+
+module.exports = { generate, budgetStatus, status, byoUsable, byoProvider, globalViewerSpendToday, SOURCE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+/**
+ * AI Chat Viewers — engine selector.
+ * site_settings.ai_viewers_engine ('v2' | 'v3') picks the default; a channel can override
+ * with settings_json.engine. Both engines expose the same lifecycle surface, so the rest
+ * of the server (go-live hooks, chat hook, routes) never cares which one is running.
+ */
+'use strict';
+const db = require('../../db/database');
+
+function _v2() { return require('./engine'); }
+function _v3() { return require('./v3/engine'); }
+
+function engineForUser(userId) {
+ let override = '';
+ try { override = (JSON.parse(db.getChannelAiConfig(userId).settings_json || '{}') || {}).engine || ''; } catch { /* */ }
+ const site = String(db.getSetting('ai_viewers_engine') || 'v2');
+ return (override || site) === 'v3' ? _v3() : _v2();
+}
+function engineForStream(streamId) {
+ if (_v3().hasWorker(streamId)) return _v3();
+ if (_v2().hasWorker(streamId)) return _v2();
+ return null;
+}
+
+module.exports = {
+ get version() { return String(db.getSetting('ai_viewers_engine') || 'v2'); },
+ engineForUser,
+ hasWorker(streamId) { return !!engineForStream(streamId); },
+ startForStream(stream) { if (!stream || !stream.user_id) return; return engineForUser(stream.user_id).startForStream(stream); },
+ stopForStream(streamId) { const e = engineForStream(streamId); if (e) return e.stopForStream(streamId); },
+ stopForUser(userId) { _v2().stopForUser(userId); _v3().stopForUser(userId); },
+ applyConfigForUser(userId) {
+ // If the channel switched engines, stop the other one first.
+ const want = engineForUser(userId);
+ const other = want === _v3() ? _v2() : _v3();
+ try { other.stopForUser(userId); } catch { /* */ }
+ return want.applyConfigForUser(userId);
+ },
+ reloadForUser(userId) { return this.applyConfigForUser(userId); },
+ onRealChatMessage(streamId, ev) { const e = engineForStream(streamId); if (e) return e.onRealChatMessage(streamId, ev); },
+ onChatEvent(streamId, ev) { const e = engineForStream(streamId); if (e && e.onChatEvent) return e.onChatEvent(streamId, ev); if (e) return e.onRealChatMessage(streamId, ev); },
+ onModCommand(channelUserId, streamId, args, opts) { const e = engineForUser(channelUserId); return e.onModCommand ? e.onModCommand(channelUserId, streamId, args, opts) : 'This channel runs the legacy AI viewers engine — switch to v3 for chat commands.'; },
+ status(userId) { const e = engineForUser(userId); return e.status ? e.status(userId) : { engine: 'v2', running: _v2().hasWorker ? [..._v2().workers.values()].some(w => w.userId === userId) : false }; },
+ preview(userId) { const e = engineForUser(userId); return e.preview ? e.preview(userId) : Promise.resolve({ error: 'Preview needs the v3 engine' }); },
+ nudge(userId) { const e = engineForUser(userId); return e.nudge ? e.nudge(userId) : false; },
+ v2: _v2, v3: _v3,
+};
@@ -74,17 +74,60 @@ function botSummary(b) {
@@ -74,17 +74,60 @@ function botSummary(b) {
}
}
// ── Config ────────────────────────────────────────────────────
// ── Config ────────────────────────────────────────────────────
+function statusSafe(userId) { try { return engine.status(userId); } catch (e) { return { error: e.message }; } }
+
router.get('/config', requireAuth, (req, res) => {
router.get('/config', requireAuth, (req, res) => {
try {
try {
+ const cfg = db.getChannelAiConfig(req.user.id);
res.json({
res.json({
- config: sanitizeConfig(db.getChannelAiConfig(req.user.id)),
+ config: sanitizeConfig(cfg),
+ settings: settingsMod.getSettings(req.user.id, cfg),
+ schema: settingsMod.describe(),
budget: budgetSummary(req.user.id),
budget: budgetSummary(req.user.id),
+ status: statusSafe(req.user.id),
+ engine: engine.version,
});
});
} catch (e) {
} catch (e) {
res.status(500).json({ error: 'Failed to load AI viewer config' });
res.status(500).json({ error: 'Failed to load AI viewer config' });
}
}
});
});
+// ── v3 control surface ────────────────────────────────────────
+router.get('/status', requireAuth, (req, res) => {
+ try {
+ const st = statusSafe(req.user.id);
+ let stats = null; try { stats = db.getAiViewerLogStats(req.user.id, 60); } catch { /* */ }
+ res.json({ status: st, last_hour: stats, budget: budgetSummary(req.user.id) });
+ } catch (e) { res.status(500).json({ error: 'Failed to load status' }); }
+});
+router.get('/log', requireAuth, (req, res) => {
+ try {
+ const rows = db.getAiViewerLog(req.user.id, { afterId: parseInt(req.query.after, 10) || 0, limit: parseInt(req.query.limit, 10) || 50, streamId: parseInt(req.query.stream_id, 10) || null });
+ res.json({ rows });
+ } catch (e) { res.status(500).json({ error: 'Failed to load log' }); }
+});
+router.get('/threads', requireAuth, (req, res) => {
+ try { res.json({ open: db.getOpenAiViewerThreads(req.user.id, 10), recent: db.getRecentClosedAiViewerThreads(req.user.id, 10) }); }
+ catch (e) { res.status(500).json({ error: 'Failed to load threads' }); }
+});
+for (const cmd of ['pause', 'resume', 'nudge']) {
+ router.post(`/${cmd}`, requireAuth, (req, res) => {
+ try { res.json({ ok: true, message: engine.onModCommand(req.user.id, null, [cmd], { by: req.user.username }), status: statusSafe(req.user.id) }); }
+ catch (e) { res.status(500).json({ error: e.message }); }
+ });
+}
+router.post('/bots/:id/mute', requireAuth, (req, res) => { const bot = ownBotOr404(req, res); if (!bot) return; res.json({ ok: true, message: engine.onModCommand(req.user.id, null, ['mute', bot.username], { by: req.user.username }) }); });
+router.post('/bots/:id/unmute', requireAuth, (req, res) => { const bot = ownBotOr404(req, res); if (!bot) return; res.json({ ok: true, message: engine.onModCommand(req.user.id, null, ['unmute', bot.username], { by: req.user.username }) }); });
+router.post('/byo/test', requireAuth, async (req, res) => {
+ try {
+ const cfg = db.getChannelAiConfig(req.user.id);
+ const body = req.body || {};
+ const override = budget.byoProvider({ ...cfg, byo_key: (body.byo_key && body.byo_key !== KEY_SENTINEL) ? body.byo_key : cfg.byo_key, byo_base_url: body.byo_base_url ?? cfg.byo_base_url, byo_model: body.byo_model ?? cfg.byo_model });
+ const r = await ai.llm.testProvider(override);
+ res.json(r);
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
router.put('/config', requireAuth, (req, res) => {
router.put('/config', requireAuth, (req, res) => {
try {
try {
const body = req.body || {};
const body = req.body || {};
@@ -102,10 +145,15 @@ router.put('/config', requireAuth, (req, res) => {
@@ -102,10 +145,15 @@ router.put('/config', requireAuth, (req, res) => {
fields.byo_key = body.byo_key;
fields.byo_key = body.byo_key;
}
}
db.upsertChannelAiConfig(req.user.id, fields);
db.upsertChannelAiConfig(req.user.id, fields);
+ // v3 settings blob (partial merge, validated + clamped in settings.js).
+ if (body.settings && typeof body.settings === 'object') settingsMod.updateSettings(req.user.id, body.settings);
try { engine.applyConfigForUser(req.user.id); } catch { /* */ }
try { engine.applyConfigForUser(req.user.id); } catch { /* */ }
+ const cfg = db.getChannelAiConfig(req.user.id);
res.json({
res.json({
- config: sanitizeConfig(db.getChannelAiConfig(req.user.id)),
+ config: sanitizeConfig(cfg),
+ settings: settingsMod.getSettings(req.user.id, cfg),
budget: budgetSummary(req.user.id),
budget: budgetSummary(req.user.id),
+ status: statusSafe(req.user.id),
});
});
} catch (e) {
} catch (e) {
res.status(500).json({ error: 'Failed to save AI viewer config' });
res.status(500).json({ error: 'Failed to save AI viewer config' });
@@ -135,18 +183,21 @@ router.patch('/bots/:id', requireAuth, (req, res) => {
@@ -135,18 +183,21 @@ router.patch('/bots/:id', requireAuth, (req, res) => {
try {
try {
const bot = ownBotOr404(req, res);
const bot = ownBotOr404(req, res);
if (!bot) return;
if (!bot) return;
+ const body = req.body || {};
const fields = {};
const fields = {};
- if (req.body.display_name !== undefined) fields.display_name = req.body.display_name;
- if (req.body.is_active !== undefined) fields.is_active = req.body.is_active ? 1 : 0;
- if (req.body.identity !== undefined) {
- let persona = {};
- try { persona = JSON.parse(bot.persona_json || '{}'); } catch { /* */ }
- persona.identity = String(req.body.identity || '').slice(0, 1200);
- fields.persona_json = persona;
- }
- const updated = db.updateChannelAiBot(bot.id, fields);
+ if (body.display_name !== undefined) fields.display_name = String(body.display_name || '').slice(0, 40) || bot.username;
+ if (body.is_active !== undefined) fields.is_active = body.is_active ? 1 : 0;
+ let persona = {}; try { persona = JSON.parse(bot.persona_json || '{}') || {}; } catch { /* */ }
+ let touched = false;
+ if (body.identity !== undefined) { persona.identity = String(body.identity || '').slice(0, 1200); touched = true; }
+ if (body.blurb !== undefined) { persona.character = { ...(persona.character || {}), blurb: String(body.blurb || '').slice(0, 300) }; touched = true; }
+ if (body.style !== undefined) { persona.style = { ...(persona.style || {}), rules: String(body.style || '').slice(0, 300) }; touched = true; }
+ if (body.tts !== undefined) { persona.tts = !!body.tts; touched = true; }
+ if (body.color !== undefined && /^#[0-9a-f]{3,8}$/i.test(String(body.color))) { persona.color = String(body.color); fields.avatar_color = persona.color; touched = true; }
+ if (touched) fields.persona_json = persona;
+ if (Object.keys(fields).length) db.updateChannelAiBot(bot.id, fields);
try { engine.applyConfigForUser(req.user.id); } catch { /* */ }
try { engine.applyConfigForUser(req.user.id); } catch { /* */ }
- res.json({ bot: botSummary(updated) });
+ res.json({ bot: botSummary(db.getChannelAiBot(bot.id)) });
} catch (e) {
} catch (e) {
res.status(500).json({ error: 'Failed to update bot' });
res.status(500).json({ error: 'Failed to update bot' });
}
}
@@ -218,6 +269,11 @@ router.post('/clone', requireAuth, async (req, res) => {
@@ -218,6 +269,11 @@ router.post('/clone', requireAuth, async (req, res) => {
// ── Preview one line (no posting) ─────────────────────────────
// ── Preview one line (no posting) ─────────────────────────────
router.post('/preview', requireAuth, async (req, res) => {
router.post('/preview', requireAuth, async (req, res) => {
try {
try {
+ if (engine.engineForUser(req.user.id).version === 'v3') {
+ const out = await engine.preview(req.user.id);
+ if (out && out.error) return res.status(400).json({ error: out.error });
+ return res.json(out);
+ }
const st = budget.budgetStatus(req.user.id);
const st = budget.budgetStatus(req.user.id);
if (!st.active) {
if (!st.active) {
const why = st.reason === 'shared_ai_disabled' ? 'Shared AI is currently disabled by the admin.'
const why = st.reason === 'shared_ai_disabled' ? 'Shared AI is currently disabled by the admin.'
new file mode 100644
new file mode 100644
@@ -0,0 +1,176 @@
@@ -0,0 +1,176 @@
+/**
+ * AI Chat Viewers — per-channel settings schema.
+ *
+ * Every knob the streamer can turn lives in channel_ai_config.settings_json (merged over
+ * admin defaults from site_settings.ai_viewers_default_settings_json, merged over the
+ * built-in defaults below). Ranges are enforced here, server-side, on every save.
+ * Legacy columns (num_ambient_bots, pacing_seconds, persona, transcribe_enabled,
+ * vision_enabled) are read as fallbacks so existing rows keep working.
+ */
+'use strict';
+const db = require('../../db/database');
+
+// key → { def, type, min, max, values, max_len, help }
+const SCHEMA = {
+ engine: { def: '', type: 'enum', values: ['', 'v2', 'v3'], help: 'Engine override (blank = site default)' },
+ // Roster
+ roster_size: { def: 3, type: 'int', min: 0, max: 12, help: 'How many ambient AI viewers hang out in chat' },
+ // Activity
+ activity: { def: 'normal', type: 'enum', values: ['quiet', 'normal', 'lively', 'chaos', 'custom'], help: 'Preset for how chatty the bots are' },
+ lines_per_min: { def: 3, type: 'num', min: 0.5, max: 12, help: 'Max bot lines per minute (admin ceiling applies)' },
+ min_gap_sec: { def: 4, type: 'int', min: 2, max: 60, help: 'Minimum seconds between any two bot lines' },
+ director_interval_sec: { def: 20, type: 'int', min: 8, max: 120, help: 'How often the director looks at the stream and plans lines (active)' },
+ idle_interval_sec: { def: 60, type: 'int', min: 20, max: 300, help: 'Director cadence when nothing is happening' },
+ lines_per_tick: { def: 3, type: 'int', min: 1, max: 6, help: 'Max lines planned per director pass' },
+ // Social
+ bot_to_bot_ratio: { def: 0.3, type: 'num', min: 0, max: 0.8, help: 'Share of bot lines that are bots talking to each other' },
+ reply_probability: { def: 0.6, type: 'num', min: 0, max: 1, help: 'Chance a real viewer message gets a bot reply' },
+ mention_fast_path_sec: { def: 6, type: 'int', min: 0, max: 30, help: 'Reply to the streamer within this many seconds even between director passes' },
+ react_to_sounds: { def: true, type: 'bool', help: 'React to detected sounds (laughs, music, alarms…)' },
+ react_to_scene_changes:{ def: true, type: 'bool', help: 'React when what is on screen changes' },
+ greet_first_timers: { def: true, type: 'bool', help: 'Welcome first-time chatters in this channel' },
+ reply_to_relay_chat: { def: true, type: 'bool', help: 'Also reply to relayed Twitch/Kick/YouTube/RS chatters' },
+ max_open_threads: { def: 3, type: 'int', min: 0, max: 6, help: 'Concurrent conversations the bots keep going' },
+ max_thread_turns: { def: 6, type: 'int', min: 2, max: 20, help: 'Turns before a bot conversation naturally ends' },
+ thread_idle_close_sec: { def: 240, type: 'int', min: 60, max: 1800, help: 'Close a conversation nobody continued after this long' },
+ // Senses
+ hear_enabled: { def: true, type: 'bool', help: 'Use the live audio transcript (free, local)' },
+ hear_window_sec: { def: 90, type: 'int', min: 30, max: 300, help: 'How many seconds of speech the bots "hear"' },
+ vision_policy: { def: 'when_addressed', type: 'enum', values: ['off', 'thumbnail', 'when_addressed', 'periodic'], help: 'When to look at the screen: never / cached thumbnail only / fresh frame when the streamer addresses chat / periodically' },
+ vision_max_age_sec: { def: 180, type: 'int', min: 60, max: 900, help: 'Consider the last screen analysis stale after this long' },
+ vision_periodic_sec: { def: 300, type: 'int', min: 120, max: 1800, help: 'Periodic screen analysis cadence (periodic policy)' },
+ // Style
+ language: { def: 'en', type: 'str', max_len: 12, help: 'Language the bots chat in (ISO code or "auto")' },
+ tone: { def: '', type: 'str', max_len: 300, help: 'Overall vibe of the bots (e.g. cozy, hype, sarcastic)' },
+ emote_usage: { def: 'some', type: 'enum', values: ['none', 'some', 'lots'], help: 'How often bots use emotes/emoji' },
+ max_words: { def: 18, type: 'int', min: 4, max: 60, help: 'Longest bot line, in words' },
+ topics: { def: '', type: 'str', max_len: 300, help: 'Things the bots like to bring up' },
+ blocklist: { def: '', type: 'str', max_len: 500, help: 'Topics/words the bots must avoid' },
+ channel_personality: { def: '', type: 'str', max_len: 4000, help: 'Free-form description of your channel for the bots' },
+ // Behaviour
+ quiet_from: { def: '', type: 'str', max_len: 5, help: 'Quiet hours start (HH:MM, streamer local time)' },
+ quiet_to: { def: '', type: 'str', max_len: 5, help: 'Quiet hours end (HH:MM)' },
+ quiet_tz: { def: 'UTC', type: 'str', max_len: 48, help: 'IANA time zone for quiet hours' },
+ quiet_allow_replies: { def: true, type: 'bool', help: 'Still answer the streamer during quiet hours' },
+ tts_enabled: { def: true, type: 'bool', help: 'Read bot lines aloud with TTS (per-bot override in roster)' },
+ powerchat_forward: { def: true, type: 'bool', help: 'Show bot lines on your PowerChat overlay' },
+ memory_fold_min: { def: 10, type: 'int', min: 3, max: 60, help: 'How often bots consolidate what they remember' },
+ remember_viewers: { def: true, type: 'bool', help: 'Let bots use what the site already knows about regulars' },
+ channel_memory: { def: '', type: 'str', max_len: 600, help: 'Running bits the bots share (maintained automatically; editable)' },
+ // Object-valued (validated separately)
+ slots: { def: {}, type: 'obj', help: 'Per-slot enable: { "<managed_stream_id>": true|false }' },
+ byo: { def: {}, type: 'obj', help: '{ provider, base_url, model, model_chat, model_vision, model_director, model_summary }' },
+ runtime: { def: {}, type: 'obj', help: 'Live mod state: { paused, muted:[bot usernames] }' },
+};
+
+const ACTIVITY_PRESETS = { quiet: 1, normal: 3, lively: 5, chaos: 8 };
+
+function _parseJson(v) {
+ if (!v) return {};
+ if (typeof v === 'object') return v;
+ try { return JSON.parse(v) || {}; } catch { return {}; }
+}
+
+function adminDefaults() {
+ try {
+ const raw = db.getSetting('ai_viewers_default_settings_json');
+ return _parseJson(raw);
+ } catch { return {}; }
+}
+function adminLimits() {
+ const n = (k, d) => { const v = parseFloat(db.getSetting(k)); return Number.isFinite(v) ? v : d; };
+ return {
+ maxRoster: Math.max(0, Math.min(12, n('ai_viewers_max_roster', 12))),
+ maxLinesPerMin: Math.max(0.5, Math.min(12, n('ai_viewers_max_lines_per_min', 12))),
+ };
+}
+
+/** Coerce one value against the schema; returns undefined when unusable. */
+function coerce(key, v, limits) {
+ const sp = SCHEMA[key];
+ if (!sp) return undefined;
+ switch (sp.type) {
+ case 'bool': return v === true || v === 'true' || v === 1 || v === '1';
+ case 'int': { const n = parseInt(v, 10); if (!Number.isFinite(n)) return undefined; return Math.max(sp.min, Math.min(sp.max, n)); }
+ case 'num': { const n = parseFloat(v); if (!Number.isFinite(n)) return undefined; return Math.max(sp.min, Math.min(sp.max, n)); }
+ case 'enum': return sp.values.includes(String(v)) ? String(v) : undefined;
+ case 'str': return String(v == null ? '' : v).slice(0, sp.max_len || 500);
+ case 'obj': return (v && typeof v === 'object' && !Array.isArray(v)) ? v : undefined;
+ default: return undefined;
+ }
+}
+
+/** Validate + clamp a partial settings object (unknown keys dropped). */
+function sanitize(input, limits = adminLimits()) {
+ const out = {};
+ for (const [k, v] of Object.entries(input || {})) {
+ const c = coerce(k, v, limits);
+ if (c !== undefined) out[k] = c;
+ }
+ if (out.roster_size != null) out.roster_size = Math.min(out.roster_size, limits.maxRoster);
+ if (out.lines_per_min != null) out.lines_per_min = Math.min(out.lines_per_min, limits.maxLinesPerMin);
+ if (out.slots) { const s = {}; for (const [id, en] of Object.entries(out.slots)) if (/^\d+$/.test(id)) s[id] = !!en; out.slots = s; }
+ if (out.byo) {
+ const b = out.byo; const clean = {};
+ for (const k of ['provider', 'base_url', 'model', 'model_chat', 'model_vision', 'model_director', 'model_summary']) if (b[k] != null) clean[k] = String(b[k]).trim().slice(0, 300);
+ if (clean.provider && !['openai', 'anthropic'].includes(clean.provider)) delete clean.provider;
+ out.byo = clean;
+ }
+ if (out.runtime) { out.runtime = { paused: !!out.runtime.paused, muted: Array.isArray(out.runtime.muted) ? out.runtime.muted.map(String).slice(0, 24) : [] }; }
+ for (const k of ['quiet_from', 'quiet_to']) if (out[k] && !/^\d{2}:\d{2}$/.test(out[k])) out[k] = '';
+ return out;
+}
+
+/** Fully-resolved settings for a channel: built-in ← admin defaults ← row (+ legacy columns). */
+function getSettings(userId, cfgRow = null) {
+ const cfg = cfgRow || db.getChannelAiConfig(userId) || {};
+ const limits = adminLimits();
+ const base = {};
+ for (const [k, sp] of Object.entries(SCHEMA)) base[k] = typeof sp.def === 'object' ? { ...sp.def } : sp.def;
+ const admin = sanitize(adminDefaults(), limits);
+ const row = sanitize(_parseJson(cfg.settings_json), limits);
+ const s = { ...base, ...admin, ...row };
+ // Legacy column fallbacks (rows that predate settings_json).
+ if (row.roster_size == null && cfg.num_ambient_bots != null) s.roster_size = Math.min(limits.maxRoster, Math.max(0, cfg.num_ambient_bots));
+ if (row.channel_personality == null && cfg.persona) s.channel_personality = String(cfg.persona).slice(0, 4000);
+ if (row.hear_enabled == null && cfg.transcribe_enabled != null) s.hear_enabled = !!cfg.transcribe_enabled;
+ if (row.vision_policy == null && cfg.vision_enabled != null) s.vision_policy = cfg.vision_enabled ? 'when_addressed' : 'off';
+ if (s.activity !== 'custom' && ACTIVITY_PRESETS[s.activity]) s.lines_per_min = Math.min(limits.maxLinesPerMin, ACTIVITY_PRESETS[s.activity]);
+ s.lines_per_min = Math.min(s.lines_per_min, limits.maxLinesPerMin);
+ s.roster_size = Math.min(s.roster_size, limits.maxRoster);
+ return s;
+}
+
+/** Persist a partial update (merged into the stored JSON). Returns the resolved settings. */
+function updateSettings(userId, partial) {
+ const cfg = db.getChannelAiConfig(userId) || {};
+ const current = _parseJson(cfg.settings_json);
+ const clean = sanitize(partial);
+ const merged = { ...current, ...clean };
+ if (partial && partial.byo && current.byo) merged.byo = { ...current.byo, ...clean.byo };
+ if (partial && partial.slots && current.slots) merged.slots = { ...current.slots, ...clean.slots };
+ db.upsertChannelAiConfig(userId, { settings_json: JSON.stringify(merged) });
+ return getSettings(userId);
+}
+
+/** Schema description for the UI (defaults resolved with admin overrides). */
+function describe() {
+ const admin = sanitize(adminDefaults());
+ const limits = adminLimits();
+ return Object.entries(SCHEMA).map(([key, sp]) => ({
+ key, type: sp.type, help: sp.help,
+ def: admin[key] !== undefined ? admin[key] : sp.def,
+ min: key === 'roster_size' ? 0 : sp.min, max: key === 'roster_size' ? limits.maxRoster : (key === 'lines_per_min' ? limits.maxLinesPerMin : sp.max),
+ values: sp.values || null,
+ }));
+}
+
+function isQuietNow(s, at = new Date()) {
+ if (!s.quiet_from || !s.quiet_to) return false;
+ let hm;
+ try { hm = new Intl.DateTimeFormat('en-GB', { timeZone: s.quiet_tz || 'UTC', hour: '2-digit', minute: '2-digit', hour12: false }).format(at); } catch { hm = at.toISOString().slice(11, 16); }
+ const cur = hm.replace(':', ''); const from = s.quiet_from.replace(':', ''); const to = s.quiet_to.replace(':', '');
+ return from <= to ? (cur >= from && cur < to) : (cur >= from || cur < to);
+}
+
+module.exports = { SCHEMA, ACTIVITY_PRESETS, getSettings, updateSettings, sanitize, describe, adminLimits, isQuietNow };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+/**
+ * AI viewers v3 — the director.
+ *
+ * ONE structured LLM call per pass plans up to K lines for the whole roster: who speaks,
+ * to whom (a viewer, another bot in a thread, the streamer, or nobody), with what text,
+ * after what delay, and why. The stable prefix goes in the (cached) system role; the
+ * volatile tail is the user message. Batching lines per call is the single biggest
+ * token saving over the old one-call-per-line engine; the cached prefix is the second.
+ */
+'use strict';
+const llm = require('../../llm');
+
+const PLAN_SCHEMA = {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ skip: { type: 'boolean', description: 'true when nothing worth saying right now' },
+ notes: { type: 'string', description: 'one short line of director reasoning (logged only)' },
+ lines: {
+ type: 'array',
+ items: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ bot: { type: 'string', description: 'username of the AI viewer that speaks' },
+ target: { type: 'string', enum: ['viewer', 'bot', 'streamer', 'ambient'] },
+ reply_to: { type: ['string', 'null'], description: 'username being replied to (viewer or bot), else null' },
+ thread: { type: ['string', 'null'], description: 'existing thread id as a string, "new" to start one, or null' },
+ topic: { type: ['string', 'null'], description: 'topic label when thread is "new"' },
+ delay_ms: { type: 'integer', minimum: 0, maximum: 45000 },
+ text: { type: 'string', description: 'the chat line, in character' },
+ reason: { type: 'string', description: 'why this bot says this now (<= 80 chars)' },
+ },
+ required: ['bot', 'target', 'reply_to', 'thread', 'topic', 'delay_ms', 'text', 'reason'],
+ },
+ },
+ threads_close: { type: 'array', items: { type: 'string' }, description: 'thread ids that reached a natural end' },
+ },
+ required: ['skip', 'notes', 'lines', 'threads_close'],
+};
+
+const DIRECTOR_RULES = `You are the DIRECTOR of a cast of AI viewers in a live stream's chat. Each pass you decide which of them speak next, to whom, and what they say — like an improv coach cueing actors. You never speak yourself.
+
+Hard rules:
+- Every line must sound like a real, casual chat message from that specific viewer, fully in character and in their typing style. Short. No quotation marks, no stage directions, no meta commentary, never mention being an AI, bot, model, or "director".
+- If the streamer addressed chat or named an AI viewer, that MUST be answered first (target "streamer"), naturally and specifically about what they said/are doing.
+- Reply to real viewers when it makes sense (target "viewer", reply_to = their username). Prefer people flagged first-time / follower / subscriber. Never reply to yourself.
+- Bots may talk to each other (target "bot", reply_to = the other bot) to continue an open thread or start one when chat is quiet — keep the bot-to-bot share at or under the target given, and keep each thread moving (a question, a joke callback, a disagreement), then let it end.
+- "ambient" lines react to what is happening on stream (heard / seen) — not filler like "nice stream".
+- Use the transcript and screen description as the source of truth for what is happening; the timestamps tell you what is recent.
+- Vary who speaks; the same viewer never posts two lines in a row; spread delay_ms so lines arrive over the pass, streamer replies first (small delay).
+- Respect the channel rules, tone, language, blocklist and the word limit. Plain text only; emoji only per the emote policy.
+- Do not repeat anything already said in chat, by anyone. If there is genuinely nothing to add, return skip=true with an empty lines array.`;
+
+function buildSystem(stableText) {
+ // Order: rules (constant) → stable context (per stream, changes rarely). Both cached.
+ return [
+ { text: DIRECTOR_RULES, cache: true },
+ { text: stableText, cache: true },
+ ];
+}
+
+/**
+ * Plan a pass. Returns { plan, usage, model, cost, latencyMs } or null (quiet).
+ * @param {object} args { stableText, volatileText, maxLines, provider (BYO override or null), ownerUserId, cacheKey, temperature }
+ */
+async function plan({ stableText, volatileText, maxLines = 3, provider = null, ownerUserId = null, cacheKey = null, temperature = 0.9 }) {
+ const r = await llm.complete({
+ role: 'director',
+ system: buildSystem(stableText),
+ user: `${volatileText}\n\nPlan the next pass now (at most ${maxLines} lines). Return JSON only.`,
+ json: { name: 'plan', schema: PLAN_SCHEMA, strict: true },
+ maxTokens: 70 * maxLines + 120,
+ temperature,
+ cacheKey,
+ kind: 'ai_viewers_director', source: 'ai_viewers', ownerUserId,
+ provider,
+ });
+ if (!r) return null;
+ let p = r.json;
+ if (!p || typeof p !== 'object') p = llm.parseJsonLoose(r.text) || { skip: true, lines: [] };
+ const lines = Array.isArray(p.lines) ? p.lines.filter(l => l && typeof l === 'object' && typeof l.text === 'string' && l.bot) : [];
+ return { plan: { skip: !!p.skip && !lines.length, notes: String(p.notes || '').slice(0, 200), lines, threads_close: Array.isArray(p.threads_close) ? p.threads_close.map(String) : [] }, usage: r.usage, model: r.model, cost: r.cost, latencyMs: r.latencyMs };
+}
+
+/**
+ * Fast path: ONE line from ONE bot answering the streamer right now (no full plan).
+ */
+async function quickReply({ stableText, situationText, bot, streamerLine, maxWords = 18, provider = null, ownerUserId = null, cacheKey = null }) {
+ const r = await llm.complete({
+ role: 'chat',
+ system: buildSystem(stableText),
+ user: `${situationText}\n\nThe streamer just said: "${streamerLine}"\n\nWrite ONE chat line as ${bot.username} answering the streamer directly and specifically (max ${maxWords} words, plain text, in character). Return only the line.`,
+ maxTokens: 70,
+ temperature: 0.9,
+ cacheKey,
+ kind: 'ai_viewers_reply', source: 'ai_viewers', ownerUserId,
+ provider,
+ });
+ return r ? { text: r.text, usage: r.usage, model: r.model, cost: r.cost } : null;
+}
+
+module.exports = { plan, quickReply, PLAN_SCHEMA, DIRECTOR_RULES, buildSystem };
new file mode 100644
new file mode 100644
@@ -0,0 +1,428 @@
@@ -0,0 +1,428 @@
+/**
+ * AI Chat Viewers v3 — engine.
+ *
+ * Per live stream: a worker holds the roster, settings, a cached stable prompt prefix,
+ * a scheduler, and pending intents. A DIRECTOR pass runs on a cadence (or immediately
+ * on a priority intent), assembles the unified context (only deltas), makes ONE
+ * structured LLM call that plans several lines, and hands them to the scheduler.
+ * Streamer mentions get a guaranteed answer via a fast path when the next pass is far.
+ * Nothing runs when nothing changed. Spend degrades gracefully toward the streamer's cap.
+ */
+'use strict';
+const db = require('../../../db/database');
+const settingsMod = require('../settings');
+const roster = require('../roster');
+const budget = require('../budget');
+const context = require('../../context');
+const director = require('./director');
+const poster = require('./poster');
+const mention = require('./mention');
+const fold = require('./fold');
+const { Scheduler } = require('./scheduler');
+
+const ACTIVE_WINDOW_MS = 45000;
+const SKIP_LOG_EVERY_MS = 5 * 60 * 1000;
+const STREAMER_QUIET_MS = 8000; // after answering the streamer, hold ambient chatter briefly
+
+function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }
+function norm(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); }
+
+class AiViewersEngineV3 {
+ constructor() { this.workers = new Map(); this.version = 'v3'; }
+
+ hasWorker(streamId) { return this.workers.has(streamId); }
+ workerForUser(userId) { for (const w of this.workers.values()) if (w.userId === userId) return w; return null; }
+
+ _killSwitch() { const v = db.getSetting('ai_viewers_enabled'); return v === false || v === 'false' || v === 0 || v === '0'; }
+
+ // ── Lifecycle ─────────────────────────────────────────────
+ async startForStream(stream) {
+ try {
+ if (!stream || !stream.id || !stream.user_id) return;
+ if (this.workers.has(stream.id)) return;
+ if (this._killSwitch()) return;
+ const cfg = db.getChannelAiConfig(stream.user_id);
+ if (!cfg.enabled) return;
+ const settings = settingsMod.getSettings(stream.user_id, cfg);
+ let full = stream;
+ try { full = db.getStreamById(stream.id) || stream; } catch { /* */ }
+ if (full.managed_stream_id && settings.slots && settings.slots[String(full.managed_stream_id)] === false) {
+ console.log(`[AI-Viewers] slot ${full.managed_stream_id} has AI viewers off — not starting for stream ${stream.id}`);
+ return;
+ }
+ const bots = roster.ensureRoster(stream.user_id, settings.roster_size);
+ if (!bots.length) return;
+
+ const worker = {
+ streamId: stream.id, userId: stream.user_id, stream: full, cfg, settings, bots,
+ stable: null, // cached stable prefix {text,hash,at}
+ lastChatId: db.getMaxChatMessageIdForChannel(stream.user_id),
+ lastTickAt: 0, nextTickAt: 0, tickTimer: null, foldTimer: null, ticking: false,
+ lastRealInputAt: 0, lastStreamerReplyAt: 0, lastSkipLogAt: 0, lastVolatileHash: null,
+ intents: [], // [{ kind:'streamer'|'mention'|'sound'|'scene', text, bots:[], at }]
+ botLines: new Map(), // botId → recent lines (memory fold)
+ recentLines: [], // normalized last 30 bot lines (dedupe)
+ lastSeenMemoryId: null, lastPeriodicVisionAt: 0,
+ paused: !!(settings.runtime && settings.runtime.paused),
+ mode: 'normal',
+ stats: { ticks: 0, lines: 0, skips: 0, cost: 0 },
+ startedAt: Date.now(), stopped: false,
+ };
+ worker.scheduler = new Scheduler(worker, {
+ post: (line) => this._post(worker, line),
+ dropped: (line, why) => poster.log(worker, { event: 'skip', bot_username: line.bot, text: line.text, reason: why }),
+ });
+ this.workers.set(stream.id, worker);
+ poster.log(worker, { event: 'info', reason: `engine v3 started — ${bots.length} viewer(s), ${settings.activity} activity, director every ${settings.director_interval_sec}s` });
+ console.log(`[AI-Viewers v3] started for stream ${stream.id} (user ${stream.user_id}, ${bots.length} bots)`);
+ this._scheduleTick(worker, 4000);
+ worker.foldTimer = setInterval(() => this._fold(worker).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000);
+ } catch (e) { console.warn('[AI-Viewers v3] start failed:', e.message); }
+ }
+
+ stopForStream(streamId) {
+ const w = this.workers.get(streamId);
+ if (!w) return;
+ w.stopped = true;
+ if (w.tickTimer) clearTimeout(w.tickTimer);
+ if (w.foldTimer) clearInterval(w.foldTimer);
+ w.scheduler.stop();
+ this.workers.delete(streamId);
+ try { db.closeAllAiViewerThreads(w.userId); } catch { /* */ }
+ this._fold(w).catch(() => {});
+ poster.log(w, { event: 'info', reason: `stopped — ${w.stats.lines} line(s), ${w.stats.ticks} pass(es), $${w.stats.cost.toFixed(4)}` });
+ console.log(`[AI-Viewers v3] stopped for stream ${streamId}`);
+ }
+ stopForUser(userId) { for (const [sid, w] of this.workers) if (w.userId === userId) this.stopForStream(sid); }
+
+ /** Hot-apply config: reload settings/roster on live workers; start/stop as needed. */
+ applyConfigForUser(userId) {
+ const cfg = db.getChannelAiConfig(userId);
+ const settings = settingsMod.getSettings(userId, cfg);
+ const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();
+ for (const [sid, w] of this.workers) {
+ if (w.userId !== userId) continue;
+ if (!cfg.enabled || this._killSwitch() || (w.stream.managed_stream_id && settings.slots && settings.slots[String(w.stream.managed_stream_id)] === false)) { this.stopForStream(sid); continue; }
+ w.cfg = cfg; w.settings = settings; w.paused = !!(settings.runtime && settings.runtime.paused);
+ try { w.bots = roster.ensureRoster(userId, settings.roster_size); } catch { /* */ }
+ w.stable = null; // prefix inputs may have changed
+ if (w.foldTimer) { clearInterval(w.foldTimer); w.foldTimer = setInterval(() => this._fold(w).catch(() => {}), Math.max(3, settings.memory_fold_min) * 60000); }
+ poster.log(w, { event: 'info', reason: 'settings applied' });
+ }
+ if (cfg.enabled && !this._killSwitch()) for (const s of live) if (!this.workers.has(s.id)) this.startForStream(s).catch(() => {});
+ }
+ reloadForUser(userId) { return this.applyConfigForUser(userId); }
+
+ // ── Chat events (native, relayed, anon — everything public) ─
+ onRealChatMessage(streamId, ev) { return this.onChatEvent(streamId, ev); }
+ onChatEvent(streamId, ev = {}) {
+ const w = this.workers.get(streamId);
+ if (!w || w.stopped) return;
+ const uname = String(ev.username || '');
+ if (ev.isBot || w.bots.some(b => b.username.toLowerCase() === uname.toLowerCase())) return; // never react to our own lines
+ if (ev.platform && !w.settings.reply_to_relay_chat) return;
+ const isStreamer = !!ev.isStreamer || (ev.userId && ev.userId === w.userId);
+ const text = String(ev.message || '');
+ // Streamer/mod shortcut: "!ai pause" etc. also works from relayed chat for the streamer.
+ if (/^!ai\b/i.test(text) && (isStreamer || ev.isMod)) {
+ try { this.onModCommand(w.userId, streamId, text.split(/\s+/).slice(1), { by: uname }); } catch { /* */ }
+ return;
+ }
+ w.lastRealInputAt = Date.now();
+ const det = mention.detect(text, w.bots, { isStreamer });
+ if (isStreamer && (det.addressedChat || det.bots.length)) {
+ this._pushIntent(w, { kind: 'streamer', text: `The streamer (${uname}) said: "${clip(text, 220)}"${det.bots.length ? ` — addressed to ${det.bots.join(', ')}` : ' — addressed to chat'}. A reply is REQUIRED.`, bots: det.bots, line: text, at: Date.now(), msgId: ev.msgId || null });
+ this._fastPath(w, { line: text, bots: det.bots, replyTo: uname, msgId: ev.msgId || null }).catch(() => {});
+ return;
+ }
+ if (det.bots.length) {
+ this._pushIntent(w, { kind: 'mention', text: `${uname} addressed ${det.bots.join(', ')}: "${clip(text, 200)}" — that viewer should be answered.`, bots: det.bots, at: Date.now(), replyTo: uname });
+ this._scheduleTick(w, 2500);
+ return;
+ }
+ if (isStreamer) { this._scheduleTick(w, Math.min(6000, w.settings.director_interval_sec * 1000)); return; }
+ // Regular viewer message: the director decides; reply probability trims how often
+ // we even suggest it, and a burst of new chat pulls the next pass closer.
+ if (Math.random() < (w.settings.reply_probability == null ? 0.6 : w.settings.reply_probability)) {
+ this._pushIntent(w, { kind: 'viewer', text: `${uname} said: "${clip(text, 200)}" — consider replying to them.`, bots: [], at: Date.now(), replyTo: uname, soft: true });
+ }
+ if (Date.now() - w.lastTickAt > 8000) this._scheduleTick(w, 6000);
+ }
+ _pushIntent(w, intent) {
+ w.intents.push(intent);
+ // Hard intents first; keep the queue short so a burst can't stack ten replies.
+ w.intents.sort((a, b) => (a.soft ? 1 : 0) - (b.soft ? 1 : 0) || a.at - b.at);
+ if (w.intents.length > 6) w.intents = w.intents.slice(0, 6);
+ }
+
+ // ── Mod commands: /ai … or !ai … ─────────────────────────
+ onModCommand(channelUserId, streamId, args = [], { by = null } = {}) {
+ const [cmd, ...rest] = (args || []).map(a => String(a || '').trim()).filter(Boolean);
+ const w = this.workerForUser(channelUserId);
+ const settings = settingsMod.getSettings(channelUserId);
+ const runtime = { paused: !!(settings.runtime && settings.runtime.paused), muted: (settings.runtime && settings.runtime.muted) || [] };
+ const save = () => { settingsMod.updateSettings(channelUserId, { runtime }); if (w) { w.settings = settingsMod.getSettings(channelUserId); w.paused = runtime.paused; w.stable = null; } };
+ switch ((cmd || 'status').toLowerCase()) {
+ case 'pause': runtime.paused = true; save(); if (w) { w.scheduler.clear(); poster.log(w, { event: 'pause', reason: `paused by ${by || 'mod'}` }); } return 'AI viewers paused.';
+ case 'resume': runtime.paused = false; save(); if (w) { poster.log(w, { event: 'pause', reason: `resumed by ${by || 'mod'}` }); this._scheduleTick(w, 3000); } return 'AI viewers resumed.';
+ case 'mute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); if (!n) return 'Usage: /ai mute <viewer>'; if (!runtime.muted.includes(n)) runtime.muted.push(n); save(); return `Muted ${n}.`; }
+ case 'unmute': { const n = String(rest[0] || '').replace(/^@/, '').toLowerCase(); runtime.muted = runtime.muted.filter(x => x !== n); save(); return `Unmuted ${n}.`; }
+ case 'nudge': if (w) { this._scheduleTick(w, 500); return 'Nudged the director.'; } return 'AI viewers are not running right now.';
+ default: {
+ if (!w) return `AI viewers: ${runtime.paused ? 'paused' : 'not running for a live stream'}${runtime.muted.length ? `, muted: ${runtime.muted.join(', ')}` : ''}.`;
+ return `AI viewers: ${w.paused ? 'paused' : 'running'} · ${w.bots.length} viewers · mode ${w.mode} · ${w.stats.lines} lines this stream${runtime.muted.length ? ` · muted: ${runtime.muted.join(', ')}` : ''}. Commands: pause, resume, mute <name>, unmute <name>, nudge.`;
+ }
+ }
+ }
+
+ // ── Director loop ────────────────────────────────────────
+ _scheduleTick(w, delayMs) {
+ if (w.stopped) return;
+ const at = Date.now() + Math.max(250, delayMs);
+ if (w.tickTimer && w.nextTickAt <= at) return; // an earlier tick is already armed
+ if (w.tickTimer) clearTimeout(w.tickTimer);
+ w.nextTickAt = at;
+ w.tickTimer = setTimeout(() => { w.tickTimer = null; this._tick(w).catch(e => console.warn('[AI-Viewers v3] tick error:', e.message)); }, at - Date.now());
+ }
+ _cadenceMs(w) {
+ const s = w.settings;
+ const active = (Date.now() - w.lastRealInputAt) < ACTIVE_WINDOW_MS;
+ let ms = (active ? s.director_interval_sec : s.idle_interval_sec) * 1000;
+ if (w.mode === 'economy') ms *= 1.5;
+ if (w.mode === 'replies_only') ms *= 2;
+ return ms;
+ }
+ _providerFor(w) { return w.cfg.use_shared_key ? null : budget.byoProvider(w.cfg); }
+
+ async _tick(w) {
+ if (w.stopped || w.ticking) return;
+ w.ticking = true;
+ const s = w.settings;
+ try {
+ if (this._killSwitch()) { this.stopForStream(w.streamId); return; }
+ try { w.stream = db.getStreamById(w.streamId) || w.stream; } catch { /* */ }
+ if (!w.stream || !w.stream.is_live) { this.stopForStream(w.streamId); return; }
+ const hardIntents = w.intents.filter(i => !i.soft);
+ const st = budget.status(w.userId);
+ w.mode = st.mode;
+ const quiet = settingsMod.isQuietNow(s);
+ if (w.paused || (quiet && !(s.quiet_allow_replies && hardIntents.length)) || st.mode === 'silent' || !st.active) {
+ w.intents = [];
+ this._logSkip(w, w.paused ? 'paused' : quiet ? 'quiet hours' : (st.reason || st.mode));
+ return;
+ }
+ if (st.mode === 'streamer_only' && !hardIntents.some(i => i.kind === 'streamer')) { w.intents = []; this._logSkip(w, 'budget: streamer replies only'); return; }
+ if (st.mode === 'replies_only') w.intents = w.intents.filter(i => !i.soft ? true : i.kind === 'viewer');
+
+ // Vision: look at the screen through the shared memory pipeline when it is stale.
+ await this._maybeVision(w, hardIntents.length > 0);
+
+ const bots = w.bots.filter(b => !((s.runtime && s.runtime.muted) || []).includes(b.username.toLowerCase()));
+ if (!bots.length) { this._logSkip(w, 'all viewers muted'); return; }
+ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots, settings: s, cacheHolder: w });
+ const linesAllowed = Math.max(1, Math.min(s.lines_per_tick, st.mode === 'economy' ? s.lines_per_tick - 1 : s.lines_per_tick));
+ const tail = context.volatileTail({
+ userId: w.userId, stream: w.stream, settings: s, sinceChatId: w.lastChatId,
+ botNames: new Set(w.bots.map(b => b.username.toLowerCase())),
+ intents: w.intents.slice(0, 5), mode: st.mode, linesAllowed, botShare: w.scheduler.botShare(),
+ });
+ const awaiting = tail.threads.some(t => t.awaiting);
+ const sceneChanged = tail.delta.memoryId && tail.delta.memoryId !== w.lastSeenMemoryId && s.react_to_scene_changes;
+ const sound = s.react_to_sounds && tail.delta.latestSound && (Date.now() - w.lastTickAt) < 120000 ? tail.delta.latestSound : null;
+ const changed = tail.hash !== w.lastVolatileHash;
+ if (!changed && !w.intents.length && !awaiting && !sceneChanged) { this._logSkip(w, 'nothing new'); return; }
+ if (!tail.delta.newChat && !tail.delta.speechLines && !w.intents.length && !awaiting && !sceneChanged && w.scheduler.pending()) { this._logSkip(w, 'lines still queued'); return; }
+ w.lastVolatileHash = tail.hash;
+ if (sceneChanged) w.lastSeenMemoryId = tail.delta.memoryId;
+ let volatileText = tail.text;
+ if (sceneChanged && !w.intents.length) volatileText += '\n\nNote: what is on screen just changed — an ambient reaction is welcome.';
+ if (sound) volatileText += `\n\nNote: a "${sound.label}" sound was just detected on stream.`;
+
+ const consumed = w.intents.splice(0, w.intents.length);
+ const res = await director.plan({
+ stableText: stable.text, volatileText, maxLines: linesAllowed,
+ provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`,
+ });
+ w.lastTickAt = Date.now();
+ w.lastChatId = Math.max(w.lastChatId, tail.chatMaxId || 0);
+ w.stats.ticks++;
+ if (!res) { poster.log(w, { event: 'error', reason: 'director call failed or provider quiet' }); w.intents.unshift(...consumed.filter(i => !i.soft)); return; }
+ w.stats.cost += res.cost || 0;
+ const accepted = this._acceptPlan(w, res.plan, bots, consumed);
+ poster.log(w, {
+ event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),
+ text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,
+ tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,
+ });
+ for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { db.closeAiViewerThread(n); } catch { /* */ } } }
+ try { db.closeStaleAiViewerThreads(w.userId, s.thread_idle_close_sec, s.max_thread_turns); } catch { /* */ }
+ } finally {
+ w.ticking = false;
+ if (!w.stopped) this._scheduleTick(w, this._cadenceMs(w));
+ }
+ }
+
+ _logSkip(w, reason) {
+ w.stats.skips++;
+ if (Date.now() - w.lastSkipLogAt > SKIP_LOG_EVERY_MS) { w.lastSkipLogAt = Date.now(); poster.log(w, { event: 'skip', reason }); }
+ }
+
+ /** Validate + enqueue a plan. Returns the number of accepted lines. */
+ _acceptPlan(w, plan, bots, consumed) {
+ if (!plan || plan.skip || !plan.lines.length) return 0;
+ const s = w.settings;
+ const byName = new Map(bots.map(b => [b.username.toLowerCase(), b]));
+ const streamerIntent = consumed.find(i => i.kind === 'streamer');
+ const out = [];
+ let botTargets = 0;
+ let lastBot = null;
+ for (const raw of plan.lines) {
+ const bot = byName.get(String(raw.bot || '').toLowerCase());
+ if (!bot) continue;
+ if (lastBot === bot.username) continue;
+ let text = poster.clean(raw.text, s.max_words);
+ if (!text) continue;
+ const n = norm(text);
+ if (w.recentLines.includes(n)) continue;
+ const mod = poster.moderate(text);
+ if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); continue; }
+ const target = ['viewer', 'bot', 'streamer', 'ambient'].includes(raw.target) ? raw.target : 'ambient';
+ if (target === 'bot') {
+ const maxBot = Math.floor((s.bot_to_bot_ratio || 0) * Math.max(1, plan.lines.length) + 0.5);
+ if (botTargets >= Math.max(0, maxBot) && (s.bot_to_bot_ratio || 0) < 0.79) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: 'bot-to-bot ratio' }); continue; }
+ botTargets++;
+ }
+ // Threads
+ let threadId = null;
+ try {
+ if (raw.thread === 'new') {
+ const kind = target === 'bot' ? 'bot_bot' : target === 'streamer' ? 'bot_streamer' : 'bot_viewer';
+ const open = db.getOpenAiViewerThreads(w.userId, 10);
+ if (open.length < (s.max_open_threads || 3)) {
+ const t = db.createAiViewerThread({ channel_user_id: w.userId, stream_id: w.streamId, kind, participants: [bot.username, raw.reply_to].filter(Boolean), topic: raw.topic ? clip(raw.topic, 80) : null, awaiting: target === 'bot' ? raw.reply_to : null });
+ threadId = t && t.id;
+ }
+ } else if (raw.thread && /^\d+$/.test(String(raw.thread))) threadId = parseInt(raw.thread, 10);
+ } catch { threadId = null; }
+ lastBot = bot.username;
+ w.recentLines.push(n); if (w.recentLines.length > 30) w.recentLines.shift();
+ out.push({ bot: bot.username, botRow: bot, text, target, replyTo: raw.reply_to || null, threadId, delay_ms: Math.max(0, Math.min(45000, parseInt(raw.delay_ms, 10) || 0)), reason: clip(raw.reason, 120), replyToId: (target === 'streamer' && streamerIntent) ? streamerIntent.msgId : null });
+ }
+ if (out.length) w.scheduler.enqueue(out);
+ return out.length;
+ }
+
+ _post(w, line) {
+ if (w.stopped) return;
+ const bot = line.botRow || w.bots.find(b => b.username === line.bot);
+ if (!bot) return;
+ const id = poster.post(w, bot, line.text, { threadId: line.threadId, replyToId: line.replyToId });
+ w.stats.lines++;
+ if (line.target === 'streamer') w.lastStreamerReplyAt = Date.now();
+ poster.log(w, { event: 'line', bot_username: bot.username, target: line.target + (line.replyTo ? `:${line.replyTo}` : ''), thread_id: line.threadId || null, chat_message_id: id, text: line.text, reason: line.reason });
+ const buf = w.botLines.get(bot.id) || []; buf.push(line.text); if (buf.length > 24) buf.shift(); w.botLines.set(bot.id, buf);
+ if (line.threadId) {
+ try { db.touchAiViewerThread(line.threadId, { line: line.text, by: bot.username, awaiting: line.target === 'bot' ? (line.replyTo || null) : null }); } catch { /* */ }
+ // A bot↔bot thread waiting on the other bot pulls the next pass closer.
+ if (line.target === 'bot') this._scheduleTick(w, Math.min(this._cadenceMs(w), 12000));
+ }
+ }
+
+ // ── Streamer fast path ───────────────────────────────────
+ async _fastPath(w, { line, bots: named, replyTo, msgId }) {
+ const s = w.settings;
+ const waitMs = Math.max(0, (s.mention_fast_path_sec || 0) * 1000);
+ const nextIn = w.nextTickAt ? w.nextTickAt - Date.now() : Infinity;
+ if (nextIn <= waitMs + 1500) { this._scheduleTick(w, Math.min(nextIn, 1500)); return; } // the director will handle it in time
+ const st = budget.status(w.userId);
+ if (!st.active || st.mode === 'silent' || w.paused) return;
+ const muted = new Set(((s.runtime && s.runtime.muted) || []).map(x => String(x).toLowerCase()));
+ const pool = w.bots.filter(b => !muted.has(b.username.toLowerCase()));
+ if (!pool.length) return;
+ const bot = (named.length && pool.find(b => b.username === named[0])) || pool[Math.floor(Math.random() * pool.length)];
+ await this._maybeVision(w, true);
+ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: pool, settings: s, cacheHolder: w });
+ const heard = context.heardBlock(w.stream, s);
+ const seen = context.seenBlock(w.stream);
+ const situation = [
+ heard.text ? `Heard on stream recently:\n${heard.text.split('\n').slice(-6).join('\n')}` : '',
+ seen.text ? `On screen (${context.ago(seen.ageMs)}): ${seen.text}` : '',
+ ].filter(Boolean).join('\n\n');
+ const r = await director.quickReply({ stableText: stable.text, situationText: situation, bot, streamerLine: clip(line, 220), maxWords: s.max_words, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}` });
+ if (!r || !r.text) return;
+ const text = poster.clean(r.text, s.max_words);
+ const mod = text ? poster.moderate(text) : { ok: false, reason: 'empty' };
+ if (!mod.ok) { poster.log(w, { event: 'skip', bot_username: bot.username, text, reason: mod.reason }); return; }
+ w.stats.cost += r.cost || 0;
+ w.intents = w.intents.filter(i => i.kind !== 'streamer'); // handled — the next pass must not double-reply
+ w.recentLines.push(norm(text)); if (w.recentLines.length > 30) w.recentLines.shift();
+ poster.log(w, { event: 'mention', bot_username: bot.username, target: `streamer:${replyTo}`, text, reason: 'fast path — streamer addressed chat', tokens_in: r.usage.input, tokens_cached: r.usage.cached, tokens_out: r.usage.output, cost_usd: r.cost, model: r.model });
+ w.scheduler.enqueue([{ bot: bot.username, botRow: bot, text, target: 'streamer', replyTo, threadId: null, delay_ms: 0, reason: 'answering the streamer', replyToId: msgId || null }], { basePrio: 3 });
+ }
+
+ // ── Vision policy ────────────────────────────────────────
+ async _maybeVision(w, addressed) {
+ const s = w.settings;
+ if (s.vision_policy === 'off') return;
+ const seen = context.seenBlock(w.stream);
+ const stale = seen.ageMs == null || seen.ageMs > (s.vision_max_age_sec || 180) * 1000;
+ const periodicDue = s.vision_policy === 'periodic' && (Date.now() - w.lastPeriodicVisionAt) > (s.vision_periodic_sec || 300) * 1000 && (Date.now() - w.lastRealInputAt) < 5 * 60000;
+ if (!stale && !periodicDue) return;
+ const allowFfmpeg = s.vision_policy !== 'thumbnail';
+ if (addressed || periodicDue) {
+ if (periodicDue) w.lastPeriodicVisionAt = Date.now();
+ const p = context.wantFreshFrame(w.stream, { maxAgeSec: s.vision_max_age_sec, allowFfmpeg });
+ // Addressed: wait up to 12s so the answer can reference the screen; otherwise fire and forget.
+ if (addressed) { const t = new Promise(res => setTimeout(() => res(false), 12000)); const started = await Promise.race([p, t]); if (started) poster.log(w, { event: 'vision', reason: 'fresh frame analyzed (shared memory pipeline)' }); }
+ else p.then(ok => { if (ok) poster.log(w, { event: 'vision', reason: 'periodic frame analyzed' }); }).catch(() => {});
+ }
+ }
+
+ // ── Memory fold ──────────────────────────────────────────
+ async _fold(w) {
+ try {
+ const r = await fold.foldAll(w, { provider: this._providerFor(w) });
+ if (r) { w.stats.cost += r.cost || 0; poster.log(w, { event: 'fold', reason: `memory updated for ${r.updated} viewer(s)`, tokens_in: r.usage && r.usage.input, tokens_cached: r.usage && r.usage.cached, tokens_out: r.usage && r.usage.output, cost_usd: r.cost, model: r.model });
+ for (let i = 0; i < w.bots.length; i++) { const fresh = db.getChannelAiBot(w.bots[i].id); if (fresh) w.bots[i] = fresh; }
+ w.stable = null;
+ }
+ } catch (e) { poster.log(w, { event: 'error', reason: `fold: ${e.message}` }); }
+ }
+
+ // ── Status / preview (control panel) ─────────────────────
+ status(userId) {
+ const w = this.workerForUser(userId);
+ const st = budget.status(userId);
+ const timelineOn = (() => { try { return require('../../timeline-job').timelineEnabled(); } catch { return false; } })();
+ const base = { engine: 'v3', running: !!w, mode: st.mode, budget_active: st.active, budget_reason: st.reason, spent_today_usd: st.spentToday, cap_usd: st.capUsd, timeline_enabled: timelineOn, kill_switch: this._killSwitch() };
+ if (!w) return base;
+ const seen = context.seenBlock(w.stream);
+ const heard = context.heardBlock(w.stream, w.settings);
+ return { ...base, stream_id: w.streamId, paused: w.paused, bots: w.bots.length, stats: w.stats, last_tick_at: w.lastTickAt || null, next_tick_in_ms: w.nextTickAt ? Math.max(0, w.nextTickAt - Date.now()) : null,
+ queued: w.scheduler.pending(), bot_share: w.scheduler.botShare(), lines_last_minute: w.scheduler.linesLastMinute(),
+ senses: { heard_age_ms: heard.ageMs, heard_lines: heard.count, seen_age_ms: seen.ageMs, seen_text: seen.text, stable_prefix_chars: w.stable ? w.stable.text.length : null, stable_cached_since: w.stable ? w.stable.at : null } };
+ }
+
+ /** Dry run: assemble the real context and ask the director what it would do (nothing posted). */
+ async preview(userId) {
+ let w = this.workerForUser(userId);
+ let temp = false;
+ if (!w) {
+ const live = (() => { try { return db.getLiveStreamsByUserId(userId) || []; } catch { return []; } })();
+ if (!live.length) return { error: 'Go live first — the preview runs on your real stream context.' };
+ const cfg = db.getChannelAiConfig(userId); const settings = settingsMod.getSettings(userId, cfg);
+ const bots = roster.ensureRoster(userId, settings.roster_size || 3);
+ w = { streamId: live[0].id, userId, stream: db.getStreamById(live[0].id) || live[0], cfg, settings, bots, lastChatId: Math.max(0, db.getMaxChatMessageIdForChannel(userId) - 40), intents: [], scheduler: { botShare: () => 0 } };
+ temp = true;
+ }
+ const s = w.settings;
+ const stable = context.stablePrefix({ userId: w.userId, stream: w.stream, bots: w.bots, settings: s, cacheHolder: temp ? null : w });
+ const tail = context.volatileTail({ userId: w.userId, stream: w.stream, settings: s, sinceChatId: Math.max(0, (w.lastChatId || 0) - 25), botNames: new Set(w.bots.map(b => b.username.toLowerCase())), intents: w.intents, mode: 'normal', linesAllowed: s.lines_per_tick, botShare: w.scheduler.botShare() });
+ const res = await director.plan({ stableText: stable.text, volatileText: tail.text, maxLines: s.lines_per_tick, provider: this._providerFor(w), ownerUserId: w.userId, cacheKey: `aiv:${w.userId}`, temperature: 0.9 });
+ return { plan: res ? res.plan : null, usage: res ? res.usage : null, cost: res ? res.cost : null, model: res ? res.model : null, context: { stable_chars: stable.text.length, volatile_chars: tail.text.length, sources: tail.sources }, stable_preview: stable.text.slice(0, 1200), volatile_preview: tail.text.slice(0, 1500) };
+ }
+
+ nudge(userId) { const w = this.workerForUser(userId); if (!w) return false; this._scheduleTick(w, 500); return true; }
+}
+
+module.exports = new AiViewersEngineV3();
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+/**
+ * AI viewers v3 — memory fold. One summary-role call per channel folds every bot's recent
+ * lines (and the threads they took part in) into their rolling memory, plus a channel-wide
+ * "running bits" note. Replaces the per-bot fold of v2 (N calls → 1).
+ */
+'use strict';
+const db = require('../../../db/database');
+const llm = require('../../llm');
+
+function clip(str, n) { return (str || '').toString().replace(/\s+/g, ' ').trim().slice(0, n); }
+
+const FOLD_SCHEMA = {
+ type: 'object', additionalProperties: false,
+ properties: {
+ memories: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { bot: { type: 'string' }, memory: { type: 'string' } }, required: ['bot', 'memory'] } },
+ channel_memory: { type: 'string', description: 'running jokes / recurring topics for the whole channel, <= 90 words' },
+ },
+ required: ['memories', 'channel_memory'],
+};
+
+/**
+ * @param {object} worker { userId, bots, botLines: Map<botId, string[]>, settings }
+ * @returns {Promise<{updated:number, cost:number}|null>}
+ */
+async function foldAll(worker, { provider = null } = {}) {
+ const entries = [];
+ for (const bot of worker.bots) {
+ const lines = worker.botLines.get(bot.id) || [];
+ if (lines.length < 3) continue;
+ let brain = {}; try { brain = JSON.parse(bot.brain_json || '{}'); } catch { /* */ }
+ entries.push({ bot, lines: lines.slice(-20), memory: brain.memory || '' });
+ }
+ if (!entries.length) return null;
+ let channelMemory = '';
+ try { channelMemory = (JSON.parse(db.getChannelAiConfig(worker.userId).settings_json || '{}') || {}).channel_memory || ''; } catch { /* */ }
+
+ const prompt = [
+ 'You maintain the memories of AI chat personas for one streaming channel.',
+ channelMemory ? `Current channel memory (running bits): ${clip(channelMemory, 600)}` : '',
+ ...entries.map(e => `### ${e.bot.username}\nCurrent memory: ${e.memory ? clip(e.memory, 700) : '(none)'}\nRecent lines:\n- ${e.lines.map(l => clip(l, 160)).join('\n- ')}`),
+ 'For each persona, write an updated memory: a tight paragraph (max 90 words) of durable facts — running jokes, who they talk to, opinions they formed, recurring topics. Keep the useful old stuff, drop the trivial. Also update the channel memory (running bits everyone shares).',
+ ].filter(Boolean).join('\n\n');
+
+ const r = await llm.complete({
+ role: 'summary', user: prompt, json: { name: 'fold', schema: FOLD_SCHEMA, strict: true },
+ maxTokens: 160 * entries.length + 160, temperature: 0.4,
+ kind: 'ai_viewers_fold', source: 'ai_viewers', ownerUserId: worker.userId, provider,
+ });
+ if (!r) return null;
+ const out = r.json || llm.parseJsonLoose(r.text);
+ if (!out || !Array.isArray(out.memories)) return null;
+ let updated = 0;
+ for (const m of out.memories) {
+ const e = entries.find(x => x.bot.username.toLowerCase() === String(m.bot || '').toLowerCase());
+ if (!e || !m.memory || !String(m.memory).trim()) continue;
+ let brain = {}; try { brain = JSON.parse(e.bot.brain_json || '{}'); } catch { /* */ }
+ brain.memory = clip(m.memory, 900);
+ brain.timeline = (brain.timeline || []).slice(-8);
+ brain.timeline.push({ n: e.bot.msg_count || 0, note: clip(e.lines[e.lines.length - 1], 80) });
+ try { db.updateChannelAiBot(e.bot.id, { brain_json: brain }); worker.botLines.set(e.bot.id, []); updated++; } catch { /* */ }
+ }
+ if (out.channel_memory && String(out.channel_memory).trim()) {
+ try {
+ const cfg = db.getChannelAiConfig(worker.userId);
+ let sj = {}; try { sj = JSON.parse(cfg.settings_json || '{}') || {}; } catch { /* */ }
+ sj.channel_memory = clip(out.channel_memory, 600);
+ db.upsertChannelAiConfig(worker.userId, { settings_json: JSON.stringify(sj) });
+ } catch { /* */ }
+ }
+ return { updated, cost: r.cost || 0, usage: r.usage, model: r.model };
+}
+
+module.exports = { foldAll };
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+/**
+ * AI viewers v3 — who is being addressed?
+ * detect(message, bots, { isStreamer }) → { bots: [usernames named], addressedChat, question }
+ * A streamer talking to "chat"/"guys"/"everyone" or asking a question addresses everyone;
+ * "@name" or a bare bot username/display name addresses that bot (anyone can do that).
+ */
+'use strict';
+
+const CHAT_WORDS = /\b(chat|guys|gang|everyone|everybody|yall|y'all|you all|anyone|anybody|viewers|lads|folks|people)\b/i;
+
+function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
+
+function detect(message, bots, { isStreamer = false } = {}) {
+ const text = String(message || '');
+ const lower = text.toLowerCase();
+ const named = [];
+ for (const b of bots || []) {
+ const names = [b.username, b.display_name].filter(Boolean).map(n => String(n).toLowerCase());
+ for (const n of names) {
+ if (n.length < 3) continue;
+ if (lower.includes('@' + n) || new RegExp(`(^|[^a-z0-9_])${escapeRe(n)}([^a-z0-9_]|$)`, 'i').test(text)) { named.push(b.username); break; }
+ }
+ }
+ const question = /\?\s*$/.test(text.trim()) || /\b(what do you|whatd?ya|do you (guys|all)|should i|thoughts\??|opinions?\??|who here|anyone (here|know|want))\b/i.test(text);
+ const addressedChat = isStreamer && (CHAT_WORDS.test(text) || question || /^(so|ok|okay|alright|hey|yo)\b/i.test(text.trim()) && text.length < 120);
+ return { bots: [...new Set(named)], addressedChat, question };
+}
+
+module.exports = { detect };
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+/**
+ * AI viewers v3 — posting a bot line into chat (+ moderation + activity log).
+ * Ported from the v2 engine's _inject, with the bits it skipped: word filter, per-bot
+ * TTS toggle, PowerChat forward toggle, and a log row for the control panel.
+ */
+'use strict';
+const db = require('../../../db/database');
+
+function botColor(bot) {
+ try { return (JSON.parse(bot.persona_json || '{}').color) || bot.avatar_color || '#8a8aff'; } catch { return bot.avatar_color || '#8a8aff'; }
+}
+function botPersona(bot) { try { return JSON.parse(bot.persona_json || '{}') || {}; } catch { return {}; } }
+
+/** Strip quotes/disclaimers; enforce a word cap. Returns '' when the line must be dropped. */
+function clean(text, maxWords = 18) {
+ let t = String(text || '').trim();
+ t = t.replace(/^["'`“”]+|["'`“”]+$/g, '').replace(/\s+/g, ' ').trim();
+ if (!t) return '';
+ if (/\b(as an ai|language model|i am a bot|i'm a bot|as a bot|openai|anthropic)\b/i.test(t)) return '';
+ if (/^\w+:\s/.test(t) && t.length < 80) t = t.replace(/^\w+:\s/, ''); // "botname: text" echo
+ const words = t.split(' ');
+ if (words.length > maxWords) t = words.slice(0, maxWords).join(' ').replace(/[,;:]$/, '');
+ if (t.length > 240) t = t.slice(0, 240);
+ return t.trim();
+}
+
+/** Word-filter gate — returns { ok, text, reason }. */
+function moderate(text) {
+ try {
+ const wf = require('../../../chat/word-filter');
+ const r = wf.check(text);
+ if (!r.safe) return { ok: false, text, reason: `word filter: ${r.matches.slice(0, 3).join(', ')}` };
+ if (typeof wf.isSpam === 'function' && wf.isSpam(text)) return { ok: false, text, reason: 'spam pattern' };
+ } catch { /* filter unavailable → allow */ }
+ return { ok: true, text, reason: null };
+}
+
+/**
+ * Post a line. Returns the chat message id (or null).
+ * @param {object} worker { streamId, userId, settings }
+ */
+function post(worker, bot, message, { threadId = null, replyToId = null } = {}) {
+ const streamId = worker.streamId;
+ const persona = botPersona(bot);
+ const color = botColor(bot);
+ let id = null;
+ try {
+ const res = db.saveChatMessage({
+ stream_id: streamId,
+ channel_user_id: worker.userId,
+ user_id: null,
+ username: bot.username,
+ message,
+ message_type: 'chat',
+ is_global: false,
+ reply_to_id: replyToId || null,
+ source_platform: 'ai', // marker → excluded from chat-AI feeds
+ metadata: { bot: 1, bot_id: bot.id, source: bot.source, thread_id: threadId || undefined },
+ });
+ id = res && res.lastInsertRowid ? Number(res.lastInsertRowid) : null;
+ } catch (e) { console.warn('[AI-Viewers] saveChatMessage failed:', e.message); }
+
+ const chatMsg = {
+ type: 'chat', id: id || undefined,
+ username: bot.username, core_username: null,
+ user_id: null, anon_id: null, role: 'user',
+ message, stream_id: streamId, is_global: false,
+ reply_to_id: replyToId || null,
+ avatar_url: null, profile_color: color,
+ is_ai: true, source_platform: 'ai',
+ filtered: false, timestamp: new Date().toISOString(),
+ };
+ try {
+ const chatServer = require('../../../chat/chat-server');
+ chatServer.broadcastToStream(streamId, chatMsg);
+ chatServer.forwardToGlobal(streamId, chatMsg);
+ const ttsOn = worker.settings.tts_enabled !== false && persona.tts !== false;
+ if (ttsOn) chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);
+ } catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }
+
+ if (worker.settings.powerchat_forward !== false) {
+ try {
+ const pc = require('../../../integrations/powerchat-platform');
+ if (worker.userId && pc.channelRelayEnabled(worker.userId, streamId)) {
+ pc.forwardChat(worker.userId, {
+ chatterName: bot.username,
+ externalChatterId: `ai:${bot.username.toLowerCase()}`,
+ message,
+ messageId: id ? `ov-${id}` : undefined,
+ avatarFallback: '🤖',
+ });
+ }
+ } catch { /* non-critical */ }
+ }
+ try { db.touchChannelAiBot(bot.id); } catch { /* */ }
+ return id;
+}
+
+/** Append a row to ai_viewer_log (best-effort). */
+function log(worker, row) {
+ try {
+ db.addAiViewerLog({ channel_user_id: worker.userId, stream_id: worker.streamId || null, ...row });
+ } catch { /* */ }
+}
+
+module.exports = { post, clean, moderate, log, botPersona, botColor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+/**
+ * AI viewers v3 — pacing. Planned lines go into a per-stream queue and are released
+ * humanly: a minimum gap between any two bot lines, a per-minute ceiling, slow-mode
+ * awareness, no bot twice in a row, and a rolling bot-to-bot share cap.
+ */
+'use strict';
+
+function rint(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
+
+class Scheduler {
+ constructor(worker, deps) {
+ this.worker = worker; // { streamId, settings, ... }
+ this.deps = deps; // { post(line), now() }
+ this.queue = []; // [{ bot, text, target, replyTo, threadId, postAt, prio, reason }]
+ this.recent = []; // [{ at, target, bot }] last 40 posted
+ this.lastPostAt = 0;
+ this.lastBot = null;
+ this.timer = null;
+ this.stopped = false;
+ }
+
+ settings() { return this.worker.settings || {}; }
+
+ /** Queue planned lines (from a director pass). */
+ enqueue(lines, { basePrio = 1 } = {}) {
+ const now = Date.now();
+ for (const l of lines) {
+ const jitter = rint(0, 2500);
+ const prio = l.target === 'streamer' ? 3 : basePrio;
+ this.queue.push({ ...l, prio, postAt: now + (prio === 3 ? Math.min(l.delay_ms || 0, 4000) : (l.delay_ms || 0)) + jitter });
+ }
+ // Keep the queue bounded: newest plans win over stale unposted ambient chatter.
+ if (this.queue.length > 12) this.queue = this.queue.sort((a, b) => b.prio - a.prio || a.postAt - b.postAt).slice(0, 12);
+ this._arm();
+ }
+
+ /** Drop everything not yet posted (config change / pause / stop). */
+ clear() { this.queue = []; if (this.timer) { clearTimeout(this.timer); this.timer = null; } }
+
+ stop() { this.stopped = true; this.clear(); }
+
+ pending() { return this.queue.length; }
+
+ /** Share of bot→bot lines over the last 20 posts (0..1). */
+ botShare() {
+ const last = this.recent.slice(-20);
+ if (!last.length) return 0;
+ return last.filter(r => r.target === 'bot').length / last.length;
+ }
+ linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }
+
+ _slowModeMs() {
+ try { const cs = require('../../../chat/chat-server'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }
+ }
+
+ _arm(delay = null) {
+ if (this.stopped || this.timer || !this.queue.length) return;
+ const next = Math.min(...this.queue.map(q => q.postAt));
+ const wait = delay != null ? delay : Math.max(250, next - Date.now());
+ this.timer = setTimeout(() => { this.timer = null; this._drain(); }, wait);
+ }
+
+ _drain() {
+ if (this.stopped || !this.queue.length) return;
+ const s = this.settings();
+ const now = Date.now();
+ const minGap = Math.max((s.min_gap_sec || 4) * 1000, this._slowModeMs());
+ if (now - this.lastPostAt < minGap) return this._arm(minGap - (now - this.lastPostAt));
+ const perMin = Math.max(0.5, s.lines_per_min || 3);
+ if (this.linesLastMinute() >= perMin) return this._arm(5000);
+
+ // Pick the highest-priority due line that doesn't violate the alternation/ratio rules.
+ const due = this.queue.filter(q => q.postAt <= now).sort((a, b) => b.prio - a.prio || a.postAt - b.postAt);
+ if (!due.length) return this._arm();
+ let pick = null;
+ for (const q of due) {
+ if (q.bot === this.lastBot && due.length > 1) continue;
+ if (q.target === 'bot' && this.botShare() >= (s.bot_to_bot_ratio || 0) + 0.05 && q.prio < 3) continue;
+ pick = q; break;
+ }
+ if (!pick) {
+ // Everything due is blocked by the ratio/alternation rules — drop stale bot↔bot chatter.
+ const stale = due.find(q => q.target === 'bot' && q.prio < 3);
+ if (stale) { this.queue = this.queue.filter(q => q !== stale); this.deps.dropped && this.deps.dropped(stale, 'bot-to-bot share cap'); }
+ return this._arm(3000);
+ }
+ this.queue = this.queue.filter(q => q !== pick);
+ try { this.deps.post(pick); } catch (e) { console.warn('[AI-Viewers] post failed:', e.message); }
+ this.lastPostAt = Date.now();
+ this.lastBot = pick.bot;
+ this.recent.push({ at: this.lastPostAt, target: pick.target, bot: pick.bot });
+ if (this.recent.length > 40) this.recent.shift();
+ this._arm();
+ }
+}
+
+module.exports = { Scheduler };
@@ -886,10 +886,17 @@ class ChatServer {
@@ -886,10 +886,17 @@ class ChatServer {
// Bots inject via broadcastToStream directly, so this path only sees
// Bots inject via broadcastToStream directly, so this path only sees
// genuine human messages.
// genuine human messages.
try {
try {
+ let isMod = false;
+ try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
require('../integrations/ai-chatbot-service').onRealChatMessage(client.streamId, {
require('../integrations/ai-chatbot-service').onRealChatMessage(client.streamId, {
username,
username,
message: text,
message: text,
userId: client.user?.id || null,
userId: client.user?.id || null,
+ anonId: client.user ? null : (client.anonId || null),
+ isStreamer: !!(client.user && client.user.id === client.channelUserId),
+ isMod,
+ msgId: chatMsg && chatMsg.id ? chatMsg.id : null,
+ channelUserId: client.channelUserId || null,
});
});
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -1324,6 +1331,16 @@ class ChatServer {
@@ -1324,6 +1331,16 @@ class ChatServer {
const argParts = parts.slice(1);
const argParts = parts.slice(1);
switch (cmd) {
switch (cmd) {
+ case 'ai': {
+ // /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.
+ if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }
+ try {
+ const engine = require('../integrations/ai-chatbot-service');
+ const reply = engine.onModCommand ? engine.onModCommand(client.channelUserId, client.streamId, parts.slice(1), { by: client.user?.username }) : 'AI viewers: command not supported by this engine.';
+ this.sendTo(ws, { type: 'system', message: reply || 'ok' });
+ } catch (e) { this.sendTo(ws, { type: 'system', message: `AI viewers: ${e.message}` }); }
+ return;
+ }
case 'help':
case 'help':
this.sendTo(ws, {
this.sendTo(ws, {
type: 'system',
type: 'system',
@@ -1818,6 +1818,35 @@ function initDb() {
@@ -1818,6 +1818,35 @@ function initDb() {
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
)`);
+ // AI viewers v3: per-channel settings blob, persisted conversations, activity log.
+ try {
+ const cols = database.prepare('PRAGMA table_info(channel_ai_config)').all().map(c => c.name);
+ if (!cols.includes('settings_json')) database.exec("ALTER TABLE channel_ai_config ADD COLUMN settings_json TEXT DEFAULT '{}'");
+ database.exec(`CREATE TABLE IF NOT EXISTS ai_viewer_threads (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ channel_user_id INTEGER NOT NULL,
+ stream_id INTEGER,
+ kind TEXT NOT NULL, -- bot_bot | bot_viewer | bot_streamer
+ participants_json TEXT NOT NULL, -- ["botname","viewer"...]
+ topic TEXT,
+ state TEXT DEFAULT 'open', -- open | closed
+ awaiting TEXT, -- bot username expected to speak next (or null)
+ turns INTEGER DEFAULT 0,
+ last_line TEXT, last_line_by TEXT, last_line_at DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ database.exec('CREATE INDEX IF NOT EXISTS idx_aiv_threads_open ON ai_viewer_threads(channel_user_id, state, updated_at)');
+ database.exec(`CREATE TABLE IF NOT EXISTS ai_viewer_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ channel_user_id INTEGER NOT NULL, stream_id INTEGER,
+ event TEXT NOT NULL, -- tick | line | skip | mention | fold | vision | degrade | pause | error | info
+ bot_username TEXT, target TEXT, thread_id INTEGER, chat_message_id INTEGER,
+ text TEXT, reason TEXT,
+ tokens_in INTEGER, tokens_cached INTEGER, tokens_out INTEGER, cost_usd REAL, model TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
+ database.exec('CREATE INDEX IF NOT EXISTS idx_aiv_log_channel ON ai_viewer_log(channel_user_id, id)');
+ } catch (e) { console.warn('[DB] ai viewers v3 tables:', e.message); }
// One-time migration of existing ai_chatbot_configs rows into the new table.
// One-time migration of existing ai_chatbot_configs rows into the new table.
const migrated = database.prepare('SELECT COUNT(*) AS c FROM channel_ai_config').get().c;
const migrated = database.prepare('SELECT COUNT(*) AS c FROM channel_ai_config').get().c;
if (!migrated) {
if (!migrated) {
@@ -5957,6 +5986,7 @@ const CHANNEL_AI_CONFIG_DEFAULTS = {
@@ -5957,6 +5986,7 @@ const CHANNEL_AI_CONFIG_DEFAULTS = {
enabled: 0, num_ambient_bots: 3, pacing_seconds: 45, persona: '',
enabled: 0, num_ambient_bots: 3, pacing_seconds: 45, persona: '',
transcribe_enabled: 0, vision_enabled: 0, use_shared_key: 1,
transcribe_enabled: 0, vision_enabled: 0, use_shared_key: 1,
daily_budget_cents: 20, byo_key: '', byo_base_url: '', byo_model: 'gpt-4o-mini',
daily_budget_cents: 20, byo_key: '', byo_base_url: '', byo_model: 'gpt-4o-mini',
+ settings_json: '{}',
};
};
function getChannelAiConfig(userId) {
function getChannelAiConfig(userId) {
@@ -5977,6 +6007,8 @@ function upsertChannelAiConfig(userId, fields) {
@@ -5977,6 +6007,8 @@ function upsertChannelAiConfig(userId, fields) {
byo_key: (v) => String(v || '').trim().slice(0, 400),
byo_key: (v) => String(v || '').trim().slice(0, 400),
byo_base_url: (v) => String(v || '').trim().slice(0, 500),
byo_base_url: (v) => String(v || '').trim().slice(0, 500),
byo_model: (v) => String(v || '').trim().slice(0, 120) || 'gpt-4o-mini',
byo_model: (v) => String(v || '').trim().slice(0, 120) || 'gpt-4o-mini',
+ // Validated/clamped by ai/viewers/settings.js before it gets here; just bound the size.
+ settings_json: (v) => { const t = typeof v === 'string' ? v : JSON.stringify(v || {}); return t.length > 40000 ? '{}' : t; },
};
};
const existing = get('SELECT 1 FROM channel_ai_config WHERE user_id = ?', [userId]);
const existing = get('SELECT 1 FROM channel_ai_config WHERE user_id = ?', [userId]);
if (existing) {
if (existing) {
@@ -6027,6 +6059,63 @@ function getChannelAiBot(id) {
@@ -6027,6 +6059,63 @@ function getChannelAiBot(id) {
return get('SELECT * FROM channel_ai_bots WHERE id = ?', [id]);
return get('SELECT * FROM channel_ai_bots WHERE id = ?', [id]);
}
}
+// ── AI viewers v3: threads + activity log ───────────────────
+function createAiViewerThread({ channel_user_id, stream_id = null, kind, participants, topic = null, awaiting = null }) {
+ const r = run('INSERT INTO ai_viewer_threads (channel_user_id, stream_id, kind, participants_json, topic, awaiting) VALUES (?, ?, ?, ?, ?, ?)',
+ [channel_user_id, stream_id, kind, JSON.stringify(participants || []), topic, awaiting]);
+ return get('SELECT * FROM ai_viewer_threads WHERE id = ?', [r.lastInsertRowid]);
+}
+function getOpenAiViewerThreads(channelUserId, limit = 6) {
+ return all("SELECT * FROM ai_viewer_threads WHERE channel_user_id = ? AND state = 'open' ORDER BY updated_at DESC LIMIT ?", [channelUserId, limit]);
+}
+function getRecentClosedAiViewerThreads(channelUserId, limit = 3) {
+ return all("SELECT * FROM ai_viewer_threads WHERE channel_user_id = ? AND state = 'closed' AND topic IS NOT NULL ORDER BY updated_at DESC LIMIT ?", [channelUserId, limit]);
+}
+function touchAiViewerThread(id, { line, by, awaiting = null, topic = undefined }) {
+ const sets = ['turns = turns + 1', 'last_line = ?', 'last_line_by = ?', 'last_line_at = CURRENT_TIMESTAMP', 'awaiting = ?', 'updated_at = CURRENT_TIMESTAMP'];
+ const params = [String(line || '').slice(0, 300), by || null, awaiting];
+ if (topic !== undefined) { sets.push('topic = ?'); params.push(topic); }
+ params.push(id);
+ return run(`UPDATE ai_viewer_threads SET ${sets.join(', ')} WHERE id = ?`, params);
+}
+function closeAiViewerThread(id) { return run("UPDATE ai_viewer_threads SET state = 'closed', awaiting = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", [id]); }
+function closeStaleAiViewerThreads(channelUserId, idleSec, maxTurns) {
+ return run(`UPDATE ai_viewer_threads SET state = 'closed', awaiting = NULL, updated_at = CURRENT_TIMESTAMP
+ WHERE channel_user_id = ? AND state = 'open' AND (updated_at < datetime('now', ?) OR turns >= ?)`, [channelUserId, `-${Math.max(30, idleSec)} seconds`, maxTurns]).changes;
+}
+function closeAllAiViewerThreads(channelUserId) { return run("UPDATE ai_viewer_threads SET state = 'closed', awaiting = NULL, updated_at = CURRENT_TIMESTAMP WHERE channel_user_id = ? AND state = 'open'", [channelUserId]).changes; }
+function addAiViewerLog(row) {
+ return run(`INSERT INTO ai_viewer_log (channel_user_id, stream_id, event, bot_username, target, thread_id, chat_message_id, text, reason, tokens_in, tokens_cached, tokens_out, cost_usd, model)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [row.channel_user_id, row.stream_id || null, row.event, row.bot_username || null, row.target || null, row.thread_id || null, row.chat_message_id || null,
+ row.text == null ? null : String(row.text).slice(0, 600), row.reason == null ? null : String(row.reason).slice(0, 300),
+ row.tokens_in || null, row.tokens_cached || null, row.tokens_out || null, row.cost_usd || null, row.model || null]);
+}
+function getAiViewerLog(channelUserId, { afterId = 0, limit = 50, streamId = null } = {}) {
+ let sql = 'SELECT * FROM ai_viewer_log WHERE channel_user_id = ?';
+ const params = [channelUserId];
+ if (afterId) { sql += ' AND id > ?'; params.push(afterId); }
+ if (streamId) { sql += ' AND stream_id = ?'; params.push(streamId); }
+ sql += ' ORDER BY id DESC LIMIT ?'; params.push(Math.max(1, Math.min(200, limit)));
+ return all(sql, params).reverse();
+}
+function pruneAiViewerLog(days = 7) { return run("DELETE FROM ai_viewer_log WHERE created_at < datetime('now', ?)", [`-${days} days`]).changes; }
+function getAiViewerLogStats(channelUserId, sinceMin = 60) {
+ return get(`SELECT SUM(CASE WHEN event = 'line' THEN 1 ELSE 0 END) AS lines, SUM(CASE WHEN event = 'tick' THEN 1 ELSE 0 END) AS ticks,
+ SUM(CASE WHEN event = 'skip' THEN 1 ELSE 0 END) AS skips, COALESCE(SUM(cost_usd),0) AS cost_usd, COALESCE(SUM(tokens_in),0) AS tokens_in, COALESCE(SUM(tokens_cached),0) AS tokens_cached
+ FROM ai_viewer_log WHERE channel_user_id = ? AND created_at > datetime('now', ?)`, [channelUserId, `-${sinceMin} minutes`]);
+}
+// Channel chat (all surfaces: native, relayed, bot lines) newer than an id — what the bots "see".
+function getChannelChatSince(channelUserId, afterId = 0, limit = 60) {
+ return all(`SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp
+ FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation')
+ AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP)
+ ORDER BY id DESC LIMIT ?`, [channelUserId, afterId, Math.max(1, Math.min(200, limit))]).reverse();
+}
+function getMaxChatMessageIdForChannel(channelUserId) {
+ return get('SELECT COALESCE(MAX(id),0) AS m FROM chat_messages WHERE channel_user_id = ?', [channelUserId])?.m || 0;
+}
+
function getChannelAiBots(channelUserId, { activeOnly = false } = {}) {
function getChannelAiBots(channelUserId, { activeOnly = false } = {}) {
let sql = 'SELECT * FROM channel_ai_bots WHERE channel_user_id = ?';
let sql = 'SELECT * FROM channel_ai_bots WHERE channel_user_id = ?';
if (activeOnly) sql += ' AND is_active = 1';
if (activeOnly) sql += ' AND is_active = 1';
@@ -7830,6 +7919,8 @@ module.exports = {
@@ -7830,6 +7919,8 @@ module.exports = {
// Site Settings
// Site Settings
getSetting, getSettingRow, getAllSettings, setSetting, deleteSetting,
getSetting, getSettingRow, getAllSettings, setSetting, deleteSetting,
saveVodTranscriptProgress, getVodTranscriptProgress,
saveVodTranscriptProgress, getVodTranscriptProgress,
+ createAiViewerThread, getOpenAiViewerThreads, getRecentClosedAiViewerThreads, touchAiViewerThread, closeAiViewerThread, closeStaleAiViewerThreads, closeAllAiViewerThreads,
+ addAiViewerLog, getAiViewerLog, pruneAiViewerLog, getAiViewerLogStats, getChannelChatSince, getMaxChatMessageIdForChannel,
getState, setState, deleteState,
getState, setState, deleteState,
// Verification Keys
// Verification Keys
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
createVerificationKey, getVerificationKeyByKey, getVerificationKeyByUsername,
@@ -989,6 +989,8 @@ async function start() {
@@ -989,6 +989,8 @@ async function start() {
try { require('./ai/stream-memory-job').start(); } catch (e) { console.warn('[AI] memory job not started:', e.message); }
try { require('./ai/stream-memory-job').start(); } catch (e) { console.warn('[AI] memory job not started:', e.message); }
try { (() => { try { const d = require('./ai/transcribe').describe(); console.log(`[AI] whisper ${d.available ? 'available' : 'UNAVAILABLE'} — bin=${d.bin} model=${d.model}${d.modelExists ? '' : ' (MISSING)'} live=${d.modelLive}${d.modelLiveExists ? '' : ' (MISSING)'} vad=${d.vadModel || 'off'} threads=${d.threads}`); } catch (e) { console.warn('[AI] whisper probe failed:', e.message); } })();
try { (() => { try { const d = require('./ai/transcribe').describe(); console.log(`[AI] whisper ${d.available ? 'available' : 'UNAVAILABLE'} — bin=${d.bin} model=${d.model}${d.modelExists ? '' : ' (MISSING)'} live=${d.modelLive}${d.modelLiveExists ? '' : ' (MISSING)'} vad=${d.vadModel || 'off'} threads=${d.threads}`); } catch (e) { console.warn('[AI] whisper probe failed:', e.message); } })();
require('./ai/backfill-job').start(); } catch (e) { console.warn('[AI] backfill job not started:', e.message); }
require('./ai/backfill-job').start(); } catch (e) { console.warn('[AI] backfill job not started:', e.message); }
+ // AI viewers activity log: keep a week.
+ setInterval(() => { try { const n = db.pruneAiViewerLog(7); if (n) console.log(`[AI-Viewers] pruned ${n} log row(s)`); } catch { /* */ } }, 6 * 3600 * 1000);
try { require('./ai/streamer-overview-job').start(); } catch (e) { console.warn('[AI] streamer-overview job not started:', e.message); }
try { require('./ai/streamer-overview-job').start(); } catch (e) { console.warn('[AI] streamer-overview job not started:', e.message); }
try { require('./ai/chat-ai').start(); } catch (e) { console.warn('[AI] chat-ai job not started:', e.message); }
try { require('./ai/chat-ai').start(); } catch (e) { console.warn('[AI] chat-ai job not started:', e.message); }
try { require('./ai/slogan-job').start(); } catch (e) { console.warn('[AI] slogan job not started:', e.message); }
try { require('./ai/slogan-job').start(); } catch (e) { console.warn('[AI] slogan job not started:', e.message); }
deleted file mode 100644
deleted file mode 100644
@@ -1,140 +0,0 @@
@@ -1,140 +0,0 @@
-/**
- * ai-chatbot-routes.js — Per-streamer AI chatbot config API.
- *
- * GET /api/ai-chatbot - Load my config (token masked)
- * PUT /api/ai-chatbot - Save my config (token preserved when blank)
- * POST /api/ai-chatbot/validate - Test the AI credentials
- * POST /api/ai-chatbot/preview - Generate a sample bot line (no posting)
- */
-'use strict';
-const express = require('express');
-const db = require('../db/database');
-const { requireAuth } = require('../auth/auth');
-const aiProvider = require('../ai/ai-provider');
-const aiChatbotService = require('./ai-chatbot-service');
-
-const router = express.Router();
-
-function sanitize(cfg) {
- const token = String(cfg.api_token || '');
- return {
- enabled: !!cfg.enabled,
- base_url: cfg.base_url || 'https://api.openai.com/v1',
- model: cfg.model || 'gpt-4o-mini',
- transcribe_enabled: !!cfg.transcribe_enabled,
- transcribe_model: cfg.transcribe_model || 'whisper-1',
- num_bots: cfg.num_bots ?? 3,
- post_interval_seconds: cfg.post_interval_seconds ?? 45,
- persona: cfg.persona || '',
- vision_enabled: !!cfg.vision_enabled,
- has_token: !!token,
- api_token_masked: token ? `****${token.slice(-4)}` : '',
- last_validated_at: cfg.last_validated_at || null,
- };
-}
-
-router.get('/', requireAuth, (req, res) => {
- try {
- res.json({ config: sanitize(db.getAiChatbotConfig(req.user.id)) });
- } catch (err) {
- res.status(500).json({ error: 'Failed to load AI chatbot config' });
- }
-});
-
-router.put('/', requireAuth, (req, res) => {
- try {
- const b = req.body || {};
- const fields = {};
- if (b.enabled !== undefined) fields.enabled = !!b.enabled;
- if (b.base_url !== undefined) fields.base_url = b.base_url;
- if (b.model !== undefined) fields.model = b.model;
- if (b.transcribe_enabled !== undefined) fields.transcribe_enabled = !!b.transcribe_enabled;
- if (b.transcribe_model !== undefined) fields.transcribe_model = b.transcribe_model;
- if (b.vision_enabled !== undefined) fields.vision_enabled = !!b.vision_enabled;
- if (b.num_bots !== undefined) fields.num_bots = b.num_bots;
- if (b.post_interval_seconds !== undefined) fields.post_interval_seconds = b.post_interval_seconds;
- if (b.persona !== undefined) fields.persona = b.persona;
- // Preserve the stored token unless a non-blank new one is provided.
- if (b.api_token !== undefined && String(b.api_token).trim()) fields.api_token = String(b.api_token).trim();
-
- const config = db.upsertAiChatbotConfig(req.user.id, fields);
-
- // Apply changes to any live stream this user is currently running
- // (starts bots if enabling mid-stream, stops them if disabling).
- try { aiChatbotService.applyConfigForUser(req.user.id); } catch { /* non-critical */ }
-
- res.json({ config: sanitize(config) });
- } catch (err) {
- console.error('[AI-Bots] save config error:', err);
- res.status(500).json({ error: 'Failed to save AI chatbot config' });
- }
-});
-
-router.post('/validate', requireAuth, async (req, res) => {
- try {
- const stored = db.getAiChatbotConfig(req.user.id);
- const b = req.body || {};
- const baseUrl = b.base_url || stored.base_url;
- const apiKey = (b.api_token && String(b.api_token).trim()) || stored.api_token || '';
- // A token is only required for hosted OpenAI; self-hosted servers (Ollama etc.) need none.
- if (!apiKey && !aiProvider.isSelfHostedBaseUrl(baseUrl)) {
- return res.status(400).json({ ok: false, error: 'Enter your API token, or set a self-hosted Base URL (Ollama/LM Studio need no token).' });
- }
- // Fail fast with a helpful message before even trying an unreachable local address.
- if (aiProvider.isLocalOrPrivateHost(baseUrl)) {
- return res.json({ ok: false, error: `That Base URL points at a local/private address, which OpenVibe.Live's servers can't reach (the AI viewers run server-side, not in your browser). Expose your AI server publicly — e.g. a tunnel like ngrok or cloudflared — and use that URL.` });
- }
- const result = await aiProvider.testConnection({
- baseUrl,
- apiKey,
- model: b.model || stored.model,
- });
- if (result.ok) {
- try { db.upsertAiChatbotConfig(req.user.id, { last_validated_at: new Date().toISOString() }); } catch {}
- }
- res.json(result);
- } catch (err) {
- res.status(500).json({ ok: false, error: err.message });
- }
-});
-
-router.post('/preview', requireAuth, async (req, res) => {
- try {
- const stored = db.getAiChatbotConfig(req.user.id);
- const b = req.body || {};
- const baseUrl = b.base_url || stored.base_url;
- const apiKey = (b.api_token && String(b.api_token).trim()) || stored.api_token || '';
- if (!apiKey && !aiProvider.isSelfHostedBaseUrl(baseUrl)) {
- return res.status(400).json({ error: 'Save or enter an API token first (or use a self-hosted Base URL).' });
- }
- if (aiProvider.isLocalOrPrivateHost(baseUrl)) {
- return res.status(400).json({ error: `That Base URL is a local/private address our servers can't reach. Use a publicly-reachable URL (e.g. an ngrok/cloudflared tunnel to your Ollama).` });
- }
- const persona = b.persona !== undefined ? String(b.persona) : stored.persona;
- const messages = [
- {
- role: 'system',
- content: [
- 'You are a live viewer typing in a Twitch-style stream chat.',
- persona ? `The streamer wants chat viewers to behave like this: ${persona}` : 'You are a playful troll.',
- 'Write ONE short chat message (max ~20 words), casual and lowercase-ish. Be a troll or argumentative but PG-13: no slurs/hate/threats. No quotes, no name prefix. Output only the message.',
- ].filter(Boolean).join('\n'),
- },
- { role: 'user', content: 'The streamer is playing a game and chat is dead. Type your one chat message:' },
- ];
- const raw = await aiProvider.chatCompletion({
- baseUrl,
- apiKey,
- model: b.model || stored.model,
- messages,
- temperature: 1.05,
- maxTokens: 60,
- });
- const sample = String(raw || '').replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim().slice(0, 200);
- res.json({ sample });
- } catch (err) {
- res.status(502).json({ error: err.message });
- }
-});
-
-module.exports = router;
@@ -1,17 +1,8 @@
@@ -1,17 +1,8 @@
/**
/**
* ai-chatbot-service.js — compatibility shim.
* ai-chatbot-service.js — compatibility shim.
- *
- * The AI "fake viewers" system was rewritten as AI Chat Viewers 2.0 (persistent
- * per-channel roster + brains, live-situation context, per-streamer budgeting).
- * The new engine lives in ../ai/viewers/engine.js. This module re-exports that
- * singleton so the existing lifecycle hook points keep working unchanged:
- *
- * startForStream(stream) · stopForStream(streamId) · stopForUser(userId)
- * applyConfigForUser(userId) · reloadForUser(userId) · hasWorker(streamId)
- * onRealChatMessage(streamId, { username, message, userId })
- *
- * Call sites: index.js, streaming/whip-handler.js, streaming/routes.js,
- * chat/chat-server.js.
+ * The AI chat viewers engine lives in ../ai/viewers (index.js picks v2/v3 per site setting
+ * and per-channel override). Every lifecycle hook + chat hook in the server goes through
+ * this module so nothing else needs to know which engine is active.
*/
*/
'use strict';
'use strict';
-module.exports = require('../ai/viewers/engine');
+module.exports = require('../ai/viewers');
@@ -423,6 +423,13 @@ class ChatRelayService {
@@ -423,6 +423,13 @@ class ChatRelayService {
} catch {}
} catch {}
chatServer.broadcastToStream(bridge.streamId, chatMsg);
chatServer.broadcastToStream(bridge.streamId, chatMsg);
+ // AI viewers see relayed chat too (they used to only see native chat).
+ try {
+ require('./ai-chatbot-service').onRealChatMessage(bridge.streamId, {
+ username: prefixedUsername, message: chatMsg.message, userId: null, anonId: null,
+ platform: bridge.platform, relayUsername: username, isStreamer: false, isMod: false, msgId: chatMsg.id || null,
+ });
+ } catch { /* non-critical */ }
// Also surface on the global / username-only overlay (tags stream_channel)
// Also surface on the global / username-only overlay (tags stream_channel)
try { chatServer.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }
try { chatServer.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }
// And to viewers of the streamer's other live slots (cross-slot chat)
// And to viewers of the streamer's other live slots (cross-slot chat)
@@ -733,6 +733,12 @@ class RobotStreamerService {
@@ -733,6 +733,12 @@ class RobotStreamerService {
} catch {}
} catch {}
chatServer.broadcastToStream(stream.id, mirrored);
chatServer.broadcastToStream(stream.id, mirrored);
+ try {
+ require('./ai-chatbot-service').onRealChatMessage(stream.id, {
+ username, message: mirrored.message, userId: null, anonId: null,
+ platform: 'rs', relayUsername: username, isStreamer: false, isMod: false, msgId: mirrored.id || null,
+ });
+ } catch { /* non-critical */ }
// Also surface on the global / username-only overlay (tags stream_channel)
// Also surface on the global / username-only overlay (tags stream_channel)
try { chatServer.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }
try { chatServer.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }
// And to viewers of the streamer's other live slots (cross-slot chat)
// And to viewers of the streamer's other live slots (cross-slot chat)