|
|
@@ -0,0 +1,514 @@
|
|
|
+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>"""
|