Brings the ISO-packaged portal copy current with the live cezen-portal/ working directory, which had drifted across the entire V1.0 GA polish initiative (Phases 1-10) plus this session's topnav icon-order bugfix. - 52 existing files updated, 2 new pages added (about.html, health.html) - cgit/cezen-portal/ is now byte-identical to cezen-portal/ - Backend files (rag_ingest.py, cezen_license.py) already in sync; main.py intentionally left as-is (cgit is ahead there with workstation-category commercial-fit logic not yet backported to the live working copy) Next: rebuild the ISO (bash autoinstall/build-iso.sh) to bake this in.
787 lines
46 KiB
HTML
787 lines
46 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=12">
|
||
<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 { width:20px; height:20px; color:var(--text-secondary); }
|
||
.mr-fallback-icon svg { width:100%; height:100%; }
|
||
.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 { width:20px; height:20px; margin:0 auto 4px; color:var(--text-secondary); }
|
||
.mr-flow-node-icon svg { width:100%; height:100%; }
|
||
.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">Governance & Audit</a>
|
||
<a href="health.html">Health Center</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 Catalog</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">MCP & Enterprise 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">AI Workspace</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 Studio</a>
|
||
<a href="schedules.html">Scheduled Jobs</a>
|
||
<a href="workflows.html">Workflow Designer</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" class="nav-icon-btn" style="position:relative" aria-label="Notifications">
|
||
<span class="icon-svg icon-md" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></span>
|
||
</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 class="icon-svg icon-md" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M16 3h5v5"/><path d="M4 20 21 3"/><path d="M21 16v5h-5"/><path d="M15 15 21 21"/><path d="M4 4h5l6 6"/></svg></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 icon-svg"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></div><div class="mr-flow-node-label">Request</div><div class="mr-flow-node-sub">User query</div></div>
|
||
<div class="mr-flow-arrow"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg></span></div>
|
||
<div class="mr-flow-node active"><div class="mr-flow-node-icon icon-svg"><svg viewBox="0 0 24 24"><path d="M16 3h5v5"/><path d="M4 20 21 3"/><path d="M21 16v5h-5"/><path d="M15 15 21 21"/><path d="M4 4h5l6 6"/></svg></div><div class="mr-flow-node-label">Router</div><div class="mr-flow-node-sub">Match rules</div></div>
|
||
<div class="mr-flow-arrow"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg></span></div>
|
||
<div class="mr-flow-node"><div class="mr-flow-node-icon icon-svg"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg></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 icon-svg"><svg viewBox="0 0 24 24"><path d="M9.5 2a3.5 3.5 0 0 0-3.4 2.7A3.5 3.5 0 0 0 3 8.2a3.5 3.5 0 0 0 1.2 2.6A3.5 3.5 0 0 0 4 12a3.5 3.5 0 0 0 2.1 3.2A3.5 3.5 0 0 0 9.5 18H10v4"/><path d="M14.5 2a3.5 3.5 0 0 1 3.4 2.7A3.5 3.5 0 0 1 21 8.2a3.5 3.5 0 0 1-1.2 2.6A3.5 3.5 0 0 1 20 12a3.5 3.5 0 0 1-2.1 3.2A3.5 3.5 0 0 1 14.5 18H14v4"/><path d="M10 10h4"/><path d="M9 14h6"/></svg></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 icon-svg"><svg viewBox="0 0 24 24"><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg></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 icon-svg"><svg viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></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 icon-svg"><svg viewBox="0 0 24 24"><path d="M7 18a4 4 0 1 1 .8-7.9A5 5 0 0 1 17 9a4 4 0 1 1 0 9Z"/></svg></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 from top to 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 class="icon-svg icon-md" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 3h6"/><path d="M10 7h4"/><rect x="5" y="5" width="14" height="16" rx="2"/></svg></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()"><span class="icon-label"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 21h14"/><path d="M19 21V7l-3-3H5v17"/><path d="M9 10h6"/><path d="M9 14h6"/></svg></span><span>Save Rules</span></span></button>
|
||
</div>
|
||
<div class="mr-card-body">
|
||
<div id="routes-list"><!-- injected --></div>
|
||
<button class="mr-add-route" onclick="addRoute()"><span class="icon-label"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></span><span>Add Routing Rule</span></span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fallback & cloud -->
|
||
<div class="mr-card" style="margin-bottom:20px">
|
||
<div class="mr-card-header">
|
||
<span class="icon-svg icon-md" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01A1.65 1.65 0 0 0 10.5 3.1V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></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 icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 11.5 12 4l9 7.5"/><path d="M5 10v10h14V10"/></svg></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 icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7 18a4 4 0 1 1 .8-7.9A5 5 0 0 1 17 9a4 4 0 1 1 0 9Z"/></svg></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 icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></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()"><span class="icon-label"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 21h14"/><path d="M19 21V7l-3-3H5v17"/><path d="M9 10h6"/><path d="M9 14h6"/></svg></span><span>Save Fallback Settings</span></span></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live test panel -->
|
||
<div class="mr-card">
|
||
<div class="mr-card-header">
|
||
<span class="icon-svg icon-md" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 3v5l-5 9a2 2 0 0 0 1.75 3h12.5A2 2 0 0 0 20 17l-5-9V3"/><path d="M9 8h6"/></svg></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()"><span class="icon-label"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m8 5 11 7-11 7z"/></svg></span><span>Test Routing</span></span></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;
|
||
function routerIconSvg(name, sizeClass = 'icon-md') {
|
||
const icons = {
|
||
route:'<svg viewBox="0 0 24 24"><path d="M16 3h5v5"/><path d="M4 20 21 3"/><path d="M21 16v5h-5"/><path d="M15 15 21 21"/><path d="M4 4h5l6 6"/></svg>',
|
||
plus:'<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
|
||
up:'<svg viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></svg>',
|
||
down:'<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>',
|
||
trash:'<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>',
|
||
chevronRight:'<svg viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg>',
|
||
drag:'<svg viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>'
|
||
};
|
||
return `<span class="icon-svg ${sizeClass}" aria-hidden="true">${icons[name] || icons.route}</span>`;
|
||
}
|
||
|
||
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">${routerIconSvg('drag', 'icon-sm')}</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">${routerIconSvg('chevronRight', 'icon-sm')}</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 <span class="cezen-help" tabindex="0" data-tip="The maximum length of the model's response for queries routed here, in tokens (roughly 3/4 of a word each). Higher allows longer answers but costs more time/VRAM.">?</span></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 <span class="cezen-help" tabindex="0" data-tip="Controls randomness: near 0 gives focused, deterministic answers (good for facts/code); near 1 gives more varied, creative answers (good for brainstorming/writing).">?</span></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> <span class="cezen-help" tabindex="0" data-tip="Routes are checked top to bottom; the first route whose conditions all match wins. Put your most specific routes near the top and a no-conditions catch-all route at the bottom.">?</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})"><span class="icon-label">${routerIconSvg('plus', 'icon-sm')}<span>Add condition</span></span></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)"><span class="icon-label">${routerIconSvg('up', 'icon-sm')}<span>Move Up</span></span></button>
|
||
<button class="mr-btn ghost" onclick="moveRoute(${r._id},1)"><span class="icon-label">${routerIconSvg('down', 'icon-sm')}<span>Move Down</span></span></button>
|
||
<button class="mr-btn danger" onclick="deleteRoute(${r._id})"><span class="icon-label">${routerIconSvg('trash', 'icon-sm')}<span>Delete</span></span></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) { showToast('Enter a condition value before saving this rule.', 'warn'); 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) { showToast('Enter a prompt to test.', 'warn'); 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 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?v=3"></script>
|
||
</body>
|
||
</html>
|