Changeset 0.10.2 (#58)

This commit is contained in:
2026-02-24 20:29:08 +00:00
parent 13772ebd6c
commit 4061e4a145
20 changed files with 1223 additions and 41 deletions

View File

@@ -0,0 +1,243 @@
// ==========================================
// Auth Resilience Tests
// ==========================================
// Validates that auth failure during startup
// results in a redirect to login, NOT a broken
// main UI with 401 errors everywhere.
//
// Bug: Profile returned 200 (token on edge of
// expiry), startApp() proceeded, then every
// subsequent call 401'd. User saw broken UI
// instead of login screen.
//
// Run: node --test src/js/__tests__/auth-resilience.test.js
// ==========================================
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
// ── Auth state model ────────────────────────
describe('Auth state transitions', () => {
// Simulates the API client's auth lifecycle
function makeAuthState() {
return {
accessToken: null,
refreshToken: null,
get isAuthed() { return !!this.accessToken; },
clearTokens() { this.accessToken = null; this.refreshToken = null; },
setTokens(access, refresh) { this.accessToken = access; this.refreshToken = refresh; },
};
}
it('isAuthed reflects token presence', () => {
const auth = makeAuthState();
assert.equal(auth.isAuthed, false);
auth.setTokens('tok_access', 'tok_refresh');
assert.equal(auth.isAuthed, true);
});
it('clearTokens makes isAuthed false', () => {
const auth = makeAuthState();
auth.setTokens('tok_access', 'tok_refresh');
assert.equal(auth.isAuthed, true);
auth.clearTokens();
assert.equal(auth.isAuthed, false);
});
it('expired token string is still truthy (the edge case)', () => {
const auth = makeAuthState();
// An expired JWT is still a non-empty string — isAuthed returns true
// This is BY DESIGN: the server validates, not the client
const expiredJWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.fake';
auth.setTokens(expiredJWT, 'tok_refresh');
assert.equal(auth.isAuthed, true, 'expired JWT is still truthy');
});
});
// ── Startup auth guard ──────────────────────
describe('startApp auth guard', () => {
// This models the critical startup sequence:
// init():
// if (isAuthed) → getProfile() → if ok → startApp()
// startApp():
// loadSettings() → loadChats() → fetchModels()
// *** AUTH GUARD CHECK HERE ***
// if (!isAuthed) → showSplash, return
// else → render UI
it('proceeds when auth is valid throughout startup', () => {
let splashShown = false;
let uiRendered = false;
const auth = { isAuthed: true };
// Simulate: loadChats succeeds, auth stays valid
const loadChats = () => { /* no 401 */ };
const fetchModels = () => { /* no 401 */ };
// startApp logic
loadChats();
fetchModels();
if (!auth.isAuthed) {
splashShown = true;
} else {
uiRendered = true;
}
assert.equal(uiRendered, true, 'UI should render when auth is valid');
assert.equal(splashShown, false);
});
it('returns to login when auth is lost during startup', () => {
let splashShown = false;
let uiRendered = false;
const auth = {
accessToken: 'tok',
get isAuthed() { return !!this.accessToken; },
clearTokens() { this.accessToken = null; },
};
// Simulate: loadChats gets 401, refresh fails, clearTokens called
const loadChats = () => {
// _authed → 401 → refresh() → fails → clearTokens()
auth.clearTokens();
};
const fetchModels = () => { /* also 401 but tokens already cleared */ };
loadChats();
fetchModels();
// THE FIX: guard check after critical loads
if (!auth.isAuthed) {
splashShown = true;
} else {
uiRendered = true;
}
assert.equal(splashShown, true, 'must return to login when auth lost');
assert.equal(uiRendered, false, 'must NOT render UI when auth lost');
});
it('detects auth loss even when only one call triggers it', () => {
const auth = {
accessToken: 'tok',
get isAuthed() { return !!this.accessToken; },
clearTokens() { this.accessToken = null; },
};
let splashShown = false;
// loadSettings succeeds (token barely valid)
// loadChats triggers 401 → clearTokens
const loadSettings = () => { /* ok */ };
const loadChats = () => { auth.clearTokens(); };
const fetchModels = () => { /* tokens already gone, silently fails */ };
loadSettings();
loadChats();
fetchModels();
if (!auth.isAuthed) splashShown = true;
assert.equal(splashShown, true, 'single 401 during startup must trigger login redirect');
});
});
// ── 401 retry + refresh behavior ─────────────
describe('401 retry with refresh', () => {
it('successful refresh preserves auth', () => {
const auth = {
accessToken: 'expired',
refreshToken: 'valid_refresh',
get isAuthed() { return !!this.accessToken; },
clearTokens() { this.accessToken = null; this.refreshToken = null; },
refresh() {
// Simulate successful refresh
this.accessToken = 'new_valid_token';
return true;
},
};
// _authed: first call 401, refresh succeeds, retry succeeds
const result401 = { status: 401 };
if (result401.status === 401 && auth.refreshToken) {
auth.refresh();
}
assert.equal(auth.isAuthed, true, 'auth preserved after successful refresh');
assert.equal(auth.accessToken, 'new_valid_token');
});
it('failed refresh clears auth', () => {
const auth = {
accessToken: 'expired',
refreshToken: 'also_expired',
get isAuthed() { return !!this.accessToken; },
clearTokens() { this.accessToken = null; this.refreshToken = null; },
refresh() {
// Simulate failed refresh (401 on refresh endpoint)
this.clearTokens();
return false;
},
};
const result401 = { status: 401 };
if (result401.status === 401 && auth.refreshToken) {
auth.refresh();
}
assert.equal(auth.isAuthed, false, 'auth cleared after failed refresh');
});
});
// ── Init flow: profile check ─────────────────
describe('init() profile gate', () => {
it('clears tokens when profile returns 401', () => {
const auth = {
accessToken: 'expired',
refreshToken: 'expired_too',
get isAuthed() { return !!this.accessToken; },
clearTokens() { this.accessToken = null; this.refreshToken = null; },
};
// Simulate getProfile throwing (as _authed does after failed refresh)
const profileFailed = true;
if (profileFailed) auth.clearTokens();
assert.equal(auth.isAuthed, false, 'tokens cleared on profile failure');
});
it('profile success + subsequent 401 = the edge case bug', () => {
// This is the exact scenario from the bug report:
// 1. Profile returns 200 (token barely valid, 5ms)
// 2. init() proceeds to startApp()
// 3. loadChats() returns 401 (token expired in the 200ms gap)
// 4. Without the guard, user sees broken UI
const auth = {
accessToken: 'barely_valid',
get isAuthed() { return !!this.accessToken; },
clearTokens() { this.accessToken = null; },
};
// Profile succeeds (token still valid)
const profileOk = true;
assert.equal(profileOk, true);
assert.equal(auth.isAuthed, true, 'auth valid after profile');
// startApp: loadChats fails, clears tokens
auth.clearTokens();
// THE GUARD: must catch this
assert.equal(auth.isAuthed, false, 'auth gone after loadChats 401');
// Without guard: UI renders. With guard: splash shown.
});
});

View File

@@ -27,6 +27,8 @@ const API = {
this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null;
this.user = saved.user || null;
// Unknown age — refresh soon to get a fresh token
if (this.refreshToken) this._scheduleRefresh(60);
}
} catch (e) { /* corrupt storage */ }
},
@@ -44,6 +46,7 @@ const API = {
this.refreshToken = null;
this.user = null;
localStorage.removeItem(_storageKey);
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
},
get isAuthed() { return !!this.accessToken; },
@@ -116,6 +119,20 @@ const API = {
this.refreshToken = data.refresh_token;
this.user = data.user;
this.saveTokens();
this._scheduleRefresh(data.expires_in || 900);
},
_refreshTimer: null,
_scheduleRefresh(expiresIn) {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
// Refresh at 80% of expiry (e.g., 12min for 15min token)
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
this._refreshTimer = setTimeout(async () => {
if (!this.refreshToken) return;
console.debug('🔄 Proactive token refresh');
await this.refresh();
}, ms);
},
// ── Channels ──────────────────────────────
@@ -202,6 +219,11 @@ const API = {
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
},
// ── Summarize & Continue ────────────────
summarizeChannel(channelId) {
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
},
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {

View File

@@ -108,6 +108,7 @@ function updateInputTokens() {
function updateContextWarning() {
const warning = document.getElementById('contextWarning');
const text = document.getElementById('contextWarningText');
const summarizeBtn = document.getElementById('summarizeBtn');
if (!warning || !text) return;
const budget = Tokens.getContextBudget();
@@ -125,10 +126,16 @@ function updateContextWarning() {
const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt);
const pct = convTokens / budget.maxContext;
// Show summarize button at ≥75% context usage (if enough messages)
const showSummarize = pct >= 0.75 && chat.messages.length >= 4;
if (summarizeBtn) {
summarizeBtn.style.display = showSummarize ? 'inline-block' : 'none';
}
if (pct >= 0.9 && !Tokens._warningDismissed) {
warning.style.display = 'flex';
warning.className = 'context-warning danger';
text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context. Consider starting a new chat.`;
text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context.`;
} else if (pct >= 0.75 && !Tokens._warningDismissed) {
warning.style.display = 'flex';
warning.className = 'context-warning';
@@ -144,6 +151,54 @@ function dismissContextWarning() {
if (el) el.style.display = 'none';
}
// ── Summarize & Continue ──────────────────
async function summarizeAndContinue() {
const channelId = App.currentChatId;
if (!channelId) return;
const btn = document.getElementById('summarizeBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Summarizing…';
}
try {
const result = await API.summarizeChannel(channelId);
UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success');
// Reload the active path to get the updated message tree
const resp = await API.getActivePath(channelId);
const chat = App.chats.find(c => c.id === channelId);
if (chat && resp) {
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
UI.renderMessages(chat.messages);
updateContextWarning();
updateInputTokens();
}
} catch (err) {
console.error('Summarize failed:', err);
UI.toast(err.message || 'Summarization failed', 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '📝 Summarize & Continue';
}
}
}
async function init() {
console.log('🔀 Chat Switchboard initializing...');
@@ -201,6 +256,15 @@ async function startApp() {
await loadSettings();
await loadChats();
await fetchModels();
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
// kick back to login instead of rendering a broken UI.
if (!API.isAuthed) {
console.warn('⚠️ Auth lost during startup, returning to login');
showSplash(null);
return;
}
await initBanners();
UI.renderChatList();
UI.updateModelSelector();
@@ -385,6 +449,7 @@ async function selectChat(chatId) {
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
@@ -394,6 +459,9 @@ async function selectChat(chatId) {
}
}
// Restore the last-used model/preset for this chat
_restoreChatModel(chatId, chat);
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
Tokens._warningDismissed = false;
@@ -401,6 +469,66 @@ async function selectChat(chatId) {
updateInputTokens();
}
// ── Per-Chat Model/Preset Persistence ────────
// BANDAID: localStorage — doesn't roam across devices.
// TODO: move to channel.settings.last_selector_id (server-side). See ROADMAP TBD.
function _saveChatModel(chatId) {
if (!chatId) return;
const selectorId = UI.getModelValue();
if (!selectorId) return;
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
map[chatId] = selectorId;
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* quota exceeded, etc */ }
}
function _restoreChatModel(chatId, chat) {
// Priority: stored selector ID → channel base model → keep current
let targetId = null;
// 1. Check localStorage for exact selector ID (handles presets)
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
if (map[chatId]) targetId = map[chatId];
} catch (e) { /* */ }
// 2. Verify the stored ID still exists in available models
if (targetId) {
const found = App.models.find(m => m.id === targetId);
if (found) {
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
return;
}
// Stored model removed — clean up stale entry
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
}
// 3. Fall back to channel's base model ID (match by baseModelId)
if (chat?.model) {
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
return;
}
}
// 4. Stored model gone — fall back to admin default → first visible
const visible = App.models.filter(m => !m.hidden);
const def = App.defaultModel
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
: null;
const fallback = def || visible[0];
if (fallback) {
UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : ''));
}
}
async function newChat() {
App.currentChatId = null;
UI.renderChatList();
@@ -422,6 +550,12 @@ async function deleteChat(chatId) {
try {
await API.deleteChannel(chatId);
App.chats = App.chats.filter(c => c.id !== chatId);
// Clean up stored model preference
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
UI.renderChatList();
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
@@ -472,6 +606,9 @@ async function sendMessage() {
// Reload active path from server to get proper IDs and sibling metadata
await reloadActivePath();
UI.showRegenerate(true);
// Persist the model/preset selection for this chat
_saveChatModel(App.currentChatId);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
@@ -518,6 +655,7 @@ async function reloadActivePath() {
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
@@ -701,6 +839,8 @@ async function switchSibling(messageId, direction) {
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
@@ -2277,6 +2417,65 @@ var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
// ── User Model Role Functions ─────────────────
async function userRoleProviderChanged(role) {
const provSel = document.getElementById(`userRole-${role}-provider`);
const modelSel = document.getElementById(`userRole-${role}-model`);
if (!provSel || !modelSel) return;
const providerId = provSel.value;
modelSel.innerHTML = '<option value="">— select model —</option>';
if (!providerId) return;
// Load models for the selected provider (App.models uses configId, not provider_config_id)
const allModels = App.models || [];
const provModels = allModels.filter(m => m.configId === providerId);
provModels.forEach(m => {
modelSel.innerHTML += `<option value="${m.baseModelId}">${esc(m.name || m.baseModelId)}</option>`;
});
}
async function saveUserRole(role) {
const provSel = document.getElementById(`userRole-${role}-provider`);
const modelSel = document.getElementById(`userRole-${role}-model`);
if (!provSel || !modelSel) return;
const providerId = provSel.value;
const modelId = modelSel.value;
if (!providerId || !modelId) {
UI.toast('Select both a provider and model', 'warning');
return;
}
try {
const settings = await API.getSettings();
const roles = settings.model_roles || {};
roles[role] = { primary: { provider_config_id: providerId, model_id: modelId } };
await API.updateSettings({ model_roles: roles });
UI.toast(`${role} role override saved`, 'success');
UI.loadUserRoles();
} catch (e) {
UI.toast(e.message || 'Failed to save role', 'error');
}
}
async function clearUserRole(role) {
try {
const settings = await API.getSettings();
const roles = settings.model_roles || {};
delete roles[role];
await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null });
UI.toast(`${role} role override removed — using org default`, 'success');
UI.loadUserRoles();
} catch (e) {
UI.toast(e.message || 'Failed to clear role', 'error');
}
}
// ── Notes ─────────────────────────────────────
async function openNotes() {
openSidePanel('notes');
_exitSelectMode();

View File

@@ -164,6 +164,26 @@ function renderPresetForm(containerEl, options = {}) {
return form;
}
// ── Summary Message Helpers ─────────────────
function _isSummaryMessage(msg) {
if (!msg.metadata) return false;
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
return meta?.type === 'summary';
}
function toggleSummarizedHistory() {
const el = document.getElementById('summarizedHistory');
const btn = document.querySelector('.message-summary-divider .summary-toggle');
if (!el) return;
const show = el.style.display === 'none';
el.style.display = show ? 'block' : 'none';
if (btn) {
const count = el.querySelectorAll('.message').length;
btn.textContent = show ? `▾ Hide ${count} earlier messages` : `${count} earlier messages summarized`;
}
}
const UI = {
// ── Sidebar ──────────────────────────────
@@ -271,15 +291,64 @@ const UI = {
return;
}
el.innerHTML = messages
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i))
.join('');
// Find the most recent summary boundary
let summaryIdx = -1;
for (let i = 0; i < messages.length; i++) {
if (_isSummaryMessage(messages[i])) summaryIdx = i;
}
// Render: if summary exists, show collapsed-history toggle + summary + messages after
let html = '';
if (summaryIdx >= 0) {
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;
if (hiddenCount > 0) {
html += `<div class="message-summary-divider" id="summaryDivider">
<button class="summary-toggle" onclick="toggleSummarizedHistory()">
${hiddenCount} earlier messages summarized
</button>
</div>
<div id="summarizedHistory" style="display:none">
${messages.slice(0, summaryIdx)
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i, true))
.join('')}
</div>`;
}
// Render summary node
html += this._summaryHTML(messages[summaryIdx]);
// Render messages after summary
html += messages.slice(summaryIdx + 1)
.filter(m => m.role !== 'system' && !_isSummaryMessage(m))
.map((m, i) => this._messageHTML(m, summaryIdx + 1 + i))
.join('');
} else {
html = messages
.filter(m => m.role !== 'system')
.map((m, i) => this._messageHTML(m, i))
.join('');
}
el.innerHTML = html;
this._scrollToBottom(true);
},
_messageHTML(msg, index) {
_summaryHTML(msg) {
const meta = msg.metadata || {};
const count = meta.summarized_count || '?';
const model = meta.utility_model || 'utility model';
return `
<div class="message-summary" data-msg-id="${esc(msg.id || '')}">
<div class="summary-header">
<span>📝 Conversation summary (${count} messages, by ${esc(model)})</span>
</div>
<div class="msg-text">${formatMessage(msg.content)}</div>
</div>`;
},
_messageHTML(msg, index, dimmed) {
const isUser = msg.role === 'user';
// Skip summary messages in normal rendering — they get _summaryHTML
if (_isSummaryMessage(msg)) return '';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
let assistantLabel = 'Assistant';
if (!isUser) {
@@ -311,7 +380,7 @@ const UI = {
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
return `
<div class="message ${msg.role}" data-msg-id="${msgId}">
<div class="message ${msg.role}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
<div class="msg-inner">
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
<div class="msg-body">
@@ -846,6 +915,7 @@ const UI = {
if (tab === 'models') UI.loadUserModels();
if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
if (tab === 'usage') UI.loadMyUsage();
if (tab === 'roles') UI.loadUserRoles();
if (tab === 'appearance') UI.loadAppearanceSettings();
},
@@ -901,12 +971,14 @@ const UI = {
const addBtn = document.getElementById('providerShowAddBtn');
const tabBtn = document.getElementById('settingsProvidersTabBtn');
const usageTabBtn = document.getElementById('settingsUsageTabBtn');
const rolesTabBtn = document.getElementById('settingsRolesTabBtn');
if (!notice) return;
const allowed = App.policies?.allow_user_byok === 'true';
notice.style.display = allowed ? 'none' : '';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none';
if (rolesTabBtn) rolesTabBtn.style.display = allowed ? '' : 'none';
},
checkUserPresetsAllowed() {
@@ -1193,6 +1265,71 @@ const UI = {
_teamAuditPage: 1,
_teamAuditPerPage: 20,
// ── User Model Roles (BYOK overrides) ──
async loadUserRoles() {
const el = document.getElementById('userRolesConfig');
const notice = document.getElementById('userRolesDisabled');
const tabBtn = document.getElementById('settingsRolesTabBtn');
if (!el) return;
// Only show if BYOK is enabled
const byokAllowed = App.policies?.allow_user_byok === 'true';
if (tabBtn) tabBtn.style.display = byokAllowed ? '' : 'none';
if (!byokAllowed) return;
try {
// Get user's personal providers and their models
const configs = await API.listConfigs();
const configList = configs.configs || configs.data || [];
const personalProviders = configList.filter(c => c.scope === 'personal');
if (personalProviders.length === 0) {
el.style.display = 'none';
if (notice) notice.style.display = '';
return;
}
el.style.display = '';
if (notice) notice.style.display = 'none';
// Load all models for personal providers
const allModels = App.models || [];
const personalModels = allModels.filter(m =>
personalProviders.some(p => p.id === m.configId)
);
// Load current user settings to get existing role overrides
const settings = await API.getSettings();
const userRoles = settings.model_roles || {};
const roleNames = ['utility', 'embedding'];
el.innerHTML = roleNames.map(role => {
const cfg = userRoles[role] || { primary: null };
return `
<div class="settings-section" style="margin-bottom:12px">
<h4 style="font-size:13px;margin:0 0 6px;text-transform:capitalize">${role}
<span class="form-hint">${role === 'utility' ? '(summarization, title gen)' : '(future: KB search, note search)'}</span>
</h4>
<div class="form-row" style="gap:8px;align-items:center">
<label style="min-width:60px;font-size:12px">Provider</label>
<select id="userRole-${role}-provider" onchange="userRoleProviderChanged('${role}')" style="min-width:140px;font-size:12px">
<option value="">— use org default —</option>
${personalProviders.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="userRole-${role}-model" style="min-width:200px;font-size:12px">
<option value="">— select model —</option>
${cfg.primary ? personalModels.filter(m => m.configId === cfg.primary.provider_config_id).map(m => `<option value="${m.baseModelId}" ${m.baseModelId === cfg.primary.model_id ? 'selected' : ''}>${esc(m.name || m.baseModelId)}</option>`).join('') : ''}
</select>
<button class="btn-small" onclick="saveUserRole('${role}')" style="font-size:11px">Save</button>
${cfg.primary ? `<button class="btn-small btn-danger" onclick="clearUserRole('${role}')" style="font-size:11px" title="Remove override, use org default">Clear</button>` : ''}
</div>
</div>`;
}).join('');
} catch (e) {
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
}
},
async loadTeamAuditLog(page) {
const teamId = UI._managingTeamId;
if (!teamId) return;
@@ -1478,7 +1615,7 @@ const UI = {
const models = await API.adminListModels();
const modelList = models.models || models.data || models || [];
const roleNames = ['utility', 'embedding', 'generation'];
const roleNames = ['utility', 'embedding'];
el.innerHTML = roleNames.map(role => {
const cfg = roles[role] || { primary: null, fallback: null };
return `