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

@@ -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();