Changeset 0.10.2 (#58)
This commit is contained in:
@@ -829,6 +829,40 @@ a:hover { text-decoration: underline; }
|
||||
.context-warning-text { flex: 1; }
|
||||
.context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; }
|
||||
.context-warning-dismiss:hover { color: var(--text-1); }
|
||||
.context-warning-action {
|
||||
flex-shrink: 0; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--bg-surface); color: var(--text-1); cursor: pointer;
|
||||
font-size: 0.75rem; white-space: nowrap; transition: all var(--transition);
|
||||
}
|
||||
.context-warning-action:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.context-warning-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text-1); border-color: var(--border); }
|
||||
/* Summary message node */
|
||||
.message-summary {
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
border-radius: 8px; padding: 10px 14px; margin: 4px 0;
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
font-size: 0.85rem; position: relative;
|
||||
}
|
||||
.message-summary .summary-header {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
|
||||
font-size: 0.75rem; color: var(--text-3); font-weight: 500;
|
||||
}
|
||||
.message-summary .summary-toggle {
|
||||
background: none; border: none; color: var(--accent); cursor: pointer;
|
||||
font-size: 0.75rem; padding: 0; text-decoration: underline;
|
||||
}
|
||||
.message-summary .summary-toggle:hover { color: var(--text-1); }
|
||||
.msg-dimmed { opacity: 0.5; }
|
||||
.message-summary-divider {
|
||||
text-align: center; padding: 8px 0; margin: 4px 0;
|
||||
}
|
||||
.message-summary-divider .summary-toggle {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
font-size: 0.78rem; padding: 4px 12px; border-radius: 4px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.message-summary-divider .summary-toggle:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||
.input-wrap {
|
||||
max-width: 768px; margin: 0 auto;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
<div class="context-warning" id="contextWarning" style="display:none">
|
||||
<span class="context-warning-icon">⚠️</span>
|
||||
<span class="context-warning-text" id="contextWarningText"></span>
|
||||
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
|
||||
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss">✕</button>
|
||||
</div>
|
||||
<div class="input-wrap">
|
||||
@@ -336,6 +337,7 @@
|
||||
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
||||
<button class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')" id="settingsPersonasTabBtn">Personas</button>
|
||||
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')" id="settingsUsageTabBtn">Usage</button>
|
||||
<button class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" id="settingsRolesTabBtn">Model Roles</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- General Tab -->
|
||||
@@ -458,6 +460,17 @@
|
||||
<div id="myUsageResults"></div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Model Roles Tab (BYOK users — personal role overrides) -->
|
||||
<div class="settings-tab-content" id="settingsRolesTab" style="display:none">
|
||||
<section class="settings-section">
|
||||
<h3 style="font-size:14px;margin-bottom:4px">Personal Model Roles</h3>
|
||||
<p class="section-hint" style="margin-bottom:8px">Override the org's default utility and embedding models with your own providers. Uses your personal API keys — no org cost.</p>
|
||||
<div id="userRolesConfig"></div>
|
||||
</section>
|
||||
<div id="userRolesDisabled" class="settings-notice" style="display:none">
|
||||
<span>ℹ️</span> Add a personal provider first to configure role overrides.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
||||
</div>
|
||||
|
||||
243
src/js/__tests__/auth-resilience.test.js
Normal file
243
src/js/__tests__/auth-resilience.test.js
Normal 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.
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
201
src/js/app.js
201
src/js/app.js
@@ -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();
|
||||
|
||||
151
src/js/ui.js
151
src/js/ui.js
@@ -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 `
|
||||
|
||||
Reference in New Issue
Block a user