// ─── Mobile sidebar toggle ─────────────────────────────────────────────────── (function() { const sidebar = document.getElementById('left-sidebar'); const overlay = document.getElementById('sidebar-overlay'); const mobileBtn = document.getElementById('sidebar-mobile-toggle'); function openSidebar() { if(sidebar) sidebar.classList.add('open'); if(overlay) overlay.classList.add('open'); } function closeSidebar() { if(sidebar) sidebar.classList.remove('open'); if(overlay) overlay.classList.remove('open'); } if (mobileBtn) mobileBtn.addEventListener('click', openSidebar); if (overlay) overlay.addEventListener('click', closeSidebar); function checkWidth() { if (!mobileBtn) return; mobileBtn.style.display = window.innerWidth <= 768 ? 'flex' : 'none'; if (window.innerWidth > 768) closeSidebar(); } checkWidth(); window.addEventListener('resize', checkWidth); })(); // ─── Reveal animations ──────────────────────────────────────────────────────── (function() { if (!window.IntersectionObserver) { document.querySelectorAll('.reveal').forEach(el => el.classList.add('visible')); return; } const io = new IntersectionObserver(entries => { entries.forEach(e => { if (!e.isIntersecting) return; e.target.classList.add('visible'); io.unobserve(e.target); }); }, { threshold: 0.1, rootMargin: '0px 0px -30px 0px' }); document.querySelectorAll('.reveal').forEach(el => io.observe(el)); })(); // ─── marked config ──────────────────────────────────────────────────────────── if (window.marked) { marked.setOptions({ highlight: function(code, lang) { if (window.hljs && lang && hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } return window.hljs ? hljs.highlightAuto(code).value : code; }, breaks: true, gfm: true, }); } // ─── Model catalogue ───────────────────────────────────────────────────────── const MODELS_FALLBACK = [ { id: 'nvidia/nemotron-3-super-120b-a12b:free', label: 'Nemotron 3 Super 120B', provider: 'Nvidia', size: '120B' }, { id: 'nvidia/nemotron-3-nano-30b-a3b:free', label: 'Nemotron 3 Nano 30B', provider: 'Nvidia', size: '30B' }, { id: 'nvidia/nemotron-nano-9b-v2:free', label: 'Nemotron Nano 9B', provider: 'Nvidia', size: '9B' }, { id: 'openai/gpt-oss-120b:free', label: 'GPT OSS 120B', provider: 'OpenAI', size: '120B' }, { id: 'openai/gpt-oss-20b:free', label: 'GPT OSS 20B', provider: 'OpenAI', size: '20B' }, { id: 'google/gemma-4-31b-it:free', label: 'Gemma 4 31B', provider: 'Google', size: '31B' }, { id: 'google/gemma-4-26b-a4b-it:free', label: 'Gemma 4 26B MoE', provider: 'Google', size: '26B' }, { id: 'google/gemini-2.0-flash-exp:free', label: 'Gemini 2.0 Flash', provider: 'Google', size: '' }, { id: 'meta-llama/llama-3.3-70b-instruct:free', label: 'Llama 3.3 70B', provider: 'Meta', size: '70B' }, { id: 'qwen/qwen3-coder:free', label: 'Qwen3 Coder 480B', provider: 'Alibaba', size: '480B' }, { id: 'qwen/qwen3-next-80b-a3b-instruct:free', label: 'Qwen3 Next 80B', provider: 'Alibaba', size: '80B' }, { id: 'qwen/qwen-2.5-72b-instruct:free', label: 'Qwen 2.5 72B', provider: 'Alibaba', size: '72B' }, { id: 'arcee-ai/trinity-large-preview:free', label: 'Trinity Large 400B', provider: 'Arcee', size: '400B' }, { id: 'z-ai/glm-4.5-air:free', label: 'GLM 4.5 Air', provider: 'Z.ai', size: '' }, { id: 'minimax/minimax-m2.5:free', label: 'MiniMax M2.5', provider: 'MiniMax', size: '' }, { id: 'openrouter/elephant-alpha', label: 'Elephant Alpha 100B', provider: 'OpenRouter', size: '100B' }, { id: 'deepseek/deepseek-r1:free', label: 'DeepSeek R1', provider: 'DeepSeek', size: '671B' }, { id: 'deepseek/deepseek-chat-v3-0324:free', label: 'DeepSeek V3', provider: 'DeepSeek', size: '' }, ]; let MODELS = MODELS_FALLBACK.slice(); async function fetchFreeModels() { try { const res = await fetch('https://openrouter.ai/api/v1/models'); if (!res.ok) return; const data = await res.json(); const free = (data.data || []) .filter(m => m.id.endsWith(':free') && m.context_length > 0) .map(m => { const parts = m.id.split('/'); const providerSlug = parts[0] || ''; const providerMap = { 'meta-llama': 'Meta', 'google': 'Google', 'mistralai': 'Mistral', 'qwen': 'Alibaba', 'deepseek': 'DeepSeek', 'microsoft': 'Microsoft', 'nvidia': 'Nvidia', 'anthropic': 'Anthropic', 'openai': 'OpenAI', 'nousresearch': 'Nous', 'cohere': 'Cohere', '01-ai': '01.AI', 'huggingfaceh4': 'HuggingFace', 'openchat': 'OpenChat', }; const provider = providerMap[providerSlug] || providerSlug; const label = m.name || parts[1] || m.id; return { id: m.id, label, provider, size: '' }; }) .sort((a, b) => a.label.localeCompare(b.label)); if (free.length > 0) { MODELS = free; populateSelects(); ['left', 'right'].forEach(side => { const sel = document.getElementById('model-' + side); if (!sel) return; const preferred = side === 'left' ? 'meta-llama/llama-3.3-70b-instruct:free' : 'google/gemma-4-31b-it:free'; const match = MODELS.find(m => m.id === preferred) || MODELS[side === 'left' ? 0 : Math.min(4, MODELS.length - 1)]; if (match) sel.value = match.id; updateBadge(side); }); } } catch (e) { console.warn('Could not fetch OpenRouter model list, using fallback.', e); } } // State let abortLeft = null; let abortRight = null; // Session log for export const sessionLog = []; // ─── Init ───────────────────────────────────────────────────────────────────── function init() { populateSelects(); loadKey(); document.getElementById('model-left').value = MODELS[0].id; document.getElementById('model-right').value = MODELS[5].id; updateBadge('left'); updateBadge('right'); fetchFreeModels(); // Enter = send, Ctrl+Enter = new line (for user-prompt textarea) const promptEl = document.getElementById('user-prompt'); promptEl.addEventListener('keydown', function(e) { if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.shiftKey) { e.preventDefault(); const btn = document.getElementById('send-btn'); if (!btn.disabled) sendPrompt(); } // Ctrl+Enter / Meta+Enter → insert newline if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); const start = this.selectionStart; const end = this.selectionEnd; this.value = this.value.slice(0, start) + '\n' + this.value.slice(end); this.selectionStart = this.selectionEnd = start + 1; updateTokenCount(this.value); } }); // API key focus-state highlighting const keyEl = document.getElementById('api-key'); keyEl.addEventListener('focus', updateKeyStyle); keyEl.addEventListener('blur', updateKeyStyle); keyEl.addEventListener('input', updateKeyStyle); } function updateKeyStyle() { const keyEl = document.getElementById('api-key'); // has-key is set by saveKey; border color handled purely by CSS focus+has-key combo // Just ensure the class is current keyEl.classList.toggle('has-key', keyEl.value.trim().length > 0); } function populateSelects() { const opts = MODELS.map(m => `` ).join(''); document.getElementById('model-left').innerHTML = opts; document.getElementById('model-right').innerHTML = opts; } function onModelChange(side) { updateBadge(side); updateStatusDot(side, null); } function updateBadge(side) { const sel = document.getElementById('model-' + side); const m = MODELS.find(x => x.id === sel.value); const badge = document.getElementById('badge-' + side); if (m) badge.textContent = m.provider; } function updateStatusDot(side, state) { // state: null = neutral, 'ok', 'slow', 'err' const dot = document.getElementById('status-' + side); if (!dot) return; dot.className = 'pg-status-dot' + (state ? ' ' + state : ''); } // ─── Key management ─────────────────────────────────────────────────────────── function loadKey() { const k = localStorage.getItem('or_api_key') || ''; const inp = document.getElementById('api-key'); inp.value = k; inp.classList.toggle('has-key', k.length > 0); } function saveKey(val) { localStorage.setItem('or_api_key', val.trim()); const el = document.getElementById('key-saved'); el.classList.add('show'); setTimeout(() => el.classList.remove('show'), 1800); const inp = document.getElementById('api-key'); inp.classList.toggle('has-key', val.trim().length > 0); } function toggleKeyVisibility() { const inp = document.getElementById('api-key'); inp.type = inp.type === 'password' ? 'text' : 'password'; } // ─── Token estimate ─────────────────────────────────────────────────────────── function updateTokenCount(text) { const est = Math.round(text.split(/\s+/).filter(Boolean).length * 1.3); document.getElementById('token-count').textContent = `~${est} tokens`; } // ─── System prompt toggle ───────────────────────────────────────────────────── function toggleSysPrompt() { const wrap = document.getElementById('sys-wrap'); const btn = document.getElementById('sys-toggle-btn'); const open = wrap.classList.toggle('open'); btn.innerHTML = open ? ` System prompt` : ` System prompt`; } // ─── Clear ──────────────────────────────────────────────────────────────────── function clearAll() { if (abortLeft) { abortLeft.abort(); abortLeft = null; } if (abortRight) { abortRight.abort(); abortRight = null; } ['left', 'right'].forEach(side => { const r = document.getElementById('response-' + side); r.innerHTML = ''; r.textContent = 'Choose a model and send a prompt'; r.classList.add('empty'); r.classList.remove('rendered'); document.getElementById('meta-' + side).style.display = 'none'; document.getElementById('tags-' + side).style.display = 'none'; document.getElementById('pg-global-error').innerHTML = ''; updateStatusDot(side, null); }); document.getElementById('user-prompt').value = ''; document.getElementById('token-count').textContent = '~0 tokens'; setSendState(false); diffActive = false; const diffBtn = document.getElementById('diff-toggle-btn'); if (diffBtn) diffBtn.classList.remove('active'); } // ─── Send ───────────────────────────────────────────────────────────────────── async function sendPrompt() { const apiKey = document.getElementById('api-key').value.trim(); const prompt = document.getElementById('user-prompt').value.trim(); const sysPrompt = document.getElementById('sys-prompt').value.trim(); const errorEl = document.getElementById('pg-global-error'); errorEl.innerHTML = ''; if (!apiKey) { errorEl.innerHTML = '
Enter your OpenRouter API key →
'; document.getElementById('api-key').focus(); return; } if (!prompt) { errorEl.innerHTML = '
Type a prompt first.
'; document.getElementById('user-prompt').focus(); return; } if (abortLeft) { abortLeft.abort(); abortLeft = null; } if (abortRight) { abortRight.abort(); abortRight = null; } const modelLeft = document.getElementById('model-left').value; const modelRight = document.getElementById('model-right').value; setSendState(true); ['left', 'right'].forEach(side => { const r = document.getElementById('response-' + side); r.innerHTML = ''; r.classList.remove('empty', 'rendered'); document.getElementById('meta-' + side).style.display = 'none'; document.getElementById('tags-' + side).style.display = 'none'; updateStatusDot(side, null); }); abortLeft = new AbortController(); abortRight = new AbortController(); const messages = []; if (sysPrompt) messages.push({ role: 'system', content: sysPrompt }); messages.push({ role: 'user', content: prompt }); const temperature = parseFloat(document.getElementById('temp-slider').value); const maxTokens = parseInt(document.getElementById('maxtok-slider').value); await Promise.allSettled([ streamResponse('left', modelLeft, messages, apiKey, abortLeft.signal, temperature, maxTokens), streamResponse('right', modelRight, messages, apiKey, abortRight.signal, temperature, maxTokens), ]); abortLeft = null; abortRight = null; setSendState(false); } function setSendState(loading) { const btn = document.getElementById('send-btn'); btn.disabled = loading; btn.innerHTML = loading ? '
Sending…' : ' Send to both models'; } async function streamResponse(side, modelId, messages, apiKey, signal, temperature, maxTokens) { const respEl = document.getElementById('response-' + side); const metaEl = document.getElementById('meta-' + side); const cursor = document.createElement('span'); cursor.className = 'stream-cursor'; const t0 = performance.now(); let completionTokens = 0; let text = ''; let lastDomUpdate = 0; let ttft = null; // time to first token (ms) let firstChunk = true; try { const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'HTTP-Referer': window.location.origin, 'X-Title': 'Klyxe Playground', }, body: JSON.stringify({ model: modelId, messages, stream: true, stream_options: { include_usage: true }, max_tokens: maxTokens, temperature, }), signal, }); if (!res.ok) { let errMsg = `HTTP ${res.status}`; try { const errData = await res.json(); errMsg = errData?.error?.message || errMsg; } catch {} if (res.status === 429) errMsg = '429 Too Many Requests — подожди немного и попробуй снова.'; else if (res.status === 400 && errMsg.includes('context')) errMsg += ' — Context Length Exceeded.'; else if (errMsg.includes('No endpoints found')) errMsg += ' — модель недоступна на OpenRouter. Выбери другую.'; updateStatusDot(side, 'err'); throw new Error(errMsg); } // Show cursor while streaming respEl.textContent = ''; respEl.appendChild(cursor); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) continue; const raw = trimmed.slice(5).trim(); if (raw === '[DONE]') continue; try { const chunk = JSON.parse(raw); if (chunk.usage?.completion_tokens) { completionTokens = chunk.usage.completion_tokens; } const delta = chunk.choices?.[0]?.delta?.content; if (delta) { // TTFT — first token if (firstChunk) { ttft = performance.now() - t0; firstChunk = false; // Status based on TTFT updateStatusDot(side, ttft < 800 ? 'ok' : ttft < 3000 ? 'slow' : 'err'); } text += delta; const now = performance.now(); if (now - lastDomUpdate > 16) { respEl.textContent = text; respEl.appendChild(cursor); lastDomUpdate = now; } } } catch { /* skip malformed JSON */ } } } // Final render: use markdown cursor.remove(); renderMarkdown(side, text); } catch (err) { cursor.remove(); if (err.name === 'AbortError') { renderMarkdown(side, text ? text + '\n\n*[Stopped]*' : '*[Stopped]*'); } else { respEl.classList.remove('rendered'); respEl.textContent = '[Error] ' + err.message; respEl.classList.add('empty'); } showStats(side, t0, completionTokens, text, ttft); return; } if (!text) { respEl.textContent = '[No response]'; respEl.classList.add('empty'); } showStats(side, t0, completionTokens, text, ttft); // Save to session log const entry = { timestamp: new Date().toISOString(), side, model: modelId, prompt: document.getElementById('user-prompt').value.trim(), response: text, ttft_ms: ttft ? Math.round(ttft) : null, tags: [], }; sessionLog.push(entry); } // ─── Markdown rendering ─────────────────────────────────────────────────────── function renderMarkdown(side, text) { const respEl = document.getElementById('response-' + side); if (!text) return; if (window.marked) { respEl.classList.add('rendered'); respEl.innerHTML = marked.parse(text); // Add copy button to each pre>code block respEl.querySelectorAll('pre').forEach(pre => { const btn = document.createElement('button'); btn.className = 'pg-code-copy'; btn.textContent = 'copy'; btn.onclick = () => { navigator.clipboard.writeText(pre.querySelector('code')?.textContent || ''); btn.textContent = '✓'; setTimeout(() => btn.textContent = 'copy', 1500); }; pre.style.position = 'relative'; pre.appendChild(btn); }); // highlight.js on code blocks if (window.hljs) { respEl.querySelectorAll('pre code').forEach(block => { hljs.highlightElement(block); }); } } else { respEl.textContent = text; } } // ─── Stats footer ───────────────────────────────────────────────────────────── function showStats(side, t0, completionTokens, text, ttft) { const elapsed = (performance.now() - t0) / 1000; const tokens = completionTokens > 0 ? completionTokens : Math.round(text.split(/\s+/).filter(Boolean).length * 1.3); const tps = tokens > 0 && elapsed > 0 ? (tokens / elapsed).toFixed(1) : '–'; document.getElementById('time-' + side).textContent = elapsed.toFixed(2) + 's'; document.getElementById('tokens-' + side).textContent = tokens; document.getElementById('tps-' + side).textContent = tps; document.getElementById('ttft-' + side).textContent = ttft ? (ttft / 1000).toFixed(2) + 's' : '–'; document.getElementById('meta-' + side).style.display = 'flex'; document.getElementById('tags-' + side).style.display = 'flex'; // Update session log entry with final stats const last = [...sessionLog].reverse().find(e => e.side === side); if (last) { last.elapsed_s = parseFloat(elapsed.toFixed(2)); last.tokens = tokens; last.tps = parseFloat(tps) || 0; } } // ─── Error tag system ───────────────────────────────────────────────────────── function toggleTag(side, tag) { const btns = document.querySelectorAll(`#tags-${side} .pg-tag-btn`); const tagMap = { hallucination: 0, truncated: 1, formatting: 2 }; const btn = btns[tagMap[tag]]; if (!btn) return; btn.classList.toggle('active'); // Update log const last = [...sessionLog].reverse().find(e => e.side === side); if (last) { if (btn.classList.contains('active')) { if (!last.tags.includes(tag)) last.tags.push(tag); } else { last.tags = last.tags.filter(t => t !== tag); } } } // ─── Diff view ──────────────────────────────────────────────────────────────── let diffActive = false; function toggleDiff() { diffActive = !diffActive; const btn = document.getElementById('diff-toggle-btn'); btn.classList.toggle('active', diffActive); if (diffActive) { applyDiff(); } else { // Re-render markdown normally const leftEntry = [...sessionLog].reverse().find(e => e.side === 'left'); const rightEntry = [...sessionLog].reverse().find(e => e.side === 'right'); if (leftEntry) renderMarkdown('left', leftEntry.response); if (rightEntry) renderMarkdown('right', rightEntry.response); } } function applyDiff() { const leftEl = document.getElementById('response-left'); const rightEl = document.getElementById('response-right'); const leftText = leftEl.innerText || leftEl.textContent; const rightText = rightEl.innerText || rightEl.textContent; const leftWords = leftText.split(/\s+/); const rightWords = rightText.split(/\s+/); // Simple word-level diff (LCS-based) const lcs = buildLCS(leftWords, rightWords); leftEl.innerHTML = diffHighlight(leftWords, lcs, 'left'); rightEl.innerHTML = diffHighlight(rightWords, lcs, 'right'); leftEl.classList.add('rendered'); rightEl.classList.add('rendered'); } function buildLCS(a, b) { const m = Math.min(a.length, 200), n = Math.min(b.length, 200); const dp = Array.from({length: m+1}, () => new Uint16Array(n+1)); for (let i=1;i<=m;i++) for (let j=1;j<=n;j++) dp[i][j] = a[i-1]===b[j-1] ? dp[i-1][j-1]+1 : Math.max(dp[i-1][j], dp[i][j-1]); // backtrack const lcs = []; let i=m,j=n; while(i>0&&j>0) { if (a[i-1]===b[j-1]) { lcs.unshift(a[i-1]); i--; j--; } else if (dp[i-1][j]>dp[i][j-1]) i--; else j--; } return new Set(lcs); } function diffHighlight(words, lcs, side) { return words.map(w => { const esc = w.replace(//g,'>'); if (lcs.has(w)) return esc; const cls = side === 'left' ? 'pg-diff-del' : 'pg-diff-add'; return `${esc}`; }).join(' '); } // ─── Export JSON ────────────────────────────────────────────────────────────── function exportJSON() { if (!sessionLog.length) { alert('No data to export yet. Send a prompt first.'); return; } const blob = new Blob([JSON.stringify(sessionLog, null, 2)], {type:'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `klyxe-playground-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); } document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { const active = document.activeElement; if (active && active.id === 'user-prompt') return; // handled by textarea listener const btn = document.getElementById('send-btn'); if (!btn.disabled) sendPrompt(); } }); init();