Changeset 0.9.1 (#51)
This commit is contained in:
@@ -531,15 +531,65 @@ a:hover { text-decoration: underline; }
|
||||
border-radius: 4px;
|
||||
}
|
||||
.msg-text pre code { background: none; padding: 0; font-size: inherit; }
|
||||
|
||||
/* Code block toolbar */
|
||||
.copy-code-btn {
|
||||
position: absolute; top: 6px; right: 6px; background: var(--bg-raised);
|
||||
border: 1px solid var(--border); color: var(--text-3); border-radius: 4px;
|
||||
padding: 2px 10px; font-size: 11px; cursor: pointer;
|
||||
opacity: 0; transition: opacity var(--transition);
|
||||
}
|
||||
.preview-code-btn { right: 56px; }
|
||||
.msg-text pre:hover .copy-code-btn { opacity: 1; }
|
||||
.copy-code-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||
|
||||
/* Language label */
|
||||
.code-lang {
|
||||
position: absolute; top: 6px; left: 10px;
|
||||
font-size: 10px; color: var(--text-3); font-family: var(--mono);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsible code blocks */
|
||||
.code-collapsible {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
margin: 0.75rem 0; background: var(--bg-surface);
|
||||
}
|
||||
.code-collapsible pre {
|
||||
margin: 0; border: none; border-radius: 0 0 var(--radius) var(--radius);
|
||||
}
|
||||
.code-collapse-summary {
|
||||
padding: 0.5rem 0.75rem; cursor: pointer; font-size: 12px;
|
||||
color: var(--text-3); user-select: none;
|
||||
transition: color var(--transition);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.code-collapse-summary:hover { color: var(--text-2); }
|
||||
.code-collapsible[open] .code-collapse-summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* HTML preview */
|
||||
.html-preview-wrap {
|
||||
border: 1px solid var(--accent); border-radius: var(--radius);
|
||||
margin: 0.5rem 0; overflow: hidden;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
.html-preview-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 4px 10px; background: var(--accent-dim);
|
||||
font-size: 11px; color: var(--accent);
|
||||
}
|
||||
.html-preview-close {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; padding: 0 4px; font-size: 14px;
|
||||
}
|
||||
.html-preview-close:hover { color: var(--text); }
|
||||
.html-preview-frame {
|
||||
width: 100%; height: 300px; border: none;
|
||||
background: #fff; display: block;
|
||||
}
|
||||
|
||||
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
|
||||
.msg-text li { margin: 0.2em 0; }
|
||||
.msg-text li > p { margin: 0.2em 0; }
|
||||
@@ -632,6 +682,29 @@ a:hover { text-decoration: underline; }
|
||||
/* ── Input Area ──────────────────────────── */
|
||||
|
||||
.input-area { padding: 0 1rem 1rem; flex-shrink: 0; }
|
||||
|
||||
/* Token counter below input */
|
||||
.input-meta { display: flex; justify-content: space-between; align-items: center; padding: 2px 12px 0; min-height: 18px; }
|
||||
.input-token-count { font-size: 0.68rem; color: var(--text-3); transition: color 0.2s; }
|
||||
.input-token-count.warning { color: var(--warning); }
|
||||
.input-token-count.danger { color: var(--danger); }
|
||||
|
||||
/* Context length warning banner */
|
||||
.context-warning {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; margin-bottom: 8px; border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent);
|
||||
font-size: 0.78rem; color: var(--text-2); animation: fadeIn 0.2s ease;
|
||||
}
|
||||
.context-warning.danger {
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--danger) 25%, transparent);
|
||||
}
|
||||
.context-warning-icon { flex-shrink: 0; }
|
||||
.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); }
|
||||
.input-wrap {
|
||||
max-width: 768px; margin: 0 auto;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
@@ -784,7 +857,10 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.auth-tab:hover { color: var(--text-2); }
|
||||
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; }
|
||||
.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; }
|
||||
.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; }
|
||||
.splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; }
|
||||
.splash-error-hint { color: var(--text-muted); font-size: 0.72rem; }
|
||||
.splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; }
|
||||
.auth-actions { margin-top: 1.5rem; }
|
||||
.auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; }
|
||||
.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; }
|
||||
@@ -805,6 +881,7 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
#authLoginForm { animation-delay: 0.29s; }
|
||||
.splash .auth-actions { animation-delay: 0.36s; }
|
||||
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* Splash responsive */
|
||||
@media (max-width: 860px) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<base href="%%BASE_HREF%%">
|
||||
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script>
|
||||
<script>window.__ENV__ = '%%ENVIRONMENT%%'; if (window.__ENV__.includes('%%')) window.__ENV__ = 'development';</script>
|
||||
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
|
||||
<title>Chat Switchboard</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
@@ -134,6 +135,11 @@
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<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-dismiss" onclick="dismissContextWarning()" title="Dismiss">✕</button>
|
||||
</div>
|
||||
<div class="input-wrap">
|
||||
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||
<div class="input-actions">
|
||||
@@ -145,6 +151,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-meta" id="inputMeta">
|
||||
<span class="input-token-count" id="inputTokenCount"></span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div><!-- .app-body -->
|
||||
|
||||
@@ -54,6 +54,25 @@ const API = {
|
||||
async health() {
|
||||
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||||
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
|
||||
return this._parseJSON(resp, '/api/v1/health');
|
||||
},
|
||||
|
||||
// Detect proxy interception: HTTP 200 but HTML instead of JSON
|
||||
async _parseJSON(resp, context) {
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
// Proxy returned an HTML page (block page, auth wall, etc.)
|
||||
let title = 'unknown';
|
||||
try {
|
||||
const html = await resp.clone().text();
|
||||
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (m) title = m[1].trim();
|
||||
} catch (e) { /* best effort */ }
|
||||
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
|
||||
err.proxyBlocked = true;
|
||||
err.proxyTitle = title;
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
},
|
||||
|
||||
@@ -157,9 +176,21 @@ const API = {
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
const err = new Error('Proxy blocked the regeneration request');
|
||||
err.proxyBlocked = true;
|
||||
throw err;
|
||||
}
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
const err = new Error('Proxy intercepted the regeneration stream');
|
||||
err.proxyBlocked = true;
|
||||
throw err;
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
|
||||
@@ -205,9 +236,22 @@ const API = {
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
const err = new Error('Proxy blocked the completion request');
|
||||
err.proxyBlocked = true;
|
||||
throw err;
|
||||
}
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
// Also check 200 OK but HTML (proxy returning block page with 200)
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
const err = new Error('Proxy intercepted the completion stream');
|
||||
err.proxyBlocked = true;
|
||||
throw err;
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
|
||||
@@ -381,6 +425,13 @@ const API = {
|
||||
|
||||
// ── HTTP Internals ───────────────────────
|
||||
|
||||
_esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
},
|
||||
|
||||
_authHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -413,11 +464,26 @@ const API = {
|
||||
|
||||
const resp = await fetch(BASE + path, opts);
|
||||
if (!resp.ok) {
|
||||
// Check if the error response is also a proxy page
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
let title = 'unknown';
|
||||
try {
|
||||
const html = await resp.text();
|
||||
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (m) title = m[1].trim();
|
||||
} catch (e) { /* */ }
|
||||
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
|
||||
err.status = resp.status;
|
||||
err.proxyBlocked = true;
|
||||
err.proxyTitle = title;
|
||||
throw err;
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
return this._parseJSON(resp, path);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
@@ -60,10 +60,12 @@ const DebugLog = {
|
||||
url: url.slice(0, 300),
|
||||
status: null,
|
||||
statusText: '',
|
||||
contentType: null,
|
||||
duration: null,
|
||||
error: null,
|
||||
responsePreview: null,
|
||||
requestBodyPreview: null
|
||||
requestBodyPreview: null,
|
||||
proxyDetected: false
|
||||
};
|
||||
|
||||
// Capture request body preview (redact keys)
|
||||
@@ -92,14 +94,27 @@ const DebugLog = {
|
||||
const resp = await self._origFetch(input, init);
|
||||
entry.status = resp.status;
|
||||
entry.statusText = resp.statusText;
|
||||
entry.contentType = resp.headers.get('content-type') || null;
|
||||
entry.duration = Math.round(performance.now() - start);
|
||||
|
||||
// Log non-ok responses at higher severity
|
||||
const logType = resp.ok ? 'NET' : 'NET:ERR';
|
||||
self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`);
|
||||
// Detect proxy interception: API call returned HTML
|
||||
const isApiCall = url.includes('/api/') || url.includes('/health');
|
||||
if (isApiCall && entry.contentType && entry.contentType.includes('text/html')) {
|
||||
entry.proxyDetected = true;
|
||||
self._addEntry('NET:PROXY', `${method} ${url.slice(0, 80)} → 🛡️ PROXY HTML (${entry.contentType}) (${entry.duration}ms)`);
|
||||
try {
|
||||
const clone = resp.clone();
|
||||
const text = await clone.text();
|
||||
entry.responsePreview = text.slice(0, 500);
|
||||
} catch (e) { /* */ }
|
||||
} else {
|
||||
// Log non-ok responses at higher severity
|
||||
const logType = resp.ok ? 'NET' : 'NET:ERR';
|
||||
self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`);
|
||||
}
|
||||
|
||||
// Clone and peek at error responses
|
||||
if (!resp.ok) {
|
||||
if (!resp.ok && !entry.proxyDetected) {
|
||||
try {
|
||||
const clone = resp.clone();
|
||||
const text = await clone.text();
|
||||
@@ -148,7 +163,7 @@ const DebugLog = {
|
||||
this.entries.shift();
|
||||
}
|
||||
|
||||
if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR') {
|
||||
if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR' || type === 'NET:PROXY') {
|
||||
this._errorCount++;
|
||||
this._updateBadge();
|
||||
}
|
||||
@@ -197,7 +212,12 @@ const DebugLog = {
|
||||
// ── State Snapshot ──────────────────────
|
||||
|
||||
getStateSnapshot() {
|
||||
const snap = {};
|
||||
const snap = {
|
||||
env: window.__ENV__ || 'unknown',
|
||||
version: window.__VERSION__ || 'unknown',
|
||||
basePath: window.__BASE__ || '(root)',
|
||||
url: location.href,
|
||||
};
|
||||
|
||||
// API client state (redact tokens)
|
||||
if (typeof API !== 'undefined') {
|
||||
@@ -254,6 +274,7 @@ const DebugLog = {
|
||||
|
||||
async runDiagnostics() {
|
||||
this.log('DIAG', '── Starting connection diagnostics ──');
|
||||
this.log('DIAG', `Environment: ${window.__ENV__ || 'unknown'} | Version: ${window.__VERSION__ || 'unknown'} | Base: ${window.__BASE__ || '(root)'}`);
|
||||
|
||||
const baseUrl = (typeof API !== 'undefined' && API._base)
|
||||
? API._base
|
||||
@@ -387,7 +408,7 @@ const DebugLog = {
|
||||
|
||||
const typeColors = {
|
||||
'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c',
|
||||
'NET:ERR': '#e74c3c',
|
||||
'NET:ERR': '#e74c3c', 'NET:PROXY': '#ff6b35',
|
||||
'WARN': '#f39c12', 'WARNING': '#f39c12',
|
||||
'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d',
|
||||
'NET': '#2ecc71',
|
||||
@@ -396,7 +417,7 @@ const DebugLog = {
|
||||
|
||||
let html = '';
|
||||
const entries = document.getElementById('debugFilterErrors')?.checked
|
||||
? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'WARN'].includes(e.type))
|
||||
? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'NET:PROXY', 'WARN'].includes(e.type))
|
||||
: this.entries;
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -430,9 +451,11 @@ const DebugLog = {
|
||||
|
||||
let html = '';
|
||||
for (const req of [...this.networkLog].reverse()) {
|
||||
const isErr = req.error || (req.status && req.status >= 400);
|
||||
const statusColor = isErr ? '#e74c3c' : '#2ecc71';
|
||||
const statusText = req.error
|
||||
const isErr = req.error || (req.status && req.status >= 400) || req.proxyDetected;
|
||||
const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71');
|
||||
const statusText = req.proxyDetected
|
||||
? `🛡️ PROXY (${req.status})`
|
||||
: req.error
|
||||
? `❌ ${req.error}`
|
||||
: (req.status ? `${req.status} ${req.statusText}` : '⏳ pending');
|
||||
|
||||
@@ -444,6 +467,9 @@ const DebugLog = {
|
||||
if (req.duration != null) html += ` <span class="debug-time">${req.duration}ms</span>`;
|
||||
html += `</summary>`;
|
||||
html += `<div class="debug-net-detail">`;
|
||||
if (req.contentType) {
|
||||
html += `<div><strong>Content-Type:</strong> <code>${this._esc(req.contentType)}</code></div>`;
|
||||
}
|
||||
if (req.requestBodyPreview) {
|
||||
html += `<div><strong>Request:</strong> <code>${this._esc(req.requestBodyPreview)}</code></div>`;
|
||||
}
|
||||
@@ -475,6 +501,7 @@ const DebugLog = {
|
||||
let text = `=== Chat Switchboard Debug Export ===\n`;
|
||||
text += `Exported: ${new Date().toISOString()}\n`;
|
||||
text += `URL: ${location.href}\n`;
|
||||
text += `ENV: ${window.__ENV__ || 'unknown'} | VERSION: ${window.__VERSION__ || 'unknown'} | BASE: ${window.__BASE__ || '(root)'}\n`;
|
||||
text += `UA: ${navigator.userAgent}\n\n`;
|
||||
|
||||
text += `--- STATE ---\n`;
|
||||
@@ -488,7 +515,10 @@ const DebugLog = {
|
||||
|
||||
text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`;
|
||||
for (const r of this.networkLog) {
|
||||
text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'} ${r.error || ''} (${r.duration || '?'}ms)\n`;
|
||||
const proxy = r.proxyDetected ? ' [PROXY]' : '';
|
||||
text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'}${proxy} ${r.error || ''} (${r.duration || '?'}ms)`;
|
||||
if (r.contentType) text += ` [${r.contentType}]`;
|
||||
text += '\n';
|
||||
if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`;
|
||||
}
|
||||
|
||||
|
||||
100
src/js/ui.js
100
src/js/ui.js
@@ -518,6 +518,8 @@ const UI = {
|
||||
el.classList.toggle('selected', el.dataset.value === val);
|
||||
});
|
||||
UI.updateCapabilityBadges();
|
||||
if (typeof updateContextWarning === 'function') updateContextWarning();
|
||||
if (typeof updateInputTokens === 'function') updateInputTokens();
|
||||
},
|
||||
|
||||
updateModelSelector() {
|
||||
@@ -625,11 +627,8 @@ const UI = {
|
||||
const modelId = UI.getModelValue();
|
||||
if (!modelId) return {};
|
||||
const model = App.findModel(modelId);
|
||||
// Model in list has resolved caps; fallback to client-side lookup
|
||||
if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
|
||||
return model.capabilities;
|
||||
}
|
||||
return (typeof lookupKnownCaps === 'function' ? lookupKnownCaps(modelId) : null) || {};
|
||||
// Model in list has resolved caps from backend (catalog → heuristic)
|
||||
return model?.capabilities || {};
|
||||
},
|
||||
|
||||
updateCapabilityBadges() {
|
||||
@@ -1700,11 +1699,37 @@ function formatMessage(content) {
|
||||
|
||||
function _formatMarked(text) {
|
||||
const rendered = marked.parse(text, { breaks: true, gfm: true });
|
||||
let html = DOMPurify.sanitize(rendered, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['id', 'class', 'onclick'] });
|
||||
let html = DOMPurify.sanitize(rendered, {
|
||||
ADD_TAGS: ['details', 'summary', 'iframe'],
|
||||
ADD_ATTR: ['id', 'class', 'onclick', 'sandbox', 'srcdoc']
|
||||
});
|
||||
|
||||
// Process code blocks: add copy button, collapsible wrapper, HTML preview
|
||||
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
|
||||
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
return `<pre><code id="${codeId}"${attrs}>${code}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`;
|
||||
const langMatch = attrs.match(/class="language-(\w+)"/);
|
||||
const lang = langMatch ? langMatch[1] : '';
|
||||
|
||||
// Count lines for collapse decision
|
||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||
const COLLAPSE_THRESHOLD = 15;
|
||||
|
||||
// Build toolbar buttons
|
||||
let toolbar = `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
|
||||
// HTML preview button (only for HTML-like content)
|
||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${codeId}')">Preview</button>`;
|
||||
}
|
||||
|
||||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||||
const block = `<pre class="code-block">${langLabel}<code id="${codeId}"${attrs}>${code}</code>${toolbar}</pre>`;
|
||||
|
||||
// Wrap in collapsible details if long
|
||||
if (lineCount > COLLAPSE_THRESHOLD) {
|
||||
return `<details class="code-collapsible"><summary class="code-collapse-summary">Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines</summary>${block}</details>`;
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
return html;
|
||||
@@ -1714,7 +1739,22 @@ function _formatBasic(text) {
|
||||
let html = esc(text);
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
const id = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
return `<pre><code id="${id}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button></pre>`;
|
||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||
const COLLAPSE_THRESHOLD = 15;
|
||||
|
||||
let toolbar = `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
|
||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${id}')">Preview</button>`;
|
||||
}
|
||||
|
||||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||||
const block = `<pre class="code-block">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code>${toolbar}</pre>`;
|
||||
|
||||
if (lineCount > COLLAPSE_THRESHOLD) {
|
||||
return `<details class="code-collapsible"><summary class="code-collapse-summary">Code${lang ? ' (' + lang + ')' : ''} · ${lineCount} lines</summary>${block}</details>`;
|
||||
}
|
||||
return block;
|
||||
});
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
@@ -1730,6 +1770,50 @@ function esc(s) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Heuristic: does this code block look like HTML?
|
||||
function _looksLikeHTML(code) {
|
||||
const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
return /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
|
||||
}
|
||||
|
||||
// Toggle sandboxed HTML preview below a code block
|
||||
function toggleHTMLPreview(codeId) {
|
||||
const existing = document.getElementById('preview-' + codeId);
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const codeEl = document.getElementById(codeId);
|
||||
if (!codeEl) return;
|
||||
|
||||
const pre = codeEl.closest('pre');
|
||||
if (!pre) return;
|
||||
|
||||
// Decode HTML entities back to real HTML for the preview
|
||||
const rawHTML = codeEl.textContent;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.id = 'preview-' + codeId;
|
||||
wrapper.className = 'html-preview-wrap';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'html-preview-header';
|
||||
header.innerHTML = `<span>HTML Preview</span><button class="html-preview-close" onclick="document.getElementById('preview-${codeId}').remove()">✕</button>`;
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'html-preview-frame';
|
||||
iframe.sandbox = 'allow-scripts'; // no allow-same-origin: fully isolated
|
||||
iframe.srcdoc = rawHTML;
|
||||
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(iframe);
|
||||
|
||||
// Insert after the <pre> (or after <details> if collapsible)
|
||||
const parent = pre.closest('.code-collapsible') || pre;
|
||||
parent.after(wrapper);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
function _relativeTime(dateStr) {
|
||||
|
||||
Reference in New Issue
Block a user