778 lines
40 KiB
HTML
778 lines
40 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Model Router — Nexus One AI</title>
|
||
<link rel="stylesheet" href="style.css?v=4">
|
||
<style>
|
||
.mr-content { max-width:1060px; margin:0 auto; padding:32px 40px 60px; }
|
||
@media(max-width:768px){ .mr-content { padding:20px 16px 48px; } }
|
||
|
||
/* ── Stats bar ── */
|
||
.mr-stats { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:24px; }
|
||
@media(max-width:700px){ .mr-stats { grid-template-columns:repeat(2,1fr); } }
|
||
.mr-stat { background:var(--navy2); border:1px solid var(--bdr); border-radius:12px; padding:16px 18px; }
|
||
.mr-stat-label { font-size:11px; font-weight:700; color:var(--lt); text-transform:uppercase; letter-spacing:.5px; margin-bottom:8px; }
|
||
.mr-stat-val { font-size:26px; font-weight:900; color:var(--ink); line-height:1; }
|
||
.mr-stat-sub { font-size:11px; color:var(--lt); margin-top:4px; }
|
||
|
||
/* ── Card ── */
|
||
.mr-card { background:var(--navy2); border:1px solid var(--bdr); border-radius:14px; margin-bottom:20px; overflow:hidden; }
|
||
.mr-card-header { padding:16px 22px; border-bottom:1px solid var(--bdr); display:flex; align-items:center; gap:12px; }
|
||
.mr-card-title { font-size:14px; font-weight:700; color:var(--ink); flex:1; }
|
||
.mr-card-body { padding:22px; }
|
||
|
||
/* ── Route list ── */
|
||
.mr-route { border:1.5px solid var(--bdr); border-radius:12px; background:var(--bg); margin-bottom:12px; overflow:hidden; transition:.15s; }
|
||
.mr-route:hover { border-color:rgba(124,58,237,.25); }
|
||
.mr-route.active-route { border-color:var(--purple); }
|
||
.mr-route-header { display:flex; align-items:center; gap:12px; padding:14px 18px; cursor:pointer; background:var(--navy2); }
|
||
.mr-route.open .mr-route-header { border-bottom:1px solid var(--bdr); }
|
||
.mr-route-drag { color:var(--xlt); cursor:grab; font-size:16px; }
|
||
.mr-route-priority { width:24px; height:24px; border-radius:50%; background:linear-gradient(135deg,var(--purple),var(--pink)); color:white; font-size:11px; font-weight:800; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
|
||
.mr-route-name { font-size:13px; font-weight:700; color:var(--ink); flex:1; }
|
||
.mr-route-model { font-size:12px; color:var(--purple); font-weight:600; background:rgba(124,58,237,.08); border:1px solid rgba(124,58,237,.15); padding:2px 10px; border-radius:20px; }
|
||
.mr-route-toggle { position:relative; width:36px; height:20px; flex-shrink:0; }
|
||
.mr-route-toggle input { opacity:0; width:0; height:0; }
|
||
.mr-route-toggle-track { position:absolute; inset:0; background:var(--bdr); border-radius:20px; transition:.2s; cursor:pointer; }
|
||
.mr-route-toggle input:checked + .mr-route-toggle-track { background:var(--purple); }
|
||
.mr-route-toggle-track::after { content:''; position:absolute; left:3px; top:3px; width:14px; height:14px; background:white; border-radius:50%; transition:.2s; }
|
||
.mr-route-toggle input:checked + .mr-route-toggle-track::after { left:19px; }
|
||
.mr-route-chevron { color:var(--lt); font-size:12px; transition:.2s; }
|
||
.mr-route.open .mr-route-chevron { transform:rotate(90deg); }
|
||
.mr-route-body { padding:18px; display:none; }
|
||
.mr-route.open .mr-route-body { display:block; }
|
||
|
||
/* ── Route fields grid ── */
|
||
.mr-field-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:14px; }
|
||
@media(max-width:600px){ .mr-field-grid { grid-template-columns:1fr; } }
|
||
.mr-field { display:flex; flex-direction:column; gap:5px; }
|
||
.mr-field label { font-size:11px; font-weight:700; color:var(--lt); text-transform:uppercase; letter-spacing:.4px; }
|
||
.mr-field input, .mr-field select, .mr-field textarea {
|
||
padding:9px 12px; border:1.5px solid var(--bdr); border-radius:8px;
|
||
font-family:inherit; font-size:13px; color:var(--ink); background:var(--navy2);
|
||
}
|
||
.mr-field input:focus, .mr-field select:focus { outline:none; border-color:var(--purple); }
|
||
.mr-field-hint { font-size:11px; color:var(--lt); margin-top:2px; }
|
||
|
||
/* ── Conditions chips ── */
|
||
.mr-conditions { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
|
||
.mr-cond-chip { display:inline-flex; align-items:center; gap:6px; background:rgba(124,58,237,.07); border:1px solid rgba(124,58,237,.18); border-radius:20px; padding:4px 12px 4px 10px; font-size:12px; color:var(--purple); font-weight:600; }
|
||
.mr-cond-remove { background:none; border:none; color:var(--lt); cursor:pointer; font-size:14px; padding:0; line-height:1; }
|
||
.mr-cond-remove:hover { color:#DC2626; }
|
||
.mr-add-cond { padding:5px 14px; border-radius:20px; border:1.5px dashed var(--bdr); background:none; font-family:inherit; font-size:12px; font-weight:600; color:var(--med); cursor:pointer; transition:.15s; }
|
||
.mr-add-cond:hover { border-color:var(--purple); color:var(--purple); }
|
||
|
||
/* ── Route actions ── */
|
||
.mr-route-actions { display:flex; gap:8px; margin-top:14px; border-top:1px solid var(--bdr); padding-top:14px; }
|
||
.mr-btn { padding:7px 16px; border-radius:8px; font-family:inherit; font-size:13px; font-weight:600; cursor:pointer; border:none; transition:.15s; }
|
||
.mr-btn.primary { background:linear-gradient(135deg,var(--purple),var(--pink)); color:white; }
|
||
.mr-btn.primary:hover { filter:brightness(1.08); }
|
||
.mr-btn.ghost { background:none; border:1px solid var(--bdr); color:var(--med); }
|
||
.mr-btn.ghost:hover { border-color:var(--purple); color:var(--purple); }
|
||
.mr-btn.danger { background:none; border:1px solid transparent; color:var(--lt); }
|
||
.mr-btn.danger:hover { color:#DC2626; }
|
||
|
||
/* ── Add route btn ── */
|
||
.mr-add-route { display:flex; align-items:center; gap:8px; width:100%; padding:12px 16px; border-radius:10px; border:1.5px dashed var(--bdr); background:var(--navy2); cursor:pointer; font-family:inherit; font-size:13px; font-weight:600; color:var(--med); transition:.15s; justify-content:center; }
|
||
.mr-add-route:hover { border-color:var(--purple); color:var(--purple); }
|
||
|
||
/* ── Fallback / cloud ── */
|
||
.mr-fallback-row { display:flex; align-items:center; gap:14px; padding:14px 18px; border:1px solid var(--bdr); border-radius:10px; background:var(--bg); margin-bottom:10px; }
|
||
.mr-fallback-icon { font-size:20px; }
|
||
.mr-fallback-info { flex:1; }
|
||
.mr-fallback-label { font-size:13px; font-weight:600; color:var(--ink); }
|
||
.mr-fallback-sub { font-size:12px; color:var(--lt); margin-top:2px; }
|
||
.mr-fallback-toggle { position:relative; width:40px; height:22px; flex-shrink:0; }
|
||
.mr-fallback-toggle input { opacity:0; width:0; height:0; }
|
||
.mr-fallback-track { position:absolute; inset:0; background:rgba(107,114,128,.25); border-radius:20px; transition:.2s; cursor:pointer; }
|
||
.mr-fallback-toggle input:checked + .mr-fallback-track { background:var(--purple); }
|
||
.mr-fallback-track::after { content:''; position:absolute; left:3px; top:3px; width:16px; height:16px; background:white; border-radius:50%; transition:.2s; }
|
||
.mr-fallback-toggle input:checked + .mr-fallback-track::after { left:21px; }
|
||
|
||
/* ── Test panel ── */
|
||
.mr-test-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
||
@media(max-width:700px){ .mr-test-grid { grid-template-columns:1fr; } }
|
||
.mr-test-input-area { display:flex; flex-direction:column; gap:10px; }
|
||
.mr-test-prompt { width:100%; box-sizing:border-box; padding:12px 14px; border:1.5px solid var(--bdr); border-radius:10px; font-family:inherit; font-size:13px; color:var(--ink); resize:vertical; min-height:100px; background:var(--navy2); }
|
||
.mr-test-prompt:focus { outline:none; border-color:var(--purple); }
|
||
.mr-test-meta { display:flex; gap:10px; }
|
||
.mr-test-meta select { flex:1; padding:8px 10px; border:1.5px solid var(--bdr); border-radius:8px; font-family:inherit; font-size:13px; color:var(--ink); background:var(--navy2); }
|
||
.mr-test-result { background:var(--bg); border:1px solid var(--bdr); border-radius:12px; padding:18px; min-height:140px; }
|
||
.mr-result-label { font-size:11px; font-weight:700; color:var(--lt); text-transform:uppercase; letter-spacing:.5px; margin-bottom:12px; }
|
||
.mr-result-route { display:flex; flex-direction:column; gap:10px; }
|
||
.mr-result-row { display:flex; align-items:center; gap:10px; }
|
||
.mr-result-key { font-size:12px; color:var(--lt); width:110px; flex-shrink:0; }
|
||
.mr-result-val { font-size:13px; font-weight:600; color:var(--ink); }
|
||
.mr-result-badge { padding:3px 12px; border-radius:20px; font-size:12px; font-weight:700; }
|
||
.mr-result-badge.matched { background:rgba(22,163,74,.1); color:#16A34A; }
|
||
.mr-result-badge.fallback { background:rgba(217,119,6,.1); color:#D97706; }
|
||
.mr-result-badge.default { background:rgba(124,58,237,.1); color:var(--purple); }
|
||
.mr-result-bar { height:4px; background:rgba(124,58,237,.1); border-radius:20px; overflow:hidden; margin-top:4px; }
|
||
.mr-result-bar-fill { height:100%; background:linear-gradient(90deg,var(--purple),var(--pink)); border-radius:20px; transition:width .5s ease; }
|
||
.mr-result-placeholder { color:var(--xlt); font-size:13px; padding-top:8px; }
|
||
|
||
/* ── Flow diagram ── */
|
||
.mr-flow { display:flex; align-items:center; gap:0; overflow-x:auto; padding:8px 0 16px; }
|
||
.mr-flow-node { background:var(--navy2); border:1px solid var(--bdr); border-radius:10px; padding:10px 16px; text-align:center; flex-shrink:0; min-width:90px; }
|
||
.mr-flow-node.active { border-color:var(--purple); background:rgba(124,58,237,.06); }
|
||
.mr-flow-node-icon { font-size:20px; margin-bottom:4px; }
|
||
.mr-flow-node-label { font-size:11px; font-weight:700; color:var(--ink); }
|
||
.mr-flow-node-sub { font-size:10px; color:var(--lt); margin-top:2px; }
|
||
.mr-flow-arrow { color:var(--xlt); font-size:18px; padding:0 6px; flex-shrink:0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="topnav">
|
||
<a href="index.html" class="brand">Nexus One <span>AI</span></a>
|
||
<nav>
|
||
<a href="index.html">Home</a>
|
||
<a href="quickstart.html">Quick Start</a>
|
||
<a href="prompts.html">Prompt Library</a>
|
||
<a href="usecases.html">Use Cases</a>
|
||
<span class="nav-sep"></span>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn">Help ▾</button>
|
||
<div class="nav-drop-menu">
|
||
<span class="nav-drop-cat">LEARN /</span>
|
||
<a href="quickstart.html">Quick Start</a>
|
||
<a href="models.html">Models</a>
|
||
<span class="nav-drop-cat">SUPPORT /</span>
|
||
<a href="troubleshooting.html">Troubleshoot</a>
|
||
<a href="faq.html">FAQ</a>
|
||
<span class="nav-drop-cat">MORE /</span>
|
||
<a href="glossary.html">Glossary</a>
|
||
<a href="whats-new.html">What's New</a>
|
||
</div>
|
||
</div>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn active">Admin ▾</button>
|
||
<div class="nav-drop-menu nav-drop-menu-wide">
|
||
<span class="nav-drop-cat">DOCS /</span>
|
||
<a href="security.html">Security & Privacy</a>
|
||
<a href="admin.html">Admin Guide</a>
|
||
<span class="nav-drop-cat">MONITOR /</span>
|
||
<a href="dashboard.html">Dashboard</a>
|
||
<a href="analytics.html">Usage Analytics</a>
|
||
<a href="audit.html">Audit Log</a>
|
||
<a href="feedback.html">Feedback & Ratings</a>
|
||
<span class="nav-drop-cat">MANAGE /</span>
|
||
<a href="users.html">Users</a>
|
||
<a href="teams.html">Teams</a>
|
||
<a href="models-admin.html">Model Manager</a>
|
||
<a href="training.html">Training</a>
|
||
<a href="knowledge.html">Knowledge Base</a>
|
||
<span class="nav-drop-cat">TOOLS /</span>
|
||
<a href="apikeys.html">API Keys</a>
|
||
<a href="benchmark.html">Benchmarking</a>
|
||
<a href="model-compare.html">Model Compare</a>
|
||
<a href="api-playground.html">API Playground</a>
|
||
<a href="guardrails.html">Guardrails</a>
|
||
<a href="rag-quality.html">RAG Quality</a>
|
||
<a href="router.html">Model Router</a>
|
||
<a href="connectors.html">Connectors</a>
|
||
<span class="nav-drop-cat">SYSTEM /</span>
|
||
<a href="console.html">Console</a>
|
||
<a href="settings.html">Settings</a>
|
||
</div>
|
||
</div>
|
||
<div class="nav-dropdown">
|
||
<button class="nav-drop-btn">AI Tools ▾</button>
|
||
<div class="nav-drop-menu">
|
||
<span class="nav-drop-cat">INTELLIGENCE /</span>
|
||
<a href="documents.html">Document Intelligence</a>
|
||
<a href="chat-multi.html">Multimodal Chat</a>
|
||
<a href="prompt-studio.html">Prompt Studio</a>
|
||
<a href="meeting.html">Meeting Assistant</a>
|
||
<span class="nav-drop-cat">AUTOMATION /</span>
|
||
<a href="agents.html">Agent Builder</a>
|
||
<a href="schedules.html">Scheduled Jobs</a>
|
||
<a href="workflows.html">Workflow Automation</a>
|
||
<span class="nav-drop-cat">QUALITY /</span>
|
||
<a href="evals.html">AI Eval Suite</a>
|
||
<a href="chatrooms.html">Chat Rooms</a>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
<a href="notifications.html" style="position:relative">🔔</a>
|
||
<span class="badge" data-brand="tier">Basic Tier</span>
|
||
<div id="nav-org-logo" class="nav-org-logo"></div>
|
||
</header>
|
||
|
||
<div class="page-hero">
|
||
<div class="label">Intelligence Infrastructure</div>
|
||
<h1>Model Router</h1>
|
||
<p>Define routing rules that automatically send requests to the right model — fast models for simple queries, powerful models for reasoning, specialist models for vision and embeddings.</p>
|
||
</div>
|
||
|
||
<div class="mr-content">
|
||
|
||
<!-- Stats -->
|
||
<div class="mr-stats" id="stats-row">
|
||
<div class="mr-stat"><div class="mr-stat-label">📡 Routes Active</div><div class="mr-stat-val" id="s-active">—</div><div class="mr-stat-sub">of <span id="s-total">—</span> configured</div></div>
|
||
<div class="mr-stat"><div class="mr-stat-label">🚀 Requests Routed</div><div class="mr-stat-val" id="s-routed">—</div><div class="mr-stat-sub">last 24 hours</div></div>
|
||
<div class="mr-stat"><div class="mr-stat-label">⚡ Avg Latency Saved</div><div class="mr-stat-val" id="s-latency">—</div><div class="mr-stat-sub">vs. always using large model</div></div>
|
||
<div class="mr-stat"><div class="mr-stat-label">☁️ Cloud Fallback</div><div class="mr-stat-val" id="s-cloud">—</div><div class="mr-stat-sub" id="s-cloud-sub">—</div></div>
|
||
</div>
|
||
|
||
<!-- Request flow diagram -->
|
||
<div class="mr-card" style="margin-bottom:20px">
|
||
<div class="mr-card-header">
|
||
<span style="font-size:18px">🔀</span>
|
||
<div class="mr-card-title">How Routing Works</div>
|
||
</div>
|
||
<div class="mr-card-body" style="padding:16px 22px">
|
||
<div class="mr-flow">
|
||
<div class="mr-flow-node active"><div class="mr-flow-node-icon">💬</div><div class="mr-flow-node-label">Request</div><div class="mr-flow-node-sub">User query</div></div>
|
||
<div class="mr-flow-arrow">→</div>
|
||
<div class="mr-flow-node active"><div class="mr-flow-node-icon">🔀</div><div class="mr-flow-node-label">Router</div><div class="mr-flow-node-sub">Match rules</div></div>
|
||
<div class="mr-flow-arrow">→</div>
|
||
<div class="mr-flow-node"><div class="mr-flow-node-icon">🖼️</div><div class="mr-flow-node-label">Vision</div><div class="mr-flow-node-sub">Images/docs</div></div>
|
||
<div class="mr-flow-arrow" style="color:var(--bdr)">·</div>
|
||
<div class="mr-flow-node"><div class="mr-flow-node-icon">🧠</div><div class="mr-flow-node-label">Reasoning</div><div class="mr-flow-node-sub">Complex tasks</div></div>
|
||
<div class="mr-flow-arrow" style="color:var(--bdr)">·</div>
|
||
<div class="mr-flow-node"><div class="mr-flow-node-icon">⚡</div><div class="mr-flow-node-label">Fast</div><div class="mr-flow-node-sub">Simple Q&A</div></div>
|
||
<div class="mr-flow-arrow" style="color:var(--bdr)">·</div>
|
||
<div class="mr-flow-node"><div class="mr-flow-node-icon">📚</div><div class="mr-flow-node-label">Embedding</div><div class="mr-flow-node-sub">RAG / search</div></div>
|
||
<div class="mr-flow-arrow" style="color:var(--bdr)">·</div>
|
||
<div class="mr-flow-node" style="border-style:dashed;opacity:.6"><div class="mr-flow-node-icon">☁️</div><div class="mr-flow-node-label">Cloud</div><div class="mr-flow-node-sub">Fallback only</div></div>
|
||
</div>
|
||
<p style="font-size:12px;color:var(--lt);margin:0">Rules are evaluated in priority order (top → bottom). The first matching rule wins. Requests that match no rule go to the Default model.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Routing rules -->
|
||
<div class="mr-card" style="margin-bottom:20px">
|
||
<div class="mr-card-header">
|
||
<span style="font-size:18px">📋</span>
|
||
<div class="mr-card-title">Routing Rules</div>
|
||
<span style="font-size:12px;color:var(--lt)">Drag to reorder priority</span>
|
||
<button class="mr-btn primary" style="margin-left:8px" onclick="saveRoutes()">💾 Save Rules</button>
|
||
</div>
|
||
<div class="mr-card-body">
|
||
<div id="routes-list"><!-- injected --></div>
|
||
<button class="mr-add-route" onclick="addRoute()">+ Add Routing Rule</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fallback & cloud -->
|
||
<div class="mr-card" style="margin-bottom:20px">
|
||
<div class="mr-card-header">
|
||
<span style="font-size:18px">🔩</span>
|
||
<div class="mr-card-title">Fallback & Cloud Settings</div>
|
||
</div>
|
||
<div class="mr-card-body">
|
||
<div class="mr-fallback-row">
|
||
<div class="mr-fallback-icon">🏠</div>
|
||
<div class="mr-fallback-info">
|
||
<div class="mr-fallback-label">Default (Local) Model</div>
|
||
<div class="mr-fallback-sub">Used when no routing rule matches. Always stays on-premises.</div>
|
||
</div>
|
||
<select id="default-model" style="padding:7px 12px;border:1.5px solid var(--bdr);border-radius:8px;font-family:inherit;font-size:13px;color:var(--ink);background:var(--navy2)">
|
||
<option>llama3</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mr-fallback-row">
|
||
<div class="mr-fallback-icon">☁️</div>
|
||
<div class="mr-fallback-info">
|
||
<div class="mr-fallback-label">Cloud Fallback</div>
|
||
<div class="mr-fallback-sub">Route to a cloud provider when local models are overloaded or unavailable. <strong>Disabled by default</strong> — enable only if data privacy policy permits.</div>
|
||
</div>
|
||
<label class="mr-fallback-toggle" title="Enable cloud fallback">
|
||
<input type="checkbox" id="cloud-fallback-toggle" onchange="onCloudToggle()">
|
||
<div class="mr-fallback-track"></div>
|
||
</label>
|
||
</div>
|
||
|
||
<div id="cloud-settings" style="display:none;margin-top:12px;padding:16px;background:rgba(217,119,6,.04);border:1px solid rgba(217,119,6,.2);border-radius:10px">
|
||
<div style="font-size:12px;font-weight:700;color:#D97706;margin-bottom:12px">⚠️ Cloud fallback will send queries outside your on-premises environment. Ensure this complies with your data privacy policy.</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
|
||
<div class="mr-field">
|
||
<label>Provider</label>
|
||
<select id="cloud-provider">
|
||
<option value="openai">OpenAI</option>
|
||
<option value="anthropic">Anthropic</option>
|
||
<option value="azure">Azure OpenAI</option>
|
||
<option value="gemini">Google Gemini</option>
|
||
</select>
|
||
</div>
|
||
<div class="mr-field">
|
||
<label>Fallback Model</label>
|
||
<input type="text" id="cloud-model" placeholder="e.g. gpt-4o-mini" value="gpt-4o-mini">
|
||
</div>
|
||
<div class="mr-field">
|
||
<label>Trigger When</label>
|
||
<select id="cloud-trigger">
|
||
<option value="overload">Local model overloaded</option>
|
||
<option value="error">Local model errors</option>
|
||
<option value="always">Always (no local match)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="mr-field" style="margin-top:10px">
|
||
<label>API Key (stored encrypted)</label>
|
||
<input type="password" id="cloud-key" placeholder="sk-…">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mr-fallback-row" style="margin-top:10px">
|
||
<div class="mr-fallback-icon">🔒</div>
|
||
<div class="mr-fallback-info">
|
||
<div class="mr-fallback-label">PII Shield</div>
|
||
<div class="mr-fallback-sub">Detect and redact personally identifiable information before routing to any cloud provider.</div>
|
||
</div>
|
||
<label class="mr-fallback-toggle">
|
||
<input type="checkbox" id="pii-shield" checked>
|
||
<div class="mr-fallback-track"></div>
|
||
</label>
|
||
</div>
|
||
|
||
<div style="margin-top:14px;display:flex;gap:10px">
|
||
<button class="mr-btn primary" onclick="saveFallback()">💾 Save Fallback Settings</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live test panel -->
|
||
<div class="mr-card">
|
||
<div class="mr-card-header">
|
||
<span style="font-size:18px">🧪</span>
|
||
<div class="mr-card-title">Live Routing Test</div>
|
||
<span style="font-size:12px;color:var(--lt)">See which route would be selected for a given input</span>
|
||
</div>
|
||
<div class="mr-card-body">
|
||
<div class="mr-test-grid">
|
||
<div class="mr-test-input-area">
|
||
<div class="mr-field">
|
||
<label>Test Prompt</label>
|
||
<textarea class="mr-test-prompt" id="test-prompt" placeholder="Type a query to see which routing rule matches…"></textarea>
|
||
</div>
|
||
<div class="mr-test-meta">
|
||
<select id="test-content-type">
|
||
<option value="text">Text only</option>
|
||
<option value="image">Has image attachment</option>
|
||
<option value="document">Has document attachment</option>
|
||
<option value="code">Code / technical</option>
|
||
</select>
|
||
<select id="test-user-role">
|
||
<option value="user">User</option>
|
||
<option value="admin">Admin</option>
|
||
</select>
|
||
</div>
|
||
<button class="mr-btn primary" style="align-self:flex-start" onclick="testRoute()">▶ Test Routing</button>
|
||
</div>
|
||
|
||
<div class="mr-test-result" id="test-result">
|
||
<div class="mr-result-label">Routing Decision</div>
|
||
<div class="mr-result-placeholder">Enter a prompt and click Test Routing to see which rule matches.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
const _API = '/api';
|
||
let routes = [];
|
||
let models = [];
|
||
let routeCounter = 0;
|
||
|
||
const CONDITION_TYPES = {
|
||
keyword: { label:'Contains keyword', placeholder:'e.g. invoice, contract' },
|
||
content_type:{ label:'Content type is', placeholder:'image / document / code / text' },
|
||
token_gt: { label:'Prompt longer than', placeholder:'e.g. 500 tokens' },
|
||
token_lt: { label:'Prompt shorter than', placeholder:'e.g. 100 tokens' },
|
||
user_role: { label:'User role is', placeholder:'admin / user' },
|
||
agent: { label:'Called by agent', placeholder:'agent name pattern' },
|
||
schedule: { label:'Time of day', placeholder:'e.g. 09:00-18:00' },
|
||
};
|
||
|
||
const DEFAULT_ROUTES = [
|
||
{
|
||
_id:1, name:'Vision & Document Analysis', enabled:true,
|
||
model:'llava', priority:1,
|
||
conditions:[{type:'content_type',value:'image'},{type:'content_type',value:'document'}],
|
||
max_tokens:4096, temperature:0.2,
|
||
notes:'Routes image and document inputs to the vision-capable model.'
|
||
},
|
||
{
|
||
_id:2, name:'Deep Reasoning & Long-form', enabled:true,
|
||
model:'llama3:70b', priority:2,
|
||
conditions:[{type:'token_gt',value:'500'},{type:'keyword',value:'analyse,compare,explain why,step by step'}],
|
||
max_tokens:8192, temperature:0.7,
|
||
notes:'Long or complex prompts routed to the larger reasoning model.'
|
||
},
|
||
{
|
||
_id:3, name:'Fast — Simple Q&A', enabled:true,
|
||
model:'llama3:8b', priority:3,
|
||
conditions:[{type:'token_lt',value:'120'}],
|
||
max_tokens:512, temperature:0.5,
|
||
notes:'Short, factual questions routed to the fastest small model.'
|
||
},
|
||
{
|
||
_id:4, name:'Code & Technical', enabled:true,
|
||
model:'codellama', priority:4,
|
||
conditions:[{type:'content_type',value:'code'},{type:'keyword',value:'function,debug,error,script,code'}],
|
||
max_tokens:4096, temperature:0.1,
|
||
notes:'Code-related queries routed to the code-specialised model.'
|
||
},
|
||
{
|
||
_id:5, name:'RAG / Embedding Queries', enabled:true,
|
||
model:'nomic-embed-text', priority:5,
|
||
conditions:[{type:'keyword',value:'search,find in,what does the policy say,according to'}],
|
||
max_tokens:2048, temperature:0.3,
|
||
notes:'Retrieval-augmented queries use the embedding model for search, then the default for generation.'
|
||
},
|
||
];
|
||
|
||
// ── Render routes ──────────────────────────────────────────────────────────────
|
||
function renderRoutes() {
|
||
const modelOpts = models.map(m => `<option value="${esc(m)}">${esc(m)}</option>`).join('') ||
|
||
['llama3','llama3:70b','llama3:8b','llava','codellama','nomic-embed-text'].map(m=>`<option>${m}</option>`).join('');
|
||
|
||
document.getElementById('routes-list').innerHTML = routes.map((r,i) => `
|
||
<div class="mr-route ${r.enabled ? 'active-route' : ''}" id="route-${r._id}">
|
||
<div class="mr-route-header" onclick="toggleRoute(${r._id})">
|
||
<span class="mr-route-drag" title="Drag to reorder">⋮⋮</span>
|
||
<div class="mr-route-priority">${i+1}</div>
|
||
<div class="mr-route-name">${esc(r.name)}</div>
|
||
<div class="mr-route-model">${esc(r.model)}</div>
|
||
<label class="mr-route-toggle" onclick="event.stopPropagation()" title="${r.enabled?'Disable':'Enable'} route">
|
||
<input type="checkbox" ${r.enabled?'checked':''} onchange="toggleEnabled(${r._id},this.checked)">
|
||
<div class="mr-route-toggle-track"></div>
|
||
</label>
|
||
<span class="mr-route-chevron">▶</span>
|
||
</div>
|
||
<div class="mr-route-body">
|
||
<div class="mr-field-grid">
|
||
<div class="mr-field">
|
||
<label>Route Name</label>
|
||
<input type="text" value="${esc(r.name)}" onchange="updateRoute(${r._id},'name',this.value)">
|
||
</div>
|
||
<div class="mr-field">
|
||
<label>Target Model</label>
|
||
<select onchange="updateRoute(${r._id},'model',this.value)">
|
||
${modelOpts.replace(`value="${esc(r.model)}"`,`value="${esc(r.model)}" selected`)}
|
||
</select>
|
||
</div>
|
||
<div class="mr-field">
|
||
<label>Max Tokens</label>
|
||
<input type="number" value="${r.max_tokens||2048}" min="128" max="32768" onchange="updateRoute(${r._id},'max_tokens',parseInt(this.value))">
|
||
</div>
|
||
<div class="mr-field">
|
||
<label>Temperature</label>
|
||
<input type="number" value="${r.temperature??0.7}" min="0" max="1" step="0.1" onchange="updateRoute(${r._id},'temperature',parseFloat(this.value))">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mr-field" style="margin-bottom:10px">
|
||
<label>Match Conditions <span style="color:var(--lt);font-weight:400">— ALL conditions must match (AND logic)</span></label>
|
||
<div class="mr-conditions" id="conds-${r._id}">
|
||
${(r.conditions||[]).map((c,ci) => renderCondChip(r._id, c, ci)).join('')}
|
||
</div>
|
||
<div style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||
<select id="cond-type-${r._id}" style="padding:6px 10px;border:1px solid var(--bdr);border-radius:8px;font-family:inherit;font-size:12px;color:var(--ink);background:var(--navy2)">
|
||
${Object.entries(CONDITION_TYPES).map(([k,v])=>`<option value="${k}">${v.label}</option>`).join('')}
|
||
</select>
|
||
<input type="text" id="cond-val-${r._id}" placeholder="value…" style="padding:6px 10px;border:1px solid var(--bdr);border-radius:8px;font-family:inherit;font-size:12px;color:var(--ink);flex:1;min-width:120px;background:var(--navy2)">
|
||
<button class="mr-add-cond" onclick="addCondition(${r._id})">+ Add condition</button>
|
||
</div>
|
||
<div class="mr-field-hint">No conditions = always match (use as catch-all at the bottom of the list)</div>
|
||
</div>
|
||
|
||
<div class="mr-field">
|
||
<label>Notes</label>
|
||
<input type="text" value="${esc(r.notes||'')}" placeholder="Optional description" onchange="updateRoute(${r._id},'notes',this.value)">
|
||
</div>
|
||
|
||
<div class="mr-route-actions">
|
||
<button class="mr-btn ghost" onclick="moveRoute(${r._id},-1)">↑ Move Up</button>
|
||
<button class="mr-btn ghost" onclick="moveRoute(${r._id},1)">↓ Move Down</button>
|
||
<button class="mr-btn danger" onclick="deleteRoute(${r._id})">🗑 Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
updateStats();
|
||
}
|
||
|
||
function renderCondChip(routeId, c, ci) {
|
||
const ct = CONDITION_TYPES[c.type] || { label: c.type };
|
||
return `<div class="mr-cond-chip">
|
||
<span>${ct.label}: <strong>${esc(c.value)}</strong></span>
|
||
<button class="mr-cond-remove" onclick="removeCondition(${routeId},${ci})" title="Remove">×</button>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Route operations ──────────────────────────────────────────────────────────
|
||
function addRoute() {
|
||
routes.push({
|
||
_id: ++routeCounter, name: 'New Route', enabled: true,
|
||
model: models[0] || 'llama3', priority: routes.length + 1,
|
||
conditions: [], max_tokens: 2048, temperature: 0.7, notes: ''
|
||
});
|
||
renderRoutes();
|
||
setTimeout(() => {
|
||
const el = document.getElementById(`route-${routeCounter}`);
|
||
if (el) { el.classList.add('open'); el.scrollIntoView({behavior:'smooth',block:'nearest'}); }
|
||
}, 50);
|
||
}
|
||
|
||
function toggleRoute(id) {
|
||
document.getElementById(`route-${id}`)?.classList.toggle('open');
|
||
}
|
||
|
||
function updateRoute(id, key, val) {
|
||
const r = routes.find(r => r._id === id);
|
||
if (r) { r[key] = val; if (key === 'model') renderRoutes(); }
|
||
}
|
||
|
||
function toggleEnabled(id, val) {
|
||
const r = routes.find(r => r._id === id);
|
||
if (r) { r.enabled = val; updateStats(); }
|
||
document.getElementById(`route-${id}`)?.classList.toggle('active-route', val);
|
||
}
|
||
|
||
function addCondition(routeId) {
|
||
const type = document.getElementById(`cond-type-${routeId}`)?.value;
|
||
const val = document.getElementById(`cond-val-${routeId}`)?.value.trim();
|
||
if (!val) { alert('Enter a condition value'); return; }
|
||
const r = routes.find(r => r._id === routeId);
|
||
if (!r) return;
|
||
r.conditions.push({ type, value: val });
|
||
document.getElementById(`cond-val-${routeId}`).value = '';
|
||
renderRoutes();
|
||
}
|
||
|
||
function removeCondition(routeId, ci) {
|
||
const r = routes.find(r => r._id === routeId);
|
||
if (r) { r.conditions.splice(ci, 1); renderRoutes(); }
|
||
}
|
||
|
||
function moveRoute(id, dir) {
|
||
const idx = routes.findIndex(r => r._id === id);
|
||
if (idx < 0) return;
|
||
const ni = idx + dir;
|
||
if (ni < 0 || ni >= routes.length) return;
|
||
[routes[idx], routes[ni]] = [routes[ni], routes[idx]];
|
||
renderRoutes();
|
||
}
|
||
|
||
function deleteRoute(id) {
|
||
if (!confirm('Delete this routing rule?')) return;
|
||
routes = routes.filter(r => r._id !== id);
|
||
renderRoutes();
|
||
}
|
||
|
||
// ── Save ───────────────────────────────────────────────────────────────────────
|
||
async function saveRoutes() {
|
||
const payload = { routes: routes.map(({_id,...r},i) => ({...r, priority:i+1})) };
|
||
try {
|
||
const res = await fetch(`${_API}/router/rules`, {
|
||
method:'PUT', credentials:'include',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!res.ok) throw new Error('Save failed');
|
||
showToast('Routing rules saved');
|
||
} catch(_) {
|
||
// Save to localStorage as fallback
|
||
localStorage.setItem('cezen_router_rules', JSON.stringify(payload.routes));
|
||
showToast('Rules saved locally');
|
||
}
|
||
}
|
||
|
||
async function saveFallback() {
|
||
const payload = {
|
||
default_model: document.getElementById('default-model').value,
|
||
cloud_enabled: document.getElementById('cloud-fallback-toggle').checked,
|
||
cloud_provider: document.getElementById('cloud-provider')?.value,
|
||
cloud_model: document.getElementById('cloud-model')?.value,
|
||
cloud_trigger: document.getElementById('cloud-trigger')?.value,
|
||
cloud_key: document.getElementById('cloud-key')?.value,
|
||
pii_shield: document.getElementById('pii-shield').checked,
|
||
};
|
||
try {
|
||
await fetch(`${_API}/router/fallback`, {
|
||
method:'PUT', credentials:'include',
|
||
headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
showToast('Fallback settings saved');
|
||
} catch(_) {
|
||
localStorage.setItem('cezen_router_fallback', JSON.stringify(payload));
|
||
showToast('Settings saved locally');
|
||
}
|
||
}
|
||
|
||
// ── Test routing ───────────────────────────────────────────────────────────────
|
||
function testRoute() {
|
||
const prompt = document.getElementById('test-prompt').value.trim();
|
||
const contentType = document.getElementById('test-content-type').value;
|
||
const userRole = document.getElementById('test-user-role').value;
|
||
|
||
if (!prompt) { alert('Enter a prompt to test'); return; }
|
||
|
||
const tokenEst = Math.round(prompt.split(/\s+/).length * 1.3);
|
||
let matched = null;
|
||
|
||
for (const r of routes) {
|
||
if (!r.enabled) continue;
|
||
if (!r.conditions || r.conditions.length === 0) { matched = r; break; }
|
||
|
||
const allMatch = r.conditions.every(c => {
|
||
if (c.type === 'keyword') {
|
||
const kws = c.value.split(',').map(k=>k.trim().toLowerCase());
|
||
return kws.some(k => prompt.toLowerCase().includes(k));
|
||
}
|
||
if (c.type === 'content_type') return c.value === contentType;
|
||
if (c.type === 'token_gt') return tokenEst > parseInt(c.value);
|
||
if (c.type === 'token_lt') return tokenEst < parseInt(c.value);
|
||
if (c.type === 'user_role') return c.value === userRole;
|
||
return false;
|
||
});
|
||
if (allMatch) { matched = r; break; }
|
||
}
|
||
|
||
const defaultModel = document.getElementById('default-model')?.value || 'llama3';
|
||
const cloudEnabled = document.getElementById('cloud-fallback-toggle')?.checked;
|
||
|
||
const el = document.getElementById('test-result');
|
||
if (matched) {
|
||
el.innerHTML = `
|
||
<div class="mr-result-label">Routing Decision</div>
|
||
<div class="mr-result-route">
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Status</div>
|
||
<div class="mr-result-val"><span class="mr-result-badge matched">✓ Rule matched</span></div>
|
||
</div>
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Route</div>
|
||
<div class="mr-result-val">${esc(matched.name)}</div>
|
||
</div>
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Model</div>
|
||
<div class="mr-result-val" style="color:var(--purple);font-weight:700">${esc(matched.model)}</div>
|
||
</div>
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Priority</div>
|
||
<div class="mr-result-val">#${routes.indexOf(matched)+1}</div>
|
||
</div>
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Est. tokens</div>
|
||
<div class="mr-result-val">${tokenEst}</div>
|
||
</div>
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Max tokens</div>
|
||
<div class="mr-result-val">${matched.max_tokens}</div>
|
||
</div>
|
||
<div style="margin-top:4px">
|
||
<div style="font-size:11px;color:var(--lt);margin-bottom:4px">Conditions matched</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||
${(matched.conditions||[]).map(c=>`<span style="background:rgba(22,163,74,.1);color:#16A34A;border-radius:20px;padding:2px 10px;font-size:11px;font-weight:600">${esc(CONDITION_TYPES[c.type]?.label||c.type)}: ${esc(c.value)}</span>`).join('') || '<span style="color:var(--lt);font-size:12px">No conditions (catch-all)</span>'}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
el.innerHTML = `
|
||
<div class="mr-result-label">Routing Decision</div>
|
||
<div class="mr-result-route">
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Status</div>
|
||
<div class="mr-result-val"><span class="mr-result-badge default">→ Default model</span></div>
|
||
</div>
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Model</div>
|
||
<div class="mr-result-val" style="color:var(--purple);font-weight:700">${esc(defaultModel)}</div>
|
||
</div>
|
||
<div class="mr-result-row">
|
||
<div class="mr-result-key">Reason</div>
|
||
<div class="mr-result-val" style="color:var(--lt)">No active rule matched</div>
|
||
</div>
|
||
${cloudEnabled ? `<div class="mr-result-row"><div class="mr-result-key">Cloud</div><div class="mr-result-val"><span class="mr-result-badge fallback">☁ Fallback enabled</span></div></div>` : ''}
|
||
<div style="margin-top:10px;font-size:12px;color:var(--lt)">
|
||
Add a rule or adjust conditions to route this type of query to a specific model.
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Stats ──────────────────────────────────────────────────────────────────────
|
||
function updateStats() {
|
||
const active = routes.filter(r=>r.enabled).length;
|
||
document.getElementById('s-active').textContent = active;
|
||
document.getElementById('s-total').textContent = routes.length;
|
||
}
|
||
|
||
// ── Cloud toggle ───────────────────────────────────────────────────────────────
|
||
function onCloudToggle() {
|
||
const on = document.getElementById('cloud-fallback-toggle').checked;
|
||
document.getElementById('cloud-settings').style.display = on ? '' : 'none';
|
||
document.getElementById('s-cloud').textContent = on ? 'ON' : 'OFF';
|
||
document.getElementById('s-cloud-sub').textContent = on ? 'Fallback active' : 'Local only';
|
||
}
|
||
|
||
// ── Load ───────────────────────────────────────────────────────────────────────
|
||
async function loadRoutes() {
|
||
try {
|
||
const res = await fetch(`${_API}/router/rules`, { credentials:'include' });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
routes = (data.routes || []).map((r,i) => ({...r, _id:++routeCounter}));
|
||
if (routes.length) { renderRoutes(); return; }
|
||
}
|
||
} catch(_) {}
|
||
// Try localStorage
|
||
const saved = localStorage.getItem('cezen_router_rules');
|
||
if (saved) {
|
||
try { routes = JSON.parse(saved).map(r=>({...r,_id:++routeCounter})); renderRoutes(); return; } catch(_) {}
|
||
}
|
||
// Use defaults
|
||
routes = DEFAULT_ROUTES.map(r=>({...r,_id:++routeCounter}));
|
||
routeCounter = routes.length;
|
||
renderRoutes();
|
||
}
|
||
|
||
async function loadModels() {
|
||
try {
|
||
const res = await fetch(`${_API}/models/list`, { credentials:'include' });
|
||
const d = await res.json();
|
||
models = (d.models||[]).map(m=>m.name);
|
||
const sel = document.getElementById('default-model');
|
||
sel.innerHTML = models.map(m=>`<option value="${esc(m)}">${esc(m)}</option>`).join('') || '<option>llama3</option>';
|
||
} catch(_) { models = ['llama3','llama3:70b','llama3:8b','llava','codellama','nomic-embed-text']; }
|
||
}
|
||
|
||
function showToast(msg) {
|
||
const t = document.createElement('div');
|
||
t.textContent = msg;
|
||
t.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#F0FDFA;color:#6D28D9;border:1px solid #99F6E4;border-radius:10px;padding:11px 18px;font-size:13px;font-weight:600;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.1)';
|
||
document.body.appendChild(t);
|
||
setTimeout(()=>t.remove(), 2500);
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ── Boot ───────────────────────────────────────────────────────────────────────
|
||
(async () => {
|
||
await loadModels();
|
||
await loadRoutes();
|
||
// Stats defaults
|
||
document.getElementById('s-routed').textContent = '1,284';
|
||
document.getElementById('s-latency').textContent = '340ms';
|
||
document.getElementById('s-cloud').textContent = 'OFF';
|
||
document.getElementById('s-cloud-sub').textContent = 'Local only';
|
||
})();
|
||
</script>
|
||
|
||
<script src="auth.js"></script>
|
||
<script src="branding.js"></script>
|
||
</body>
|
||
</html>
|