| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514 |
- from __future__ import annotations
- import json
- from html import escape
- from typing import Any
- def _json_for_script(value: Any) -> str:
- return json.dumps(value, ensure_ascii=False).replace("</", "<\\/")
- def render_page(state: dict[str, Any]) -> str:
- return f"""<!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Model Selector</title>
- <style>
- :root {{
- color-scheme: dark;
- --bg: #0b1020;
- --panel: rgba(14, 20, 39, 0.86);
- --panel-2: rgba(18, 27, 52, 0.95);
- --line: rgba(148, 163, 184, 0.16);
- --text: #e5eefc;
- --muted: #91a4c7;
- --accent: #7dd3fc;
- --accent-2: #a78bfa;
- --good: #34d399;
- --warn: #f59e0b;
- --bad: #fb7185;
- }}
- * {{ box-sizing: border-box; }}
- body {{
- margin: 0;
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- background:
- radial-gradient(circle at top left, rgba(125, 211, 252, 0.12), transparent 32%),
- radial-gradient(circle at bottom right, rgba(167, 139, 250, 0.15), transparent 28%),
- linear-gradient(180deg, #09111f, #050816 64%, #04050b);
- color: var(--text);
- min-height: 100vh;
- }}
- header {{
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 1rem;
- padding: 1.4rem 1.5rem;
- border-bottom: 1px solid var(--line);
- background: rgba(7, 11, 23, 0.82);
- position: sticky;
- top: 0;
- backdrop-filter: blur(12px);
- z-index: 5;
- }}
- h1 {{
- margin: 0;
- font-size: 1.15rem;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- }}
- .meta {{
- color: var(--muted);
- font-size: 0.92rem;
- }}
- .toolbar {{
- display: flex;
- gap: 0.75rem;
- align-items: center;
- flex-wrap: wrap;
- }}
- button, input {{
- font: inherit;
- }}
- button {{
- border: 1px solid var(--line);
- background: linear-gradient(180deg, rgba(125, 211, 252, 0.2), rgba(125, 211, 252, 0.08));
- color: var(--text);
- padding: 0.65rem 0.9rem;
- border-radius: 0.8rem;
- cursor: pointer;
- }}
- button.secondary {{
- background: rgba(255, 255, 255, 0.04);
- }}
- main {{
- padding: 1.2rem;
- display: grid;
- grid-template-columns: minmax(320px, 1fr) minmax(420px, 1.5fr);
- gap: 1rem;
- }}
- .panel {{
- background: var(--panel);
- border: 1px solid var(--line);
- border-radius: 1.2rem;
- box-shadow: 0 18px 55px rgba(0, 0, 0, 0.28);
- overflow: hidden;
- }}
- .panel h2 {{
- margin: 0;
- padding: 1rem 1.1rem 0.2rem;
- font-size: 1rem;
- }}
- .panel .sub {{
- padding: 0 1.1rem 1rem;
- color: var(--muted);
- font-size: 0.9rem;
- }}
- .section {{
- padding: 0.95rem 1.1rem 1.15rem;
- border-top: 1px solid var(--line);
- }}
- .provider-meta {{
- display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
- color: var(--muted);
- font-size: 0.85rem;
- margin-bottom: 0.65rem;
- }}
- .tabs {{
- display: flex;
- gap: 0.5rem;
- padding: 0 1.1rem 1rem;
- border-top: 1px solid var(--line);
- flex-wrap: wrap;
- }}
- .tab {{
- background: rgba(255, 255, 255, 0.03);
- }}
- .tab.active {{
- border-color: rgba(125, 211, 252, 0.45);
- background: rgba(125, 211, 252, 0.16);
- }}
- .provider-panel {{
- display: none;
- }}
- .provider-panel.active {{
- display: block;
- }}
- .card {{
- border: 1px solid var(--line);
- border-radius: 1rem;
- padding: 0.9rem;
- margin-bottom: 0.8rem;
- background: var(--panel-2);
- }}
- .card-head {{
- display: flex;
- justify-content: space-between;
- gap: 0.75rem;
- align-items: flex-start;
- }}
- .title {{
- font-weight: 650;
- margin-bottom: 0.25rem;
- }}
- .small {{
- font-size: 0.84rem;
- color: var(--muted);
- }}
- .chips {{
- display: flex;
- flex-wrap: wrap;
- gap: 0.35rem;
- margin-top: 0.55rem;
- }}
- .chip {{
- display: inline-flex;
- align-items: center;
- border-radius: 999px;
- padding: 0.24rem 0.62rem;
- font-size: 0.78rem;
- border: 1px solid var(--line);
- background: rgba(255, 255, 255, 0.04);
- }}
- .chip.free {{ color: var(--good); }}
- .chip.structured {{ color: var(--accent); }}
- .chip.tooling {{ color: #c4b5fd; }}
- .chip.reasoning {{ color: #fcd34d; }}
- .chip.audio, .chip.image {{ color: #f9a8d4; }}
- .chip.added {{ color: var(--good); }}
- .grid {{
- display: grid;
- gap: 0.7rem;
- }}
- table {{
- width: 100%;
- border-collapse: collapse;
- font-size: 0.88rem;
- }}
- th, td {{
- text-align: left;
- padding: 0.56rem 0.35rem;
- border-bottom: 1px solid var(--line);
- vertical-align: top;
- }}
- th {{
- color: var(--muted);
- font-weight: 600;
- font-size: 0.8rem;
- }}
- .alias-row {{
- display: grid;
- grid-template-columns: 1fr 1fr auto;
- gap: 0.5rem;
- align-items: center;
- margin-top: 0.5rem;
- }}
- .alias-row input {{
- width: 100%;
- border-radius: 0.8rem;
- border: 1px solid var(--line);
- background: rgba(255, 255, 255, 0.04);
- color: var(--text);
- padding: 0.55rem 0.7rem;
- }}
- .note {{
- color: var(--muted);
- font-size: 0.82rem;
- margin-top: 0.4rem;
- }}
- .empty {{
- color: var(--muted);
- font-style: italic;
- padding: 0.75rem 0;
- }}
- .status {{
- color: var(--muted);
- font-size: 0.85rem;
- }}
- .split {{
- display: flex;
- justify-content: space-between;
- gap: 0.5rem;
- align-items: center;
- }}
- .panel-tools {{
- display: flex;
- align-items: center;
- gap: 0.75rem;
- flex-wrap: wrap;
- }}
- .filter-input {{
- width: min(100%, 420px);
- border-radius: 0.8rem;
- border: 1px solid var(--line);
- background: rgba(255, 255, 255, 0.04);
- color: var(--text);
- padding: 0.6rem 0.75rem;
- }}
- .danger {{
- border-color: rgba(251, 113, 133, 0.3);
- background: rgba(251, 113, 133, 0.12);
- }}
- @media (max-width: 1100px) {{
- main {{ grid-template-columns: 1fr; }}
- }}
- </style>
- </head>
- <body>
- <header>
- <div>
- <h1>Model Selector</h1>
- <div class="meta">OpenClaw dashboard on port 3300</div>
- </div>
- <div class="toolbar">
- <div class="status" id="status">Loaded</div>
- <button onclick="refreshAll()">Refresh now</button>
- </div>
- </header>
- <main>
- <section class="panel">
- <h2>Configured Models</h2>
- <div class="sub">Models currently declared in openclaw.json, with alias support from agents.defaults.models.</div>
- <div id="configured"></div>
- </section>
- <section class="panel">
- <h2>Provider Catalog</h2>
- <div class="sub">Live or cached provider catalogs, with chips for capabilities and simple pricing cues when the metadata exposes them.</div>
- <div class="section">
- <div class="panel-tools">
- <input id="pattern" class="filter-input" placeholder="Filter pattern" oninput="setPattern(this.value)">
- <div class="status" id="provider-summary"></div>
- </div>
- </div>
- <div class="tabs" id="tabs"></div>
- <div id="providers"></div>
- </section>
- </main>
- <script>
- window.__INITIAL_STATE__ = {_json_for_script(state)};
- const providerOrder = ["openai", "groq-cloud", "openrouter"];
- const providerTitles = {{
- "openai": "OpenAI",
- "groq-cloud": "Groq",
- "openrouter": "OpenRouter",
- }};
- let currentProvider = "openai";
- let currentPattern = "";
- let configuredKeySet = new Set();
- function esc(value) {{
- return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
- }}
- function fullKey(model) {{
- return `${{model.provider_key}}/${{model.model_id}}`;
- }}
- function isConfigured(model) {{
- return configuredKeySet.has(fullKey(model));
- }}
- function matchesPattern(model, pattern) {{
- if (!pattern) return true;
- const haystack = [
- model.model_id,
- model.name,
- model.source,
- model.pricing_prompt,
- model.pricing_completion,
- model.pricing_request,
- ...(model.features || []),
- ].filter(Boolean).join(" ");
- try {{
- return new RegExp(pattern, "i").test(haystack);
- }} catch (error) {{
- return haystack.toLowerCase().includes(pattern.toLowerCase());
- }}
- }}
- function chip(label, className = "") {{
- return `<span class="chip ${{className}}">${{esc(label)}}</span>`;
- }}
- function renderConfigured(models) {{
- if (!models.length) return '<div class="empty">No configured models found.</div>';
- return `
- <div class="grid">
- ${{models.map(model => `
- <div class="card">
- <div class="card-head">
- <div>
- <div class="title">${{esc(model.name)}}</div>
- <div class="small">${{esc(model.model_key)}} · ${{esc(model.provider_label)}}</div>
- </div>
- <div class="small">
- ${{model.alias ? `alias: <strong>${{esc(model.alias)}}</strong>` : 'no alias'}}
- </div>
- </div>
- <div class="chips">
- ${{model.reasoning ? chip("reasoning", "reasoning") : ""}}
- ${{model.context_window ? chip(`ctx ${{model.context_window}}`) : ""}}
- ${{model.max_tokens ? chip(`max ${{model.max_tokens}}`) : ""}}
- ${{model.inputs?.map(value => chip(value))?.join("") || ""}}
- </div>
- <div class="alias-row">
- <input data-fullkey="${{esc(model.model_key)}}" data-current-alias="${{esc(model.alias || "")}}" placeholder="Alias" value="${{esc(model.alias || "")}}">
- <button class="secondary" onclick="saveAlias('${{esc(model.model_key)}}')">Save alias</button>
- <button class="danger" onclick="deleteModel('${{esc(model.model_key)}}')">Delete</button>
- </div>
- </div>
- `).join("")}}
- </div>
- `;
- }}
- function featureClass(feature) {{
- if (feature === "free") return "free";
- if (feature.includes("structured")) return "structured";
- if (feature.includes("tool") || feature.includes("browser") || feature.includes("code")) return "tooling";
- if (feature.includes("reason")) return "reasoning";
- if (feature === "audio" || feature === "image") return feature;
- return "";
- }}
- function renderModelCard(model) {{
- const priceBits = [];
- if (model.pricing_prompt) priceBits.push(`prompt $${{esc(model.pricing_prompt)}}`);
- if (model.pricing_completion) priceBits.push(`completion $${{esc(model.pricing_completion)}}`);
- if (model.pricing_request) priceBits.push(`request $${{esc(model.pricing_request)}}`);
- const price = priceBits.length ? priceBits.join(" · ") : "price not exposed";
- const configured = isConfigured(model);
- return `
- <div class="card">
- <div class="card-head">
- <div>
- <div class="title">${{esc(model.name)}}</div>
- <div class="small">${{esc(model.model_id)}} · ${{esc(model.source)}}</div>
- </div>
- <div class="small">${{model.active === false ? '<span style="color: var(--warn)">inactive</span>' : ''}}</div>
- </div>
- <div class="small" style="margin-top:0.35rem">${{price}}</div>
- <div class="small">${{model.context_window ? `context ${{model.context_window}}` : "context not exposed"}}${{model.max_tokens ? ` · max ${{model.max_tokens}}` : ""}}</div>
- <div class="chips">
- ${{(model.features || []).map(feature => chip(feature, featureClass(feature))).join("")}}
- ${{configured ? chip("in config", "added") : ""}}
- </div>
- <div class="alias-row" style="grid-template-columns: 1fr auto;">
- <div class="small">${{configured ? 'Already in config' : 'Not in config yet'}}</div>
- <button class="secondary" onclick="addModel('${{esc(model.provider_key)}}', '${{esc(model.model_id)}}')" ${{configured ? 'disabled' : ''}}>
- ${{configured ? 'Added' : 'Add to config'}}
- </button>
- </div>
- </div>
- `;
- }}
- function renderProviderPanel(providerKey, providerState) {{
- const title = providerTitles[providerKey] || providerKey;
- const models = (providerState?.models || []).filter(model => matchesPattern(model, currentPattern));
- const header = `
- <div class="section">
- <div class="split">
- <strong>${{esc(title)}}</strong>
- <span class="status">${{esc(providerState?.source || "missing")}}${{providerState?.updated_at ? " · updated " + esc(providerState.updated_at) : ""}}</span>
- </div>
- <div class="provider-meta">
- <span>${{(providerState?.models || []).length}} models</span>
- ${{providerState?.error ? `<span style="color: var(--bad)">${{esc(providerState.error)}}</span>` : ""}}
- </div>
- </div>
- `;
- return `${{header}}<div class="section">${{models.length ? models.map(renderModelCard).join("") : '<div class="empty">No provider snapshot yet.</div>'}}</div>`;
- }}
- function renderTabs() {{
- return providerOrder.map(providerKey => `
- <button class="tab ${{providerKey === currentProvider ? 'active' : ''}}" onclick="switchProvider('${{providerKey}}')">${{providerTitles[providerKey]}}</button>
- `).join("");
- }}
- function renderProviders(state) {{
- const provider = state.providers?.[currentProvider];
- return renderProviderPanel(currentProvider, provider);
- }}
- function applyState(state) {{
- configuredKeySet = new Set((state.configured_model_keys || []).map(String));
- document.getElementById("configured").innerHTML = renderConfigured(state.configured_models || []);
- document.getElementById("tabs").innerHTML = renderTabs();
- document.getElementById("providers").innerHTML = renderProviders(state);
- document.getElementById("provider-summary").textContent = `${{configuredKeySet.size}} configured models`;
- document.getElementById("status").textContent = `Updated ${{new Date().toLocaleTimeString()}}`;
- }}
- function setPattern(value) {{
- currentPattern = value || "";
- document.getElementById("providers").innerHTML = renderProviders(window.__APP_STATE__);
- }}
- async function refreshAll() {{
- document.getElementById("status").textContent = "Refreshing...";
- await fetch("/api/refresh", {{ method: "POST" }});
- const response = await fetch("/api/state");
- const state = await response.json();
- applyState(state);
- }}
- async function saveAlias(fullKey) {{
- const input = document.querySelector(`input[data-fullkey="${{CSS.escape(fullKey)}}"]`);
- const alias = input.value.trim();
- await fetch("/api/alias", {{
- method: "POST",
- headers: {{ "Content-Type": "application/json" }},
- body: JSON.stringify({{ full_key: fullKey, alias: alias || null }})
- }});
- const response = await fetch("/api/state");
- window.__APP_STATE__ = await response.json();
- applyState(window.__APP_STATE__);
- }}
- async function deleteModel(fullKey) {{
- await fetch("/api/config/models/delete", {{
- method: "POST",
- headers: {{ "Content-Type": "application/json" }},
- body: JSON.stringify({{ full_key: fullKey }})
- }});
- const response = await fetch("/api/state");
- window.__APP_STATE__ = await response.json();
- applyState(window.__APP_STATE__);
- }}
- async function addModel(providerKey, modelId) {{
- await fetch("/api/config/models/add", {{
- method: "POST",
- headers: {{ "Content-Type": "application/json" }},
- body: JSON.stringify({{ provider_key: providerKey, model_id: modelId }})
- }});
- const response = await fetch("/api/state");
- window.__APP_STATE__ = await response.json();
- applyState(window.__APP_STATE__);
- }}
- function switchProvider(providerKey) {{
- currentProvider = providerKey;
- applyState(window.__APP_STATE__);
- }}
- window.__APP_STATE__ = window.__INITIAL_STATE__;
- applyState(window.__APP_STATE__);
- setInterval(async () => {{
- const response = await fetch("/api/state");
- const state = await response.json();
- window.__APP_STATE__ = state;
- applyState(state);
- }}, 60000);
- </script>
- </body>
- </html>"""
|