Merge branch 'feature/caller-redesign' into main

Brings in the Phase 5B caller-generation rewrite:
- Slim two-stage pipeline: Sonnet 4.6 batch identity pregen at session
  start, Haiku 4.5 live dialog per turn. Deletes CallerBackground
  dataclass, shape/style/voice-matching, per-model routing, preflight UI.
- New caller_gen.py (slim prompt builder) and regulars_v2.py (Obsidian
  lore loader for named recurring callers).
- Reworked caller buttons and info panel around the slim background:
  identity, situation, signature, secret want. Removes shape badges,
  energy dots, emotion info-badges.
- Inworld TTS: emotional_register -> (temperature, speed_adjust) mapping
  via _emotional_register_to_params(). applyTextNormalization ON.
- Silas identity/voice leak fix, avatar gender, pre-warm batch gen.
- Archives old regulars to data/regulars.archived.json, adds 10 sample
  caller transcripts under docs/samples/.

Post-merge fixes applied during resolution:
- intern.py: kept main's richer new_show() (NEW SHOW history marker,
  trim, _save()) and _track_suggestion(); removed the branch's
  duplicate minimal new_show() stub.
- tts.py: added 5 voice speed overrides (Graham, Malcolm, Victoria,
  Loretta, Marlene) that main had tuned locally. Evelyn deliberately
  NOT added to VOICE_PROFILES — she is in BLACKLISTED_VOICES for
  unnatural prosody.
- Main's cost dashboard polish, LLM per-model params, and Devon show
  context memory from commits c087c03..61b3cba carried through cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 23:43:56 -06:00
co-authored by Claude Opus 4.6
29 changed files with 3089 additions and 7952 deletions
+14 -218
View File
@@ -335,25 +335,6 @@ section h2 {
position: relative;
}
.energy-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.shape-badge {
font-size: 0.6rem;
background: rgba(232, 121, 29, 0.25);
color: var(--accent);
padding: 1px 4px;
border-radius: 3px;
font-weight: bold;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.caller-btn:hover {
border-color: var(--accent);
background: #2a1e10;
@@ -452,20 +433,19 @@ section h2 {
font-weight: 600;
}
.info-badge.shape {
background: rgba(232, 121, 29, 0.2);
color: var(--accent);
.caller-identity {
font-size: 0.85rem;
color: var(--text);
font-weight: 600;
margin-bottom: 4px;
line-height: 1.3;
}
.info-badge.energy {
color: white;
font-size: 0.7rem;
}
.info-badge.emotion {
background: rgba(154, 139, 120, 0.2);
.caller-situation {
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
margin-bottom: 4px;
line-height: 1.3;
}
.caller-signature {
@@ -475,90 +455,13 @@ section h2 {
font-style: italic;
}
.caller-situation {
font-size: 0.8rem;
color: var(--text-muted);
.caller-secret-want {
font-size: 0.75rem;
color: #d4a030;
font-style: italic;
line-height: 1.3;
}
/* Caller model indicator */
.info-badge.model {
background: rgba(100, 140, 220, 0.2);
color: #7ab0e8;
font-size: 0.7rem;
cursor: pointer;
}
.caller-model-override {
font-size: 0.7rem;
padding: 2px 4px;
background: var(--bg);
color: var(--text);
border: 1px solid rgba(100, 140, 220, 0.3);
border-radius: 4px;
max-width: 140px;
}
/* Caller button model badge */
.model-tag {
font-size: 0.55rem;
color: #7ab0e8;
background: rgba(100, 140, 220, 0.15);
padding: 0 3px;
border-radius: 2px;
font-weight: 700;
letter-spacing: 0.3px;
flex-shrink: 0;
}
/* Caller Models settings section */
.caller-model-row {
margin-bottom: 8px;
}
.caller-model-row label {
margin-bottom: 0;
}
.cm-pool-input {
font-size: 0.8rem;
}
.cm-style-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 8px;
max-height: 200px;
overflow-y: auto;
}
.cm-style-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
padding: 3px 6px;
}
.cm-style-name {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
}
.cm-style-select {
font-size: 0.7rem;
padding: 2px 3px;
background: var(--bg);
color: var(--text);
border: 1px solid rgba(232, 121, 29, 0.15);
border-radius: 4px;
max-width: 110px;
}
.caller-background-full {
margin-top: 8px;
font-size: 0.75rem;
@@ -1947,110 +1850,3 @@ button:focus-visible {
.log-toggle-btn:hover {
color: var(--text);
}
/* Preflight */
.preflight-btn {
background: rgba(90, 138, 60, 0.15);
color: var(--accent-green);
border: 1px solid rgba(90, 138, 60, 0.3);
}
.preflight-btn:hover {
background: rgba(90, 138, 60, 0.25);
}
.preflight-content {
max-width: 700px;
}
.preflight-status {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
font-weight: 700;
font-size: 1.1rem;
}
.preflight-status.pass { background: rgba(90, 138, 60, 0.15); color: var(--accent-green); }
.preflight-status.warn { background: rgba(232, 169, 29, 0.15); color: #e8a91d; }
.preflight-status.fail { background: rgba(204, 34, 34, 0.15); color: var(--accent-red); }
.preflight-status.loading { background: rgba(232, 121, 29, 0.1); color: var(--text-muted); }
.preflight-checks {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.preflight-check {
background: var(--bg);
border: 1px solid rgba(232, 121, 29, 0.1);
border-radius: var(--radius-sm);
padding: 12px 16px;
}
.preflight-check-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.preflight-check-name {
font-weight: 600;
font-size: 0.95rem;
}
.preflight-check-badge {
font-size: 0.75rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
}
.preflight-check-badge.pass { background: rgba(90, 138, 60, 0.2); color: var(--accent-green); }
.preflight-check-badge.warn { background: rgba(232, 169, 29, 0.2); color: #e8a91d; }
.preflight-check-badge.fail { background: rgba(204, 34, 34, 0.2); color: var(--accent-red); }
.preflight-check-badge.skip { background: rgba(154, 139, 120, 0.2); color: var(--text-muted); }
.preflight-check-details {
margin-top: 10px;
font-size: 0.85rem;
color: var(--text-muted);
display: none;
}
.preflight-check.open .preflight-check-details {
display: block;
}
.preflight-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.preflight-table th {
text-align: left;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
padding: 4px 8px;
border-bottom: 1px solid rgba(232, 121, 29, 0.1);
}
.preflight-table td {
padding: 4px 8px;
font-size: 0.8rem;
color: var(--text);
border-bottom: 1px solid rgba(232, 121, 29, 0.05);
}
.preflight-table tr.mismatch td { color: var(--accent-red); }
.preflight-table tr.connected td { color: var(--accent-green); }
.preflight-test-btn {
background: rgba(232, 121, 29, 0.15);
color: var(--accent);
border: 1px solid rgba(232, 121, 29, 0.3);
}
.preflight-test-btn:hover { background: rgba(232, 121, 29, 0.25); }
.preflight-test-btn.loading { opacity: 0.6; pointer-events: none; }
+4 -56
View File
@@ -15,7 +15,6 @@
<button id="rec-btn" class="rec-btn" title="Record stems for post-production">REC</button>
<button id="new-session-btn" class="new-session-btn">New Session</button>
<button id="export-session-btn">Export</button>
<button id="preflight-btn" class="preflight-btn">Preflight</button>
<button id="settings-btn">Settings</button>
<a href="/costs" class="header-link-btn" title="Cost Dashboard">Costs</a>
</div>
@@ -73,15 +72,10 @@
</div>
<div id="call-status" class="call-status">No active call</div>
<div id="caller-info-panel" class="caller-info-panel hidden">
<div class="caller-info-row">
<span id="caller-shape-badge" class="info-badge shape"></span>
<span id="caller-energy-badge" class="info-badge energy"></span>
<span id="caller-emotion" class="info-badge emotion"></span>
<span id="caller-model-badge" class="info-badge model"></span>
<select id="caller-model-override" class="caller-model-override hidden"></select>
</div>
<div id="caller-signature" class="caller-signature"></div>
<div id="caller-identity" class="caller-identity"></div>
<div id="caller-situation" class="caller-situation"></div>
<div id="caller-signature" class="caller-signature"></div>
<div id="caller-secret-want" class="caller-secret-want"></div>
<details id="caller-background-details" class="caller-background-full">
<summary>Full Background</summary>
<div id="caller-background"></div>
@@ -289,36 +283,6 @@
</div>
</div>
<!-- Caller Model Routing -->
<div class="settings-group">
<h3>Caller Models</h3>
<div class="caller-model-row">
<label>
Strategy
<select id="cm-strategy">
<option value="single">Single Model</option>
<option value="cycle">Cycle Models</option>
<option value="style_matched">Style-Matched</option>
</select>
</label>
</div>
<div id="cm-pool-section" class="hidden">
<label>
Model Pool
<input type="text" id="cm-pool" class="cm-pool-input" placeholder="x-ai/grok-4, deepseek/deepseek-v3.2, ...">
</label>
</div>
<div id="cm-style-map" class="hidden">
<div class="cm-style-grid" id="cm-style-grid"></div>
</div>
<div class="caller-model-row">
<label>
Fallback Model
<select id="cm-fallback" class="model-select"></select>
</label>
</div>
</div>
<!-- TTS Settings -->
<div class="settings-group">
<h3>TTS Provider</h3>
@@ -359,24 +323,8 @@
</div>
</div>
</div>
<!-- Preflight Modal -->
<div id="preflight-modal" class="modal hidden">
<div class="modal-content preflight-content">
<h2>Show Preflight</h2>
<div id="preflight-status" class="preflight-status loading">
<span class="preflight-status-icon">...</span>
<span class="preflight-status-text">Running checks...</span>
</div>
<div id="preflight-checks" class="preflight-checks"></div>
<div class="modal-buttons">
<button id="preflight-test-btn" class="preflight-test-btn">Test Responses</button>
<button id="preflight-rerun-btn">Re-run</button>
<button id="close-preflight">Close</button>
</div>
</div>
</div>
</div>
<script src="/js/app.js?v=27"></script>
<script src="/js/app.js?v=28"></script>
</body>
</html>
+31 -409
View File
@@ -131,7 +131,6 @@ document.addEventListener('DOMContentLoaded', async () => {
initEventListeners();
initClock();
loadShowTheme();
loadCallerModels();
loadVoicemails();
setInterval(loadVoicemails, 30000);
loadEmails();
@@ -356,27 +355,6 @@ function initEventListeners() {
else if (e.key === 'Escape') e.target.blur();
});
// Caller Models
document.getElementById('cm-strategy')?.addEventListener('change', () => {
callerModelSettings.strategy = document.getElementById('cm-strategy').value;
updateCallerModelUI();
});
document.getElementById('caller-model-badge')?.addEventListener('click', () => {
const sel = document.getElementById('caller-model-override');
if (!sel || !currentCaller) return;
sel.classList.toggle('hidden');
if (!sel.classList.contains('hidden')) {
const current = callerModelAssignments[currentCaller.key];
if (current) sel.value = current;
}
});
document.getElementById('caller-model-override')?.addEventListener('change', (e) => {
if (currentCaller && e.target.value) {
overrideCallerModel(currentCaller.key, e.target.value);
e.target.classList.add('hidden');
}
});
// Settings
document.getElementById('settings-btn')?.addEventListener('click', async () => {
document.getElementById('settings-modal')?.classList.remove('hidden');
@@ -392,17 +370,6 @@ function initEventListeners() {
});
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
// Preflight
document.getElementById('preflight-btn')?.addEventListener('click', () => {
document.getElementById('preflight-modal')?.classList.remove('hidden');
runPreflight(false);
});
document.getElementById('preflight-test-btn')?.addEventListener('click', () => runPreflight(true));
document.getElementById('preflight-rerun-btn')?.addEventListener('click', () => runPreflight(false));
document.getElementById('close-preflight')?.addEventListener('click', () => {
document.getElementById('preflight-modal')?.classList.add('hidden');
});
// Wrap-up button
document.getElementById('wrapup-btn')?.addEventListener('click', wrapUp);
@@ -637,21 +604,7 @@ async function loadCallers() {
btn.dataset.key = caller.key;
let html = '';
if (caller.energy_level) {
const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' };
const color = energyColors[caller.energy_level] || '#9a8b78';
html += `<span class="energy-dot" style="background:${color}" title="${caller.energy_level} energy"></span>`;
}
html += caller.returning ? `<span class="caller-name">\u2605 ${caller.name}</span>` : `<span class="caller-name">${caller.name}</span>`;
if (caller.call_shape && caller.call_shape !== 'standard') {
const shapeLabels = {
escalating_reveal: 'ER', am_i_the_asshole: 'AITA', confrontation: 'VS',
celebration: '\u{1F389}', quick_hit: 'QH', bait_and_switch: 'B&S',
the_hangup: 'HU', reactive: 'RE'
};
const label = shapeLabels[caller.call_shape] || caller.call_shape.substring(0, 2).toUpperCase();
html += `<span class="shape-badge" title="${caller.call_shape.replace(/_/g, ' ')}">${label}</span>`;
}
// Shortcut label: 1-9 for first 9, 0 for 10th
if (idx < 10) {
const shortcutKey = idx === 9 ? '0' : String(idx + 1);
@@ -669,7 +622,6 @@ async function loadCallers() {
}
console.log('Loaded', data.callers.length, 'callers, session:', data.session_id);
updateCallerModelBadges();
} catch (err) {
console.error('loadCallers error:', err);
}
@@ -742,27 +694,20 @@ async function startCall(key, name) {
if (aiInfo) aiInfo.classList.remove('hidden');
if (aiName) aiName.textContent = name;
// Show caller info panel with structured data
// Show caller info panel with slim background data
const infoPanel = document.getElementById('caller-info-panel');
if (infoPanel && data.caller_info) {
const ci = data.caller_info;
const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' };
const shapeBadge = document.getElementById('caller-shape-badge');
const energyBadge = document.getElementById('caller-energy-badge');
const emotionBadge = document.getElementById('caller-emotion');
const signature = document.getElementById('caller-signature');
const identity = document.getElementById('caller-identity');
const situation = document.getElementById('caller-situation');
if (shapeBadge) shapeBadge.textContent = (ci.call_shape || 'standard').replace(/_/g, ' ');
if (energyBadge) { energyBadge.textContent = (ci.energy_level || '').replace('_', ' '); energyBadge.style.background = energyColors[ci.energy_level] || '#9a8b78'; }
if (emotionBadge) emotionBadge.textContent = ci.emotional_state || '';
if (signature) signature.textContent = ci.signature_detail ? `"${ci.signature_detail}"` : '';
if (situation) situation.textContent = ci.situation_summary || '';
const signature = document.getElementById('caller-signature');
const secretWant = document.getElementById('caller-secret-want');
if (identity) identity.textContent = ci.identity || '';
if (situation) situation.textContent = ci.situation || '';
if (signature) signature.textContent = ci.signature ? `"${ci.signature}"` : '';
if (secretWant) secretWant.textContent = ci.secret_want ? `secretly wants: ${ci.secret_want}` : '';
infoPanel.classList.remove('hidden');
}
try {
showCallerModelBadge(callerModelAssignments[key] || data.model);
} catch(e) { console.error('[startCall] showCallerModelBadge error:', e); }
document.getElementById('caller-model-override')?.classList.add('hidden');
const bgEl = document.getElementById('caller-background');
if (bgEl && data.background) bgEl.textContent = data.background;
@@ -799,19 +744,32 @@ async function newSession() {
await hangup();
}
await fetch('/api/session/reset', { method: 'POST' });
conversationSince = 0;
const btn = document.getElementById('new-session-btn');
const originalText = btn?.textContent;
if (btn) {
btn.disabled = true;
btn.textContent = 'Generating...';
}
// Hide caller background
const bgDetails = document.getElementById('caller-background-details');
if (bgDetails) bgDetails.classList.add('hidden');
try {
await fetch('/api/session/reset', { method: 'POST' });
conversationSince = 0;
// Reload callers to get new session ID
await loadCallers();
await loadShowTheme();
await loadCallerModels();
// Hide caller background
const bgDetails = document.getElementById('caller-background-details');
if (bgDetails) bgDetails.classList.add('hidden');
log('New session started - all callers have fresh backgrounds');
// Reload callers to get new session ID
await loadCallers();
await loadShowTheme();
log('New session started - all callers have fresh backgrounds');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalText || 'New Session';
}
}
}
@@ -848,8 +806,6 @@ async function hangup() {
document.getElementById('caller-info-panel')?.classList.add('hidden');
const bgDetails2 = document.getElementById('caller-background-details');
if (bgDetails2) bgDetails2.classList.add('hidden');
showCallerModelBadge(null);
document.getElementById('caller-model-override')?.classList.add('hidden');
// Hide AI caller indicator
document.getElementById('ai-caller-info')?.classList.add('hidden');
@@ -1437,188 +1393,6 @@ async function clearShowTheme() {
}
// --- Caller Model Routing ---
const MODEL_ABBREVS = {
'claude-sonnet-4-5': 'Son', 'claude-haiku-4.5': 'Hai', 'claude-3-haiku': 'H3',
'grok-4': 'Grk', 'grok-4-fast': 'GrF',
'minimax-m2-her': 'MnM', 'mistral-small-creative': 'Mis',
'deepseek-v3.2': 'DSk', 'gemini-2.5-flash': 'Gem', 'gemini-flash-1.5': 'Gm1',
'gpt-4o-mini': '4oM', 'gpt-4o': '4o', 'llama-3.1-8b-instruct': 'Lla',
};
const CALLER_STYLES = [
'quiet_nervous', 'storyteller', 'deadpan', 'high_energy', 'confrontational',
'oversharer', 'philosopher', 'bragger', 'first_time', 'emotional',
'world_weary', 'conspiracy', 'comedian', 'angry_venting', 'sweet_earnest',
'mysterious', 'know_it_all', 'rambling',
];
let callerModelSettings = { strategy: 'single', pool: [], fallback: '', style_map: {} };
let callerModelAssignments = {}; // key -> model_id
function modelAbbrev(modelId) {
const name = (modelId || '').split('/').pop();
return MODEL_ABBREVS[name] || name.substring(0, 3).toUpperCase();
}
async function loadCallerModels() {
try {
const res = await fetch('/api/caller-models');
if (!res.ok) return;
const data = await res.json();
callerModelSettings = {
strategy: data.strategy || 'single',
pool: data.pool || [],
fallback: data.fallback || '',
style_map: data.map || data.style_map || {},
};
callerModelAssignments = data.assignments || {};
updateCallerModelUI();
updateCallerModelBadges();
} catch (e) {
console.error('Failed to load caller models:', e);
}
}
function updateCallerModelUI() {
const strategyEl = document.getElementById('cm-strategy');
if (strategyEl) strategyEl.value = callerModelSettings.strategy;
const poolSection = document.getElementById('cm-pool-section');
const styleMap = document.getElementById('cm-style-map');
if (poolSection) poolSection.classList.toggle('hidden', callerModelSettings.strategy === 'single');
if (styleMap) styleMap.classList.toggle('hidden', callerModelSettings.strategy !== 'style_matched');
const poolInput = document.getElementById('cm-pool');
if (poolInput) poolInput.value = callerModelSettings.pool.join(', ');
// Populate style map grid
const grid = document.getElementById('cm-style-grid');
if (grid && callerModelSettings.strategy === 'style_matched') {
grid.innerHTML = '';
for (const style of CALLER_STYLES) {
const item = document.createElement('div');
item.className = 'cm-style-item';
const label = style.replace(/_/g, ' ');
item.innerHTML = `<span class="cm-style-name">${label}</span>`;
const sel = document.createElement('select');
sel.className = 'cm-style-select';
sel.dataset.style = style;
const models = window._openrouterModels || callerModelSettings.pool;
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
if (m === callerModelSettings.style_map[style]) opt.selected = true;
sel.appendChild(opt);
}
item.appendChild(sel);
grid.appendChild(item);
}
}
// Fallback dropdown
const fallbackEl = document.getElementById('cm-fallback');
if (fallbackEl) {
const currentVal = fallbackEl.value;
fallbackEl.innerHTML = '';
const models = callerModelSettings.pool.length > 0
? callerModelSettings.pool
: (window._openrouterModels || []);
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
if (m === callerModelSettings.fallback) opt.selected = true;
fallbackEl.appendChild(opt);
}
if (!fallbackEl.value && currentVal) fallbackEl.value = currentVal;
}
}
function updateCallerModelBadges() {
document.querySelectorAll('.caller-btn').forEach(btn => {
const key = btn.dataset.key;
const model = callerModelAssignments[key];
let tag = btn.querySelector('.model-tag');
if (model) {
if (!tag) {
tag = document.createElement('span');
tag.className = 'model-tag';
btn.appendChild(tag);
}
tag.textContent = modelAbbrev(model);
tag.title = model;
} else if (tag) {
tag.remove();
}
});
}
function showCallerModelBadge(model) {
const badge = document.getElementById('caller-model-badge');
if (badge) {
badge.textContent = model ? `via ${modelAbbrev(model)}` : '';
badge.title = model || '';
badge.classList.toggle('hidden', !model);
}
}
function populateCallerModelOverride() {
const sel = document.getElementById('caller-model-override');
if (!sel) return;
sel.innerHTML = '';
const models = window._openrouterModels || [];
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m.split('/').pop();
sel.appendChild(opt);
}
}
async function overrideCallerModel(callerKey, modelId) {
try {
const res = await fetch(`/api/caller-models/${callerKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: modelId })
});
if (!res.ok) throw new Error(res.status);
callerModelAssignments[callerKey] = modelId;
showCallerModelBadge(modelId);
updateCallerModelBadges();
log(`Model override: ${currentCaller?.name || callerKey}${modelAbbrev(modelId)}`);
} catch (err) {
log('Model override failed: ' + err.message);
}
}
async function saveCallerModels() {
const strategy = document.getElementById('cm-strategy')?.value || 'single';
const poolRaw = document.getElementById('cm-pool')?.value || '';
const pool = poolRaw.split(',').map(s => s.trim()).filter(Boolean);
const fallback = document.getElementById('cm-fallback')?.value || '';
const style_map = {};
document.querySelectorAll('.cm-style-select').forEach(sel => {
if (sel.value) style_map[sel.dataset.style] = sel.value;
});
try {
const res = await fetch('/api/caller-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strategy, pool, fallback, map: style_map })
});
if (!res.ok) throw new Error(res.status);
callerModelSettings = { strategy, pool, fallback, style_map };
} catch (err) {
log('Caller model save failed: ' + err.message);
}
}
// --- Settings ---
async function loadSettings() {
try {
@@ -1675,7 +1449,6 @@ async function loadSettings() {
// Category model routing
const models = data.available_openrouter_models || [];
window._openrouterModels = models;
populateCallerModelOverride();
const categoryModels = data.category_models || {};
const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary'];
for (const cat of categories) {
@@ -1709,9 +1482,6 @@ async function saveSettings() {
// Save audio devices
await saveAudioDevices();
// Save caller model routing
await saveCallerModels();
// Collect category model routing
const categoryModels = {};
const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary'];
@@ -2431,151 +2201,3 @@ async function dismissDevonSuggestion() {
}
// --- Preflight ---
const PREFLIGHT_STATUS_ICONS = { pass: '✓', warn: '⚠', fail: '✗', skip: '—' };
const PREFLIGHT_CHECK_NAMES = {
model_diversity: 'Model Diversity',
theme_penetration: 'Theme Penetration',
voice_age_alignment: 'Voice-Age Alignment',
response_coherence: 'Response Coherence',
};
async function runPreflight(testResponses) {
const statusEl = document.getElementById('preflight-status');
const checksEl = document.getElementById('preflight-checks');
const testBtn = document.getElementById('preflight-test-btn');
statusEl.className = 'preflight-status loading';
statusEl.querySelector('.preflight-status-icon').textContent = '...';
statusEl.querySelector('.preflight-status-text').textContent = 'Running checks...';
checksEl.innerHTML = '';
if (testResponses && testBtn) testBtn.classList.add('loading');
try {
const url = '/api/show/preflight' + (testResponses ? '?test_responses=true' : '');
const data = await safeFetch(url, {}, 120000);
renderPreflightResults(data, statusEl, checksEl);
} catch (err) {
statusEl.className = 'preflight-status fail';
statusEl.querySelector('.preflight-status-icon').textContent = '✗';
statusEl.querySelector('.preflight-status-text').textContent = 'Error: ' + err.message;
} finally {
if (testBtn) testBtn.classList.remove('loading');
}
}
function renderPreflightResults(data, statusEl, checksEl) {
const overall = data.status || 'pass';
statusEl.className = 'preflight-status ' + overall;
statusEl.querySelector('.preflight-status-icon').textContent = PREFLIGHT_STATUS_ICONS[overall] || '✓';
statusEl.querySelector('.preflight-status-text').textContent =
overall === 'pass' ? 'All checks passed' :
overall === 'warn' ? 'Passed with warnings' : 'Issues found';
checksEl.innerHTML = '';
const checksObj = data.checks || {};
for (const [checkKey, check] of Object.entries(checksObj)) {
const card = document.createElement('div');
card.className = 'preflight-check';
const status = check.status || 'skip';
const name = PREFLIGHT_CHECK_NAMES[checkKey] || checkKey;
card.innerHTML = `
<div class="preflight-check-header">
<span class="preflight-check-name">${escapeHtml(name)}</span>
<span class="preflight-check-badge ${status}">${status.toUpperCase()}</span>
</div>
<div class="preflight-check-details">${renderCheckDetails(checkKey, check)}</div>
`;
card.querySelector('.preflight-check-header').addEventListener('click', () => {
card.classList.toggle('open');
});
checksEl.appendChild(card);
}
}
function renderCheckDetails(name, check) {
const d = check.details || {};
switch (name) {
case 'model_diversity': return renderModelDiversity(d);
case 'theme_penetration': return renderThemePenetration(d);
case 'voice_age_alignment': return renderVoiceAgeAlignment(d);
case 'response_coherence': return renderResponseCoherence(check);
default: return `<pre>${escapeHtml(JSON.stringify(d, null, 2))}</pre>`;
}
}
function renderModelDiversity(d) {
const callers = d.callers || [];
if (!callers.length) return '<p>No callers to check.</p>';
let html = `<table class="preflight-table">
<thead><tr><th>Caller</th><th>Style</th><th>Model</th></tr></thead><tbody>`;
for (const c of callers) {
html += `<tr><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.style || '')}</td><td>${escapeHtml(c.model || '')}</td></tr>`;
}
html += '</tbody></table>';
if (d.max_same_model_pct != null) {
html += `<p style="margin-top:8px">${d.max_same_model_pct}% on same model</p>`;
}
return html;
}
function renderThemePenetration(d) {
let html = '';
if (d.theme) html += `<p><strong>Theme:</strong> ${escapeHtml(d.theme)}</p>`;
if (d.connected?.length) {
html += `<p style="color:var(--accent-green);margin-top:6px">Connected: ${d.connected.map(n => escapeHtml(n)).join(', ')}</p>`;
}
if (d.not_connected?.length) {
html += `<p style="color:var(--text-muted);margin-top:4px">Not connected: ${d.not_connected.map(n => escapeHtml(n)).join(', ')}</p>`;
}
if (d.penetration_pct != null) {
html += `<p style="margin-top:6px">${d.penetration_pct}% penetration</p>`;
}
return html || '<p>No theme set.</p>';
}
function renderVoiceAgeAlignment(d) {
const callers = d.callers || [];
if (!callers.length) return '<p>No callers to check.</p>';
let html = `<table class="preflight-table">
<thead><tr><th>Caller</th><th>Age</th><th>Voice</th><th>Age Feel</th></tr></thead><tbody>`;
for (const c of callers) {
const cls = c.mismatch ? ' class="mismatch"' : '';
html += `<tr${cls}><td>${escapeHtml(c.name || '')}</td><td>${c.age || ''}</td><td>${escapeHtml(c.voice || '')}</td><td>${escapeHtml(c.age_feel || '')}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
function renderResponseCoherence(check) {
if (check.status === 'skip') {
return '<p>Use <strong>Test Responses</strong> button to run this check.</p>';
}
const d = check.details || {};
const results = d.results || [];
if (!results.length) return '<p>No test results.</p>';
let html = `<table class="preflight-table">
<thead><tr><th>Caller</th><th>Model</th><th>R1</th><th>R2</th><th>Avg</th><th></th></tr></thead><tbody>`;
for (const c of results) {
const cls = c.pass ? '' : ' class="mismatch"';
if (c.error) {
html += `<tr class="mismatch"><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.model || '')}</td><td colspan="3">${escapeHtml(c.error)}</td><td>✗</td></tr>`;
} else {
html += `<tr${cls}><td>${escapeHtml(c.name || '')}</td><td>${escapeHtml(c.model || '')}</td><td>${c.r1_words || 0}</td><td>${c.r2_words || 0}</td><td>${c.word_count || 0}</td><td>${c.pass ? '✓' : '✗'}</td></tr>`;
if (c.snippet) {
html += `<tr><td colspan="6" style="color:var(--text-muted);font-size:0.75rem;padding-left:16px">${escapeHtml(c.snippet)}</td></tr>`;
}
}
}
html += '</tbody></table>';
const passed = results.filter(r => r.pass).length;
html += `<p style="margin-top:8px">${passed}/${results.length} callers passed (min ${50} words per response)</p>`;
return html;
}