Changeset 0.10.2 (#58)
This commit is contained in:
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();
|
||||
|
||||
Reference in New Issue
Block a user