Changeset 0.9.1 (#51)
This commit is contained in:
219
src/js/app.js
219
src/js/app.js
@@ -32,7 +32,118 @@ const App = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Init ─────────────────────────────────────
|
||||
// ── Token Estimation + Context Tracking ─────
|
||||
|
||||
const Tokens = {
|
||||
// Rough heuristic: ~4 chars per token for English (GPT/Claude average).
|
||||
// Not exact, but good enough for a UI indicator.
|
||||
estimate(text) {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
},
|
||||
|
||||
// Estimate tokens for the full conversation context sent to the model
|
||||
estimateConversation(messages, systemPrompt) {
|
||||
let total = 0;
|
||||
// System prompt
|
||||
if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters
|
||||
// Messages
|
||||
for (const m of messages) {
|
||||
total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters)
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// Get context budget for current model
|
||||
getContextBudget() {
|
||||
const caps = UI.getSelectedModelCaps();
|
||||
return {
|
||||
maxContext: caps.max_context || 0,
|
||||
maxOutput: caps.max_output_tokens || 0,
|
||||
};
|
||||
},
|
||||
|
||||
// Format token count for display
|
||||
format(n) {
|
||||
if (n >= 100000) return (n / 1000).toFixed(0) + 'K';
|
||||
if (n >= 10000) return (n / 1000).toFixed(1) + 'K';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||
return String(n);
|
||||
},
|
||||
|
||||
_warningDismissed: false,
|
||||
};
|
||||
|
||||
// Update the token counter below the input
|
||||
function updateInputTokens() {
|
||||
const el = document.getElementById('inputTokenCount');
|
||||
if (!el) return;
|
||||
|
||||
const input = document.getElementById('messageInput');
|
||||
const inputText = input?.value || '';
|
||||
const inputTokens = Tokens.estimate(inputText);
|
||||
|
||||
if (!inputText.trim()) {
|
||||
el.textContent = '';
|
||||
el.className = 'input-token-count';
|
||||
return;
|
||||
}
|
||||
|
||||
const budget = Tokens.getContextBudget();
|
||||
if (budget.maxContext > 0) {
|
||||
// Show relative to available context
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
|
||||
const totalWithInput = convTokens + inputTokens;
|
||||
const pct = totalWithInput / budget.maxContext;
|
||||
el.textContent = `~${Tokens.format(inputTokens)} tokens · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`;
|
||||
el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
|
||||
} else {
|
||||
el.textContent = `~${Tokens.format(inputTokens)} tokens`;
|
||||
el.className = 'input-token-count';
|
||||
}
|
||||
}
|
||||
|
||||
// Check conversation length and show/hide warning
|
||||
function updateContextWarning() {
|
||||
const warning = document.getElementById('contextWarning');
|
||||
const text = document.getElementById('contextWarningText');
|
||||
if (!warning || !text) return;
|
||||
|
||||
const budget = Tokens.getContextBudget();
|
||||
if (budget.maxContext <= 0) {
|
||||
warning.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat || !chat.messages?.length) {
|
||||
warning.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt);
|
||||
const pct = convTokens / budget.maxContext;
|
||||
|
||||
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.`;
|
||||
} else if (pct >= 0.75 && !Tokens._warningDismissed) {
|
||||
warning.style.display = 'flex';
|
||||
warning.className = 'context-warning';
|
||||
text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`;
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function dismissContextWarning() {
|
||||
Tokens._warningDismissed = true;
|
||||
const el = document.getElementById('contextWarning');
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
async function init() {
|
||||
console.log('🔀 Chat Switchboard initializing...');
|
||||
@@ -45,7 +156,24 @@ async function init() {
|
||||
console.log('✅ Backend reachable:', health.version);
|
||||
} catch (e) {
|
||||
console.error('❌ Backend unreachable:', e.message);
|
||||
document.getElementById('splashError').textContent = 'Cannot reach server — check connection';
|
||||
const splashErr = document.getElementById('splashError');
|
||||
if (e.proxyBlocked) {
|
||||
splashErr.innerHTML =
|
||||
`<strong>Network proxy blocked this request</strong><br>` +
|
||||
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
|
||||
`<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` +
|
||||
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
|
||||
} else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
|
||||
splashErr.innerHTML =
|
||||
`<strong>Connection timed out</strong><br>` +
|
||||
`<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` +
|
||||
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
|
||||
} else {
|
||||
splashErr.innerHTML =
|
||||
`<strong>Cannot reach server</strong><br>` +
|
||||
`<span class="splash-error-hint">${API._esc(e.message)}. ` +
|
||||
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
|
||||
}
|
||||
showSplash(null);
|
||||
return;
|
||||
}
|
||||
@@ -144,69 +272,16 @@ function updateAvatarPreview(dataURI) {
|
||||
|
||||
// ── Models ───────────────────────────────────
|
||||
|
||||
// Client-side known model capabilities — used when backend hasn't synced yet.
|
||||
// Mirrors server/providers/capabilities.go knownModels table.
|
||||
const KNOWN_MODELS = {
|
||||
'claude-opus-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:32000 },
|
||||
'claude-sonnet-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:16000 },
|
||||
'claude-3-5-sonnet': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
|
||||
'claude-3-5-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
|
||||
'claude-3-opus': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
|
||||
'claude-3-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
|
||||
'gpt-4o': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
|
||||
'gpt-4o-mini': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
|
||||
'gpt-4-turbo': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:4096 },
|
||||
'o1': { streaming:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||
'o3': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||
'o3-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:65536 },
|
||||
'o4-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||||
'gemini-2.5-pro': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
|
||||
'gemini-2.5-flash': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
|
||||
'gemini-2.0-flash': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:8192 },
|
||||
'deepseek-r1': { streaming:true, reasoning:true, max_context:65536, max_output_tokens:8192 },
|
||||
'deepseek-v3': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
|
||||
'deepseek-chat': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
|
||||
'llama-3.1-405b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||
'llama-3.1-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||
'llama-3.3-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||||
'llama-4-maverick': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:16384 },
|
||||
'llama-4-scout': { streaming:true, tool_calling:true, vision:true, max_context:524288, max_output_tokens:16384 },
|
||||
'mistral-large': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
|
||||
'codestral': { streaming:true, tool_calling:true, code_optimized:true, max_context:262144, max_output_tokens:8192 },
|
||||
'qwen-2.5-72b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
|
||||
'qwq-32b': { streaming:true, reasoning:true, max_context:131072, max_output_tokens:8192 },
|
||||
};
|
||||
// ── Capability Resolution ───────────────────────────────────────
|
||||
// The backend is the source of truth for model capabilities.
|
||||
// Resolution chain on the server: catalog (provider API sync) → heuristic.
|
||||
// The frontend does NOT maintain a static model table — the same model
|
||||
// can have different capabilities on different providers (e.g. DeepSeek
|
||||
// on Venice has no tool_calling, same model on OpenRouter does).
|
||||
|
||||
// Look up client-side capabilities by model ID with prefix matching
|
||||
function lookupKnownCaps(modelId) {
|
||||
const id = modelId.toLowerCase().replace(/^[^/]+\//, ''); // strip provider prefix
|
||||
// Exact match
|
||||
if (KNOWN_MODELS[id]) return { ...KNOWN_MODELS[id] };
|
||||
// Prefix match (longest wins)
|
||||
let best = null, bestLen = 0;
|
||||
for (const key of Object.keys(KNOWN_MODELS)) {
|
||||
if (id.startsWith(key) && key.length > bestLen) {
|
||||
best = key; bestLen = key.length;
|
||||
}
|
||||
}
|
||||
return best ? { ...KNOWN_MODELS[best] } : null;
|
||||
}
|
||||
|
||||
// Merge: backend/provider caps are authoritative, client-side fills gaps only
|
||||
// resolveCapabilities returns backend caps as-is. No client-side override.
|
||||
function resolveCapabilities(backendCaps, modelId) {
|
||||
const known = lookupKnownCaps(modelId) || {};
|
||||
if (!backendCaps || Object.keys(backendCaps).length === 0) return known;
|
||||
|
||||
// Backend is authoritative — start with it
|
||||
const caps = { ...backendCaps };
|
||||
|
||||
// Fill gaps (fields backend didn't report) from known table
|
||||
for (const [k, v] of Object.entries(known)) {
|
||||
if (caps[k] === undefined || caps[k] === null) {
|
||||
caps[k] = v;
|
||||
}
|
||||
}
|
||||
return caps;
|
||||
return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
@@ -318,6 +393,9 @@ async function selectChat(chatId) {
|
||||
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning();
|
||||
updateInputTokens();
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
@@ -325,6 +403,9 @@ async function newChat() {
|
||||
UI.renderChatList();
|
||||
UI.showEmptyState();
|
||||
UI.showRegenerate(false);
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning();
|
||||
updateInputTokens();
|
||||
document.getElementById('messageInput').focus();
|
||||
if (window.innerWidth <= 768) {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
@@ -395,7 +476,9 @@ async function sendMessage() {
|
||||
} else {
|
||||
console.error('Completion error:', e);
|
||||
const msg = e.message || '';
|
||||
if (msg.includes('provider error') && msg.includes('401')) {
|
||||
if (e.proxyBlocked) {
|
||||
UI.toast('Network proxy blocked this request — contact your network admin', 'error');
|
||||
} else if (msg.includes('provider error') && msg.includes('401')) {
|
||||
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
|
||||
} else if (msg.includes('provider error') && msg.includes('429')) {
|
||||
UI.toast('Provider rate limit — wait and retry', 'warning');
|
||||
@@ -436,6 +519,7 @@ async function reloadActivePath() {
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning();
|
||||
} catch (e) {
|
||||
console.error('Failed to reload path:', e.message);
|
||||
}
|
||||
@@ -472,7 +556,9 @@ async function regenerateMessage(messageId) {
|
||||
await reloadActivePath();
|
||||
} else {
|
||||
const msg = e.message || '';
|
||||
if (msg.includes('provider error') && msg.includes('401')) {
|
||||
if (e.proxyBlocked) {
|
||||
UI.toast('Network proxy blocked this request', 'error');
|
||||
} else if (msg.includes('provider error') && msg.includes('401')) {
|
||||
UI.toast('Provider API key rejected — check Settings', 'error');
|
||||
} else { UI.toast(msg, 'error'); }
|
||||
}
|
||||
@@ -894,7 +980,7 @@ function initListeners() {
|
||||
});
|
||||
document.getElementById('settingsModel').addEventListener('change', function() {
|
||||
const model = App.findModel(this.value);
|
||||
const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
|
||||
const caps = model?.capabilities || {};
|
||||
const hint = document.getElementById('settingsMaxHint');
|
||||
if (hint) {
|
||||
hint.textContent = caps.max_output_tokens > 0
|
||||
@@ -1287,6 +1373,7 @@ function initListeners() {
|
||||
input.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||
updateInputTokens();
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
|
||||
Reference in New Issue
Block a user