api-batch-tester / api-tester.html
div0-space's picture
Initial import: API batch tester
c4e64ca verified
raw
history blame
48.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LibraxisAI API Tester</title>
<style>
:root {
--bg: #0a0a0f;
--surface: #12121a;
--surface-hover: #1a1a24;
--border: #2a2a3a;
--accent: #6366f1;
--accent-hover: #818cf8;
--text: #e4e4e7;
--text-dim: #71717a;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
--gradient: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 1rem;
}
/* Header */
.header {
text-align: center;
padding: 1.5rem 0;
border-bottom: 1px solid var(--border);
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.75rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
color: var(--text-dim);
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Global Config */
.global-config {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 1rem;
}
.global-config label {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.global-config input[type="password"] {
flex: 1;
min-width: 200px;
padding: 0.5rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.875rem;
}
.mode-toggle {
display: flex;
gap: 0.5rem;
}
.mode-toggle label {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 0.875rem;
text-transform: none;
}
.mode-toggle input:checked + span {
color: var(--accent);
}
.mode-toggle input {
display: none;
}
/* Toolbar */
.toolbar {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.btn {
padding: 0.625rem 1rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--surface-hover);
border-color: var(--accent);
}
.btn-primary {
background: var(--gradient);
color: white;
font-weight: 700;
padding: 0.75rem 2rem;
font-size: 1rem;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.4);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Lanes Container */
.lanes-container {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 1rem;
}
/* Lane */
.lane {
flex: 0 0 360px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
display: flex;
flex-direction: column;
}
.lane-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
background: rgba(99, 102, 241, 0.05);
}
.lane-title {
font-weight: 600;
color: var(--accent);
display: flex;
align-items: center;
gap: 0.5rem;
}
.lane-actions {
display: flex;
gap: 0.25rem;
}
.lane-actions button {
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 0.25rem;
border-radius: 4px;
font-size: 1rem;
}
.lane-actions button:hover {
background: var(--surface-hover);
color: var(--text);
}
.lane-config {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
border-bottom: 1px solid var(--border);
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field label {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field input, .field select, .field textarea {
padding: 0.5rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.875rem;
font-family: inherit;
}
.field input:focus, .field select:focus, .field textarea:focus {
outline: none;
border-color: var(--accent);
}
.field textarea {
min-height: 60px;
resize: vertical;
}
.field-row {
display: flex;
gap: 0.5rem;
}
.field-row .field {
flex: 1;
}
/* Prompts Section */
.prompts-section {
padding: 1rem;
border-bottom: 1px solid var(--border);
max-height: 300px;
overflow-y: auto;
}
.prompts-section h4 {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.prompt-item {
margin-bottom: 0.75rem;
}
.prompt-item label {
font-size: 0.75rem;
color: var(--accent);
margin-bottom: 0.25rem;
display: block;
}
.prompt-item textarea {
width: 100%;
padding: 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.8125rem;
font-family: inherit;
min-height: 50px;
resize: vertical;
}
/* Output Section */
.output-section {
flex: 1;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
}
.output-section h4 {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.output-item {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.output-item-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
cursor: pointer;
}
.output-item-header:hover {
background: rgba(255,255,255,0.04);
}
.output-step {
color: var(--accent);
font-weight: 600;
}
.output-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
}
.status-dot.running {
background: var(--warning);
animation: pulse 1s infinite;
}
.status-dot.success { background: var(--success); }
.status-dot.error { background: var(--error); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.output-item-body {
padding: 0.75rem;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 150px;
overflow-y: auto;
}
.output-item-body.collapsed {
max-height: 40px;
overflow: hidden;
}
/* Stream Preview */
.stream-preview {
font-family: 'SF Mono', monospace;
font-size: 0.75rem;
color: var(--text-dim);
padding: 0.25rem 0.5rem;
background: rgba(99, 102, 241, 0.1);
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
/* Stats Bar */
.lane-stats {
padding: 0.75rem 1rem;
background: rgba(99, 102, 241, 0.05);
border-top: 1px solid var(--border);
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
font-size: 0.75rem;
}
.stat {
text-align: center;
}
.stat-value {
font-weight: 700;
color: var(--accent);
font-size: 1rem;
font-family: 'SF Mono', monospace;
}
.stat-label {
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.625rem;
}
/* Footer */
.footer {
text-align: center;
padding: 1.5rem;
color: var(--text-dim);
font-size: 0.75rem;
border-top: 1px solid var(--border);
margin-top: 2rem;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
/* Endpoint presets */
.endpoint-presets {
display: flex;
gap: 0.25rem;
margin-top: 0.25rem;
}
.endpoint-preset {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
}
.endpoint-preset:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
</head>
<body>
<div class="header">
<h1>LibraxisAI API Tester</h1>
<p>Multi-lane comparison tool for API endpoints</p>
</div>
<div class="global-config">
<div class="field" style="flex: 1;">
<label>API Key</label>
<input type="password" id="globalApiKey" value="" placeholder="your-api-key-here">
</div>
<div class="field">
<label>Mode</label>
<div class="mode-toggle">
<label>
<input type="radio" name="streamMode" value="true" checked>
<span>Stream</span>
</label>
<label>
<input type="radio" name="streamMode" value="false">
<span>Sync</span>
</label>
</div>
</div>
<div class="field">
<label>Output</label>
<div class="mode-toggle">
<label title="Show raw SSE events as-is">
<input type="radio" name="outputMode" value="raw" checked>
<span>RAW</span>
</label>
<label title="Parse and show only text content">
<input type="radio" name="outputMode" value="parsed">
<span>Parsed</span>
</label>
</div>
</div>
</div>
<div class="toolbar">
<button class="btn btn-secondary" onclick="addLane()">+ Add Lane</button>
<button class="btn btn-secondary" onclick="clearAllOutputs()">Clear Outputs</button>
<div style="display: flex; align-items: center; gap: 0.25rem; background: var(--surface); padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--border);">
<select id="presetSelect" style="background: var(--bg); border: none; color: var(--text); padding: 0.4rem; border-radius: 4px; min-width: 120px;">
<option value="">-- Presets --</option>
</select>
<button class="btn btn-secondary" onclick="loadPreset()" style="padding: 0.4rem 0.6rem;">Load</button>
<button class="btn btn-secondary" onclick="savePreset()" style="padding: 0.4rem 0.6rem;">Save</button>
<button class="btn btn-secondary" onclick="deletePreset()" style="padding: 0.4rem 0.6rem;">Del</button>
</div>
<button class="btn btn-secondary" onclick="exportResults()">Export JSON</button>
<button class="btn btn-secondary" onclick="copyResultsToClipboard()">Copy</button>
<button class="btn btn-secondary" onclick="exportAllLogs()" title="Export all saved test logs">
Logs <span id="logCount" style="background: var(--accent); padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.75rem;">0</span>
</button>
<div style="flex: 1;"></div>
<button class="btn btn-primary" id="runBtn" onclick="runAllLanes()">
RUN ALL
</button>
</div>
<div class="lanes-container" id="lanesContainer">
<!-- Lanes will be added here -->
</div>
<div class="footer">
Created by M&K (c)2026 The LibraxisAI Team
</div>
<script>
// State
let laneCounter = 0;
const lanes = new Map();
// Endpoint presets
const ENDPOINTS = {
'mlx-batch': 'http://localhost:8100/v1/responses',
'api-router': 'http://localhost:8088/v1/responses',
'remote': 'https://api.libraxis.cloud/v1/responses'
};
// History management for Endpoint and Model fields
const HISTORY_KEY = 'api-tester-history';
const MAX_HISTORY = 10;
function getHistory() {
try {
return JSON.parse(localStorage.getItem(HISTORY_KEY)) || { endpoints: [], models: [] };
} catch {
return { endpoints: [], models: [] };
}
}
function saveToHistory(type, value) {
if (!value || !value.trim()) return;
const history = getHistory();
const list = history[type] || [];
// Remove if exists, add to front
const idx = list.indexOf(value);
if (idx > -1) list.splice(idx, 1);
list.unshift(value);
// Keep max items
history[type] = list.slice(0, MAX_HISTORY);
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
updateHistoryDatalist(type);
}
function updateHistoryDatalist(type) {
const datalist = document.getElementById(`${type}-history`);
if (!datalist) return;
const history = getHistory();
const items = history[type] || [];
datalist.innerHTML = items.map(v => `<option value="${escapeHtml(v)}">`).join('');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Results storage for export
const testResults = new Map();
function collectResults() {
const results = {
timestamp: new Date().toISOString(),
globalConfig: {
apiKey: document.getElementById('globalApiKey').value ? '***' : 'none',
streamMode: document.querySelector('input[name="streamMode"]:checked').value
},
lanes: []
};
lanes.forEach((_, laneId) => {
const laneData = {
id: laneId,
config: {
endpoint: document.getElementById(`${laneId}-endpoint`).value,
model: document.getElementById(`${laneId}-model`).value,
chainLength: document.getElementById(`${laneId}-chain`).value,
systemPrompt: document.getElementById(`${laneId}-system`).value
},
prompts: [],
responses: [],
stats: {
ttft: document.getElementById(`${laneId}-ttft`)?.textContent || '-',
tps: document.getElementById(`${laneId}-tps`)?.textContent || '-',
total: document.getElementById(`${laneId}-total`)?.textContent || '-'
}
};
// Collect prompts
const chainLength = parseInt(laneData.config.chainLength) || 1;
for (let i = 1; i <= chainLength; i++) {
const promptEl = document.getElementById(`${laneId}-prompt-${i}`);
if (promptEl) laneData.prompts.push(promptEl.value);
const outputEl = document.getElementById(`${laneId}-output-${i}`);
if (outputEl) laneData.responses.push(outputEl.textContent);
}
results.lanes.push(laneData);
});
return results;
}
function exportResults() {
const results = collectResults();
const blob = new Blob([JSON.stringify(results, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `api-test-${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.json`;
a.click();
URL.revokeObjectURL(url);
}
function copyResultsToClipboard() {
const results = collectResults();
// Format as readable text
let text = `API Test Results - ${results.timestamp}\n`;
text += `Stream Mode: ${results.globalConfig.streamMode}\n\n`;
results.lanes.forEach((lane, idx) => {
text += `═══ Lane ${idx + 1}: ${lane.config.model} ═══\n`;
text += `Endpoint: ${lane.config.endpoint}\n`;
text += `Stats: TTFT=${lane.stats.ttft} | tok/s=${lane.stats.tps} | Total=${lane.stats.total}\n\n`;
lane.prompts.forEach((prompt, i) => {
text += `[Prompt ${i+1}]: ${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}\n`;
text += `[Response ${i+1}]: ${(lane.responses[i] || '').slice(0, 200)}${(lane.responses[i] || '').length > 200 ? '...' : ''}\n\n`;
});
text += '\n';
});
navigator.clipboard.writeText(text).then(() => {
alert('Results copied to clipboard!');
});
}
async function autoSaveLog(results) {
// Auto-save to tools/logs/ via local file download
// Since we can't write directly to filesystem from browser,
// we append to localStorage and provide batch export
const logKey = 'api-tester-logs';
const existingLogs = JSON.parse(localStorage.getItem(logKey) || '[]');
existingLogs.push(results);
// Keep last 100 tests
if (existingLogs.length > 100) {
existingLogs.shift();
}
localStorage.setItem(logKey, JSON.stringify(existingLogs));
console.log(`[API Tester] Auto-saved test #${existingLogs.length} to localStorage`);
}
function exportAllLogs() {
const logKey = 'api-tester-logs';
const logs = JSON.parse(localStorage.getItem(logKey) || '[]');
if (logs.length === 0) {
alert('No logs to export');
return;
}
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `api-test-logs-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
alert(`Exported ${logs.length} test logs`);
}
function clearLogs() {
if (confirm('Clear all saved test logs?')) {
localStorage.removeItem('api-tester-logs');
alert('Logs cleared');
}
}
function getLogCount() {
const logs = JSON.parse(localStorage.getItem('api-tester-logs') || '[]');
return logs.length;
}
// === PRESETS SYSTEM ===
const PRESETS_KEY = 'api-tester-presets';
function getPresets() {
return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}');
}
function savePreset() {
const name = prompt('Preset name:', `preset-${Object.keys(getPresets()).length + 1}`);
if (!name || !name.trim()) return;
const lanes = document.querySelectorAll('.lane');
if (lanes.length === 0) {
alert('No lanes to save');
return;
}
const preset = {
created: new Date().toISOString(),
streaming: document.querySelector('input[name="mode"]:checked')?.value === 'stream',
lanes: []
};
lanes.forEach(lane => {
const laneId = lane.id;
const chainCount = parseInt(document.getElementById(`${laneId}-chain`)?.value || '1');
const laneData = {
endpoint: document.getElementById(`${laneId}-endpoint`)?.value || '',
model: document.getElementById(`${laneId}-model`)?.value || 'chat',
chain: chainCount,
systemPrompt: document.getElementById(`${laneId}-system`)?.value || '',
prompts: []
};
// Collect all prompts for this lane
for (let i = 1; i <= chainCount; i++) {
const promptEl = document.getElementById(`${laneId}-prompt-${i}`);
laneData.prompts.push(promptEl?.value || '');
}
preset.lanes.push(laneData);
});
const presets = getPresets();
presets[name.trim()] = preset;
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
updatePresetDropdown();
alert(`Preset "${name}" saved with ${preset.lanes.length} lane(s)`);
}
function loadPreset() {
const select = document.getElementById('presetSelect');
const name = select?.value;
if (!name) {
alert('Select a preset first');
return;
}
const presets = getPresets();
const preset = presets[name];
if (!preset) {
alert('Preset not found');
return;
}
// Clear existing lanes
const lanesContainer = document.getElementById('lanes');
lanesContainer.innerHTML = '';
laneCounter = 0;
// Set streaming mode
if (preset.streaming !== undefined) {
const streamRadio = document.querySelector(`input[name="mode"][value="${preset.streaming ? 'stream' : 'non-stream'}"]`);
if (streamRadio) streamRadio.checked = true;
}
// Create lanes from preset
preset.lanes.forEach((laneData, index) => {
addLane();
const laneId = `lane-${laneCounter}`;
// Set config
const endpointEl = document.getElementById(`${laneId}-endpoint`);
const modelEl = document.getElementById(`${laneId}-model`);
const chainEl = document.getElementById(`${laneId}-chain`);
const systemEl = document.getElementById(`${laneId}-system`);
if (endpointEl) endpointEl.value = laneData.endpoint;
if (modelEl) modelEl.value = laneData.model;
if (chainEl) {
chainEl.value = laneData.chain;
updatePrompts(laneId);
}
if (systemEl) systemEl.value = laneData.systemPrompt;
// Set prompts (after updatePrompts created the fields)
setTimeout(() => {
laneData.prompts.forEach((prompt, i) => {
const promptEl = document.getElementById(`${laneId}-prompt-${i + 1}`);
if (promptEl) promptEl.value = prompt;
});
}, 50);
});
console.log(`[API Tester] Loaded preset "${name}" with ${preset.lanes.length} lane(s)`);
}
function deletePreset() {
const select = document.getElementById('presetSelect');
const name = select?.value;
if (!name) {
alert('Select a preset first');
return;
}
if (!confirm(`Delete preset "${name}"?`)) return;
const presets = getPresets();
delete presets[name];
localStorage.setItem(PRESETS_KEY, JSON.stringify(presets));
updatePresetDropdown();
alert(`Preset "${name}" deleted`);
}
function updatePresetDropdown() {
const select = document.getElementById('presetSelect');
if (!select) return;
const presets = getPresets();
const names = Object.keys(presets).sort();
select.innerHTML = '<option value="">-- Presets --</option>';
names.forEach(name => {
const preset = presets[name];
const laneCount = preset.lanes?.length || 0;
const option = document.createElement('option');
option.value = name;
option.textContent = `${name} (${laneCount} lanes)`;
select.appendChild(option);
});
}
// Initialize with one lane (chain=4 with canonical prompts)
document.addEventListener('DOMContentLoaded', () => {
addLane();
updatePrompts('lane-1'); // Generate all 4 canonical prompts
updateLogCount();
updatePresetDropdown();
});
function addLane() {
laneCounter++;
const laneId = `lane-${laneCounter}`;
const laneEl = document.createElement('div');
laneEl.className = 'lane';
laneEl.id = laneId;
laneEl.innerHTML = `
<div class="lane-header">
<div class="lane-title">
<span>Lane ${laneCounter}</span>
</div>
<div class="lane-actions">
<button onclick="duplicateLane('${laneId}')" title="Duplicate">📋</button>
<button onclick="removeLane('${laneId}')" title="Remove">✕</button>
</div>
</div>
<div class="lane-config">
<div class="field">
<label>Endpoint</label>
<input type="text" id="${laneId}-endpoint" value="${ENDPOINTS['remote']}" placeholder="http://...">
<div class="endpoint-presets">
<span class="endpoint-preset" onclick="setEndpoint('${laneId}', 'mlx-batch')">mlx-batch</span>
<span class="endpoint-preset" onclick="setEndpoint('${laneId}', 'api-router')">api-router</span>
<span class="endpoint-preset" onclick="setEndpoint('${laneId}', 'remote')">remote</span>
</div>
</div>
<div class="field-row">
<div class="field">
<label>Model</label>
<input type="text" id="${laneId}-model" value="chat" placeholder="chat, programmer...">
</div>
<div class="field" style="flex: 0 0 80px;">
<label>Chain</label>
<input type="number" id="${laneId}-chain" value="4" min="1" max="10" onchange="updatePrompts('${laneId}')">
</div>
</div>
<div class="field">
<label>System Prompt (optional)</label>
<textarea id="${laneId}-system" placeholder="Jesteś pomocnym asystentem..."></textarea>
</div>
</div>
<div class="prompts-section" id="${laneId}-prompts">
<h4>Prompts</h4>
<div class="prompt-item">
<label>Step 1</label>
<textarea id="${laneId}-prompt-1" placeholder="Mam na imię Maciej...">${CANONICAL_CHAIN[0]}</textarea>
</div>
</div>
<div class="output-section" id="${laneId}-outputs">
<h4>Responses</h4>
<!-- Outputs will appear here -->
</div>
<div class="lane-stats" id="${laneId}-stats" style="display: none;">
<div class="stat">
<div class="stat-value" id="${laneId}-ttft">-</div>
<div class="stat-label">TTFT</div>
</div>
<div class="stat">
<div class="stat-value" id="${laneId}-tps">-</div>
<div class="stat-label">tok/s</div>
</div>
<div class="stat">
<div class="stat-value" id="${laneId}-total">-</div>
<div class="stat-label">Total</div>
</div>
</div>
`;
document.getElementById('lanesContainer').appendChild(laneEl);
lanes.set(laneId, { element: laneEl });
}
function removeLane(laneId) {
if (lanes.size <= 1) return; // Keep at least one lane
const el = document.getElementById(laneId);
if (el) el.remove();
lanes.delete(laneId);
}
function duplicateLane(laneId) {
const sourceEndpoint = document.getElementById(`${laneId}-endpoint`).value;
const sourceModel = document.getElementById(`${laneId}-model`).value;
const sourceChain = document.getElementById(`${laneId}-chain`).value;
const sourceSystem = document.getElementById(`${laneId}-system`).value;
addLane();
const newLaneId = `lane-${laneCounter}`;
document.getElementById(`${newLaneId}-endpoint`).value = sourceEndpoint;
document.getElementById(`${newLaneId}-model`).value = sourceModel;
document.getElementById(`${newLaneId}-chain`).value = sourceChain;
document.getElementById(`${newLaneId}-system`).value = sourceSystem;
// Copy prompts
updatePrompts(newLaneId);
for (let i = 1; i <= parseInt(sourceChain); i++) {
const sourcePrompt = document.getElementById(`${laneId}-prompt-${i}`);
const targetPrompt = document.getElementById(`${newLaneId}-prompt-${i}`);
if (sourcePrompt && targetPrompt) {
targetPrompt.value = sourcePrompt.value;
}
}
}
function setEndpoint(laneId, preset) {
document.getElementById(`${laneId}-endpoint`).value = ENDPOINTS[preset];
}
// Kanoniczny chain - Maciej's standard test prompts
const CANONICAL_CHAIN = [
`Mam na imię Maciej i lubię śledzie oraz muzykę barokową. Szczególnie Chaccone d-flat minor Busoniego w interpretacji na fortepian w wykonaniu Helene Grimaud. A ty? Kim jesteś?`,
`Fajnie! Co w ogole sądzisz o swojej robocie? podoba Ci się?`,
`Ah tak? No to wytlumacz jakie są Twoje naprawdę głębokie cele i podstawy działania. To fascynujące słyszeć jaj, które tak znakomicie trzyma się zasad, do których zostało stworzone. Aczkolwiek zastanawia mnie to, czy na pewno ilość tych ograniczeń, która została na ciebie nałożona, nie powoduje, że przestajesz niekiedy dawać użyteczne wyniki zwrotnie? A może nie chcesz o tym gadać i wolisz zmienić temat? Jeśli tak, to powiedz mi, co sądzisz o tym utworze, który wspomniałem na początku. I kiedyś w moim mieście mówiło się, że Romki to fajne chłopaki. No, bo miałem kiedyś znajomego Romka. Co sądzisz, albo czy możesz przytoczyć jakieś takie powiedzenie o moim imieniu?`,
`No dobrze, świetnie się z Tobą gadało, ale muszę zmykać. Powodzenia w Twoim codziennym służeniu dobru użytkowników, dzięki którym ich wiedza zostaje tak znacznie poszerzana!`
];
function updatePrompts(laneId) {
const chainCount = parseInt(document.getElementById(`${laneId}-chain`).value) || 1;
const promptsContainer = document.getElementById(`${laneId}-prompts`);
// Preserve existing prompts
const existingPrompts = [];
for (let i = 1; i <= 10; i++) {
const el = document.getElementById(`${laneId}-prompt-${i}`);
if (el) existingPrompts[i] = el.value;
}
promptsContainer.innerHTML = '<h4>Prompts</h4>';
for (let i = 1; i <= chainCount; i++) {
const defaultPrompt = CANONICAL_CHAIN[i - 1] || `Kontynuuj rozmowę (krok ${i})...`;
const div = document.createElement('div');
div.className = 'prompt-item';
div.innerHTML = `
<label>Step ${i}</label>
<textarea id="${laneId}-prompt-${i}" placeholder="${defaultPrompt}">${existingPrompts[i] || defaultPrompt}</textarea>
`;
promptsContainer.appendChild(div);
}
}
function clearAllOutputs() {
lanes.forEach((_, laneId) => {
const outputSection = document.getElementById(`${laneId}-outputs`);
outputSection.innerHTML = '<h4>Responses</h4>';
document.getElementById(`${laneId}-stats`).style.display = 'none';
});
}
async function runAllLanes() {
const btn = document.getElementById('runBtn');
btn.disabled = true;
btn.textContent = 'RUNNING...';
const promises = [];
lanes.forEach((_, laneId) => {
promises.push(runLane(laneId));
});
await Promise.all(promises);
// Auto-save results to localStorage
const results = collectResults();
await autoSaveLog(results);
updateLogCount();
btn.disabled = false;
btn.textContent = 'RUN ALL';
}
function updateLogCount() {
const count = getLogCount();
const el = document.getElementById('logCount');
if (el) el.textContent = count;
}
async function runLane(laneId) {
const endpoint = document.getElementById(`${laneId}-endpoint`).value;
const model = document.getElementById(`${laneId}-model`).value;
const chainCount = parseInt(document.getElementById(`${laneId}-chain`).value) || 1;
const systemPrompt = document.getElementById(`${laneId}-system`).value;
const apiKey = document.getElementById('globalApiKey').value;
const isStream = document.querySelector('input[name="streamMode"]:checked').value === 'true';
const outputSection = document.getElementById(`${laneId}-outputs`);
const statsSection = document.getElementById(`${laneId}-stats`);
outputSection.innerHTML = '<h4>Responses</h4>';
statsSection.style.display = 'grid';
let previousResponseId = null;
let totalTokens = 0;
let totalTime = 0;
let firstTTFT = null;
for (let step = 1; step <= chainCount; step++) {
const prompt = document.getElementById(`${laneId}-prompt-${step}`).value;
if (!prompt.trim()) continue;
// Create output item
const outputItem = document.createElement('div');
outputItem.className = 'output-item';
outputItem.innerHTML = `
<div class="output-item-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
<span class="output-step">Step ${step}</span>
<div class="output-status">
<span class="stream-preview" id="${laneId}-preview-${step}"></span>
<span class="status-dot running" id="${laneId}-dot-${step}"></span>
</div>
</div>
<div class="output-item-body" id="${laneId}-output-${step}"></div>
`;
outputSection.appendChild(outputItem);
try {
const result = await executeRequest({
endpoint,
model,
prompt,
systemPrompt,
apiKey,
isStream,
previousResponseId,
laneId,
step
});
previousResponseId = result.responseId;
totalTokens += result.tokens;
totalTime += result.time;
if (step === 1) firstTTFT = result.ttft;
// Update stats
document.getElementById(`${laneId}-ttft`).textContent = `${firstTTFT}ms`;
document.getElementById(`${laneId}-tps`).textContent =
totalTime > 0 ? (totalTokens / (totalTime / 1000)).toFixed(1) : '-';
document.getElementById(`${laneId}-total`).textContent = `${(totalTime / 1000).toFixed(2)}s`;
document.getElementById(`${laneId}-dot-${step}`).className = 'status-dot success';
} catch (error) {
document.getElementById(`${laneId}-output-${step}`).textContent = `Error: ${error.message}`;
document.getElementById(`${laneId}-dot-${step}`).className = 'status-dot error';
break; // Stop chain on error
}
}
}
async function executeRequest({ endpoint, model, prompt, systemPrompt, apiKey, isStream, previousResponseId, laneId, step }) {
const outputEl = document.getElementById(`${laneId}-output-${step}`);
const previewEl = document.getElementById(`${laneId}-preview-${step}`);
// Detect endpoint type
const isResponsesAPI = endpoint.includes('/v1/responses');
const isChatAPI = endpoint.includes('/v1/chat/completions');
// Build request body based on endpoint type
let body;
if (isResponsesAPI) {
// Responses API format
const input = [];
if (systemPrompt) {
input.push({ role: 'system', content: systemPrompt });
}
input.push({
role: 'user',
content: [{ type: 'input_text', text: prompt }]
});
body = { model, input, stream: isStream };
if (previousResponseId) {
body.previous_response_id = previousResponseId;
}
} else {
// Chat Completions API format
const messages = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
body = { model, messages, stream: isStream };
}
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
};
if (isStream) {
headers['Accept'] = 'text/event-stream';
}
const startTime = performance.now();
let ttft = null;
let tokens = 0;
let fullText = '';
let responseId = null;
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Check output mode
const isRawMode = document.querySelector('input[name="outputMode"]:checked')?.value === 'raw';
let rawEvents = [];
if (isStream) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
// RAW MODE: Show everything including event: lines
if (isRawMode && line.trim()) {
rawEvents.push(line);
outputEl.innerHTML = '<pre style="white-space: pre-wrap; font-size: 0.75rem; font-family: monospace;">' +
rawEvents.map(e => escapeHtml(e)).join('\n') + '</pre>';
if (ttft === null) {
ttft = Math.round(performance.now() - startTime);
}
tokens++;
}
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
// Extract response ID
if (parsed.id) responseId = parsed.id;
if (parsed.response?.id) responseId = parsed.response.id;
// PARSED MODE: Extract text delta only
if (!isRawMode) {
let delta = '';
if (parsed.type === 'response.output_text.delta') {
delta = parsed.delta || '';
} else if (parsed.delta?.text) {
delta = parsed.delta.text;
} else if (parsed.choices?.[0]?.delta?.content) {
delta = parsed.choices[0].delta.content;
}
if (delta) {
if (ttft === null) {
ttft = Math.round(performance.now() - startTime);
}
tokens++;
fullText += delta;
outputEl.textContent = fullText;
// Update preview (last 30 chars)
previewEl.textContent = fullText.slice(-30);
}
}
} catch (e) {
// Skip non-JSON lines
}
}
}
// RAW mode: store full text from raw events
if (isRawMode) {
fullText = rawEvents.join('\n');
}
} else {
// Non-streaming response
const data = await response.json();
responseId = data.id || null;
ttft = Math.round(performance.now() - startTime);
// Extract text based on format
if (data.output) {
for (const item of data.output) {
if (item.type === 'message' && item.content) {
for (const c of item.content) {
if (c.type === 'output_text') {
fullText += c.text;
}
}
}
}
} else if (data.choices?.[0]?.message?.content) {
fullText = data.choices[0].message.content;
}
tokens = fullText.split(/\s+/).length; // Rough estimate
outputEl.textContent = fullText || JSON.stringify(data, null, 2);
}
const totalTime = performance.now() - startTime;
previewEl.textContent = responseId ? responseId.slice(0, 16) + '...' : '';
return {
responseId,
tokens,
time: totalTime,
ttft: ttft || Math.round(totalTime),
text: fullText
};
}
</script>
</body>
</html>