ui.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. from __future__ import annotations
  2. import json
  3. from html import escape
  4. from typing import Any
  5. def _json_for_script(value: Any) -> str:
  6. return json.dumps(value, ensure_ascii=False).replace("</", "<\\/")
  7. def render_page(state: dict[str, Any]) -> str:
  8. return f"""<!doctype html>
  9. <html lang="en">
  10. <head>
  11. <meta charset="utf-8">
  12. <meta name="viewport" content="width=device-width, initial-scale=1">
  13. <title>Model Selector</title>
  14. <style>
  15. :root {{
  16. color-scheme: dark;
  17. --bg: #0b1020;
  18. --panel: rgba(14, 20, 39, 0.86);
  19. --panel-2: rgba(18, 27, 52, 0.95);
  20. --line: rgba(148, 163, 184, 0.16);
  21. --text: #e5eefc;
  22. --muted: #91a4c7;
  23. --accent: #7dd3fc;
  24. --accent-2: #a78bfa;
  25. --good: #34d399;
  26. --warn: #f59e0b;
  27. --bad: #fb7185;
  28. }}
  29. * {{ box-sizing: border-box; }}
  30. body {{
  31. margin: 0;
  32. font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  33. background:
  34. radial-gradient(circle at top left, rgba(125, 211, 252, 0.12), transparent 32%),
  35. radial-gradient(circle at bottom right, rgba(167, 139, 250, 0.15), transparent 28%),
  36. linear-gradient(180deg, #09111f, #050816 64%, #04050b);
  37. color: var(--text);
  38. min-height: 100vh;
  39. }}
  40. header {{
  41. display: flex;
  42. justify-content: space-between;
  43. align-items: center;
  44. gap: 1rem;
  45. padding: 1.4rem 1.5rem;
  46. border-bottom: 1px solid var(--line);
  47. background: rgba(7, 11, 23, 0.82);
  48. position: sticky;
  49. top: 0;
  50. backdrop-filter: blur(12px);
  51. z-index: 5;
  52. }}
  53. h1 {{
  54. margin: 0;
  55. font-size: 1.15rem;
  56. letter-spacing: 0.06em;
  57. text-transform: uppercase;
  58. }}
  59. .meta {{
  60. color: var(--muted);
  61. font-size: 0.92rem;
  62. }}
  63. .toolbar {{
  64. display: flex;
  65. gap: 0.75rem;
  66. align-items: center;
  67. flex-wrap: wrap;
  68. }}
  69. button, input {{
  70. font: inherit;
  71. }}
  72. button {{
  73. border: 1px solid var(--line);
  74. background: linear-gradient(180deg, rgba(125, 211, 252, 0.2), rgba(125, 211, 252, 0.08));
  75. color: var(--text);
  76. padding: 0.65rem 0.9rem;
  77. border-radius: 0.8rem;
  78. cursor: pointer;
  79. }}
  80. button.secondary {{
  81. background: rgba(255, 255, 255, 0.04);
  82. }}
  83. main {{
  84. padding: 1.2rem;
  85. display: grid;
  86. grid-template-columns: minmax(320px, 1fr) minmax(420px, 1.5fr);
  87. gap: 1rem;
  88. }}
  89. .panel {{
  90. background: var(--panel);
  91. border: 1px solid var(--line);
  92. border-radius: 1.2rem;
  93. box-shadow: 0 18px 55px rgba(0, 0, 0, 0.28);
  94. overflow: hidden;
  95. }}
  96. .panel h2 {{
  97. margin: 0;
  98. padding: 1rem 1.1rem 0.2rem;
  99. font-size: 1rem;
  100. }}
  101. .panel .sub {{
  102. padding: 0 1.1rem 1rem;
  103. color: var(--muted);
  104. font-size: 0.9rem;
  105. }}
  106. .section {{
  107. padding: 0.95rem 1.1rem 1.15rem;
  108. border-top: 1px solid var(--line);
  109. }}
  110. .provider-meta {{
  111. display: flex;
  112. gap: 0.5rem;
  113. flex-wrap: wrap;
  114. color: var(--muted);
  115. font-size: 0.85rem;
  116. margin-bottom: 0.65rem;
  117. }}
  118. .tabs {{
  119. display: flex;
  120. gap: 0.5rem;
  121. padding: 0 1.1rem 1rem;
  122. border-top: 1px solid var(--line);
  123. flex-wrap: wrap;
  124. }}
  125. .tab {{
  126. background: rgba(255, 255, 255, 0.03);
  127. }}
  128. .tab.active {{
  129. border-color: rgba(125, 211, 252, 0.45);
  130. background: rgba(125, 211, 252, 0.16);
  131. }}
  132. .provider-panel {{
  133. display: none;
  134. }}
  135. .provider-panel.active {{
  136. display: block;
  137. }}
  138. .card {{
  139. border: 1px solid var(--line);
  140. border-radius: 1rem;
  141. padding: 0.9rem;
  142. margin-bottom: 0.8rem;
  143. background: var(--panel-2);
  144. }}
  145. .card-head {{
  146. display: flex;
  147. justify-content: space-between;
  148. gap: 0.75rem;
  149. align-items: flex-start;
  150. }}
  151. .title {{
  152. font-weight: 650;
  153. margin-bottom: 0.25rem;
  154. }}
  155. .small {{
  156. font-size: 0.84rem;
  157. color: var(--muted);
  158. }}
  159. .chips {{
  160. display: flex;
  161. flex-wrap: wrap;
  162. gap: 0.35rem;
  163. margin-top: 0.55rem;
  164. }}
  165. .chip {{
  166. display: inline-flex;
  167. align-items: center;
  168. border-radius: 999px;
  169. padding: 0.24rem 0.62rem;
  170. font-size: 0.78rem;
  171. border: 1px solid var(--line);
  172. background: rgba(255, 255, 255, 0.04);
  173. }}
  174. .chip.free {{ color: var(--good); }}
  175. .chip.structured {{ color: var(--accent); }}
  176. .chip.tooling {{ color: #c4b5fd; }}
  177. .chip.reasoning {{ color: #fcd34d; }}
  178. .chip.audio, .chip.image {{ color: #f9a8d4; }}
  179. .chip.added {{ color: var(--good); }}
  180. .grid {{
  181. display: grid;
  182. gap: 0.7rem;
  183. }}
  184. table {{
  185. width: 100%;
  186. border-collapse: collapse;
  187. font-size: 0.88rem;
  188. }}
  189. th, td {{
  190. text-align: left;
  191. padding: 0.56rem 0.35rem;
  192. border-bottom: 1px solid var(--line);
  193. vertical-align: top;
  194. }}
  195. th {{
  196. color: var(--muted);
  197. font-weight: 600;
  198. font-size: 0.8rem;
  199. }}
  200. .alias-row {{
  201. display: grid;
  202. grid-template-columns: 1fr 1fr auto;
  203. gap: 0.5rem;
  204. align-items: center;
  205. margin-top: 0.5rem;
  206. }}
  207. .alias-row input {{
  208. width: 100%;
  209. border-radius: 0.8rem;
  210. border: 1px solid var(--line);
  211. background: rgba(255, 255, 255, 0.04);
  212. color: var(--text);
  213. padding: 0.55rem 0.7rem;
  214. }}
  215. .note {{
  216. color: var(--muted);
  217. font-size: 0.82rem;
  218. margin-top: 0.4rem;
  219. }}
  220. .empty {{
  221. color: var(--muted);
  222. font-style: italic;
  223. padding: 0.75rem 0;
  224. }}
  225. .status {{
  226. color: var(--muted);
  227. font-size: 0.85rem;
  228. }}
  229. .split {{
  230. display: flex;
  231. justify-content: space-between;
  232. gap: 0.5rem;
  233. align-items: center;
  234. }}
  235. .panel-tools {{
  236. display: flex;
  237. align-items: center;
  238. gap: 0.75rem;
  239. flex-wrap: wrap;
  240. }}
  241. .filter-input {{
  242. width: min(100%, 420px);
  243. border-radius: 0.8rem;
  244. border: 1px solid var(--line);
  245. background: rgba(255, 255, 255, 0.04);
  246. color: var(--text);
  247. padding: 0.6rem 0.75rem;
  248. }}
  249. .danger {{
  250. border-color: rgba(251, 113, 133, 0.3);
  251. background: rgba(251, 113, 133, 0.12);
  252. }}
  253. @media (max-width: 1100px) {{
  254. main {{ grid-template-columns: 1fr; }}
  255. }}
  256. </style>
  257. </head>
  258. <body>
  259. <header>
  260. <div>
  261. <h1>Model Selector</h1>
  262. <div class="meta">OpenClaw dashboard on port 3300</div>
  263. </div>
  264. <div class="toolbar">
  265. <div class="status" id="status">Loaded</div>
  266. <button onclick="refreshAll()">Refresh now</button>
  267. </div>
  268. </header>
  269. <main>
  270. <section class="panel">
  271. <h2>Configured Models</h2>
  272. <div class="sub">Models currently declared in openclaw.json, with alias support from agents.defaults.models.</div>
  273. <div id="configured"></div>
  274. </section>
  275. <section class="panel">
  276. <h2>Provider Catalog</h2>
  277. <div class="sub">Live or cached provider catalogs, with chips for capabilities and simple pricing cues when the metadata exposes them.</div>
  278. <div class="section">
  279. <div class="panel-tools">
  280. <input id="pattern" class="filter-input" placeholder="Filter pattern" oninput="setPattern(this.value)">
  281. <div class="status" id="provider-summary"></div>
  282. </div>
  283. </div>
  284. <div class="tabs" id="tabs"></div>
  285. <div id="providers"></div>
  286. </section>
  287. </main>
  288. <script>
  289. window.__INITIAL_STATE__ = {_json_for_script(state)};
  290. const providerOrder = ["openai", "groq-cloud", "openrouter"];
  291. const providerTitles = {{
  292. "openai": "OpenAI",
  293. "groq-cloud": "Groq",
  294. "openrouter": "OpenRouter",
  295. }};
  296. let currentProvider = "openai";
  297. let currentPattern = "";
  298. let configuredKeySet = new Set();
  299. function esc(value) {{
  300. return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
  301. }}
  302. function fullKey(model) {{
  303. return `${{model.provider_key}}/${{model.model_id}}`;
  304. }}
  305. function isConfigured(model) {{
  306. return configuredKeySet.has(fullKey(model));
  307. }}
  308. function matchesPattern(model, pattern) {{
  309. if (!pattern) return true;
  310. const haystack = [
  311. model.model_id,
  312. model.name,
  313. model.source,
  314. model.pricing_prompt,
  315. model.pricing_completion,
  316. model.pricing_request,
  317. ...(model.features || []),
  318. ].filter(Boolean).join(" ");
  319. try {{
  320. return new RegExp(pattern, "i").test(haystack);
  321. }} catch (error) {{
  322. return haystack.toLowerCase().includes(pattern.toLowerCase());
  323. }}
  324. }}
  325. function chip(label, className = "") {{
  326. return `<span class="chip ${{className}}">${{esc(label)}}</span>`;
  327. }}
  328. function renderConfigured(models) {{
  329. if (!models.length) return '<div class="empty">No configured models found.</div>';
  330. return `
  331. <div class="grid">
  332. ${{models.map(model => `
  333. <div class="card">
  334. <div class="card-head">
  335. <div>
  336. <div class="title">${{esc(model.name)}}</div>
  337. <div class="small">${{esc(model.model_key)}} · ${{esc(model.provider_label)}}</div>
  338. </div>
  339. <div class="small">
  340. ${{model.alias ? `alias: <strong>${{esc(model.alias)}}</strong>` : 'no alias'}}
  341. </div>
  342. </div>
  343. <div class="chips">
  344. ${{model.reasoning ? chip("reasoning", "reasoning") : ""}}
  345. ${{model.context_window ? chip(`ctx ${{model.context_window}}`) : ""}}
  346. ${{model.max_tokens ? chip(`max ${{model.max_tokens}}`) : ""}}
  347. ${{model.inputs?.map(value => chip(value))?.join("") || ""}}
  348. </div>
  349. <div class="alias-row">
  350. <input data-fullkey="${{esc(model.model_key)}}" data-current-alias="${{esc(model.alias || "")}}" placeholder="Alias" value="${{esc(model.alias || "")}}">
  351. <button class="secondary" onclick="saveAlias('${{esc(model.model_key)}}')">Save alias</button>
  352. <button class="danger" onclick="deleteModel('${{esc(model.model_key)}}')">Delete</button>
  353. </div>
  354. </div>
  355. `).join("")}}
  356. </div>
  357. `;
  358. }}
  359. function featureClass(feature) {{
  360. if (feature === "free") return "free";
  361. if (feature.includes("structured")) return "structured";
  362. if (feature.includes("tool") || feature.includes("browser") || feature.includes("code")) return "tooling";
  363. if (feature.includes("reason")) return "reasoning";
  364. if (feature === "audio" || feature === "image") return feature;
  365. return "";
  366. }}
  367. function renderModelCard(model) {{
  368. const priceBits = [];
  369. if (model.pricing_prompt) priceBits.push(`prompt $${{esc(model.pricing_prompt)}}`);
  370. if (model.pricing_completion) priceBits.push(`completion $${{esc(model.pricing_completion)}}`);
  371. if (model.pricing_request) priceBits.push(`request $${{esc(model.pricing_request)}}`);
  372. const price = priceBits.length ? priceBits.join(" · ") : "price not exposed";
  373. const configured = isConfigured(model);
  374. return `
  375. <div class="card">
  376. <div class="card-head">
  377. <div>
  378. <div class="title">${{esc(model.name)}}</div>
  379. <div class="small">${{esc(model.model_id)}} · ${{esc(model.source)}}</div>
  380. </div>
  381. <div class="small">${{model.active === false ? '<span style="color: var(--warn)">inactive</span>' : ''}}</div>
  382. </div>
  383. <div class="small" style="margin-top:0.35rem">${{price}}</div>
  384. <div class="small">${{model.context_window ? `context ${{model.context_window}}` : "context not exposed"}}${{model.max_tokens ? ` · max ${{model.max_tokens}}` : ""}}</div>
  385. <div class="chips">
  386. ${{(model.features || []).map(feature => chip(feature, featureClass(feature))).join("")}}
  387. ${{configured ? chip("in config", "added") : ""}}
  388. </div>
  389. <div class="alias-row" style="grid-template-columns: 1fr auto;">
  390. <div class="small">${{configured ? 'Already in config' : 'Not in config yet'}}</div>
  391. <button class="secondary" onclick="addModel('${{esc(model.provider_key)}}', '${{esc(model.model_id)}}')" ${{configured ? 'disabled' : ''}}>
  392. ${{configured ? 'Added' : 'Add to config'}}
  393. </button>
  394. </div>
  395. </div>
  396. `;
  397. }}
  398. function renderProviderPanel(providerKey, providerState) {{
  399. const title = providerTitles[providerKey] || providerKey;
  400. const models = (providerState?.models || []).filter(model => matchesPattern(model, currentPattern));
  401. const header = `
  402. <div class="section">
  403. <div class="split">
  404. <strong>${{esc(title)}}</strong>
  405. <span class="status">${{esc(providerState?.source || "missing")}}${{providerState?.updated_at ? " · updated " + esc(providerState.updated_at) : ""}}</span>
  406. </div>
  407. <div class="provider-meta">
  408. <span>${{(providerState?.models || []).length}} models</span>
  409. ${{providerState?.error ? `<span style="color: var(--bad)">${{esc(providerState.error)}}</span>` : ""}}
  410. </div>
  411. </div>
  412. `;
  413. return `${{header}}<div class="section">${{models.length ? models.map(renderModelCard).join("") : '<div class="empty">No provider snapshot yet.</div>'}}</div>`;
  414. }}
  415. function renderTabs() {{
  416. return providerOrder.map(providerKey => `
  417. <button class="tab ${{providerKey === currentProvider ? 'active' : ''}}" onclick="switchProvider('${{providerKey}}')">${{providerTitles[providerKey]}}</button>
  418. `).join("");
  419. }}
  420. function renderProviders(state) {{
  421. const provider = state.providers?.[currentProvider];
  422. return renderProviderPanel(currentProvider, provider);
  423. }}
  424. function applyState(state) {{
  425. configuredKeySet = new Set((state.configured_model_keys || []).map(String));
  426. document.getElementById("configured").innerHTML = renderConfigured(state.configured_models || []);
  427. document.getElementById("tabs").innerHTML = renderTabs();
  428. document.getElementById("providers").innerHTML = renderProviders(state);
  429. document.getElementById("provider-summary").textContent = `${{configuredKeySet.size}} configured models`;
  430. document.getElementById("status").textContent = `Updated ${{new Date().toLocaleTimeString()}}`;
  431. }}
  432. function setPattern(value) {{
  433. currentPattern = value || "";
  434. document.getElementById("providers").innerHTML = renderProviders(window.__APP_STATE__);
  435. }}
  436. async function refreshAll() {{
  437. document.getElementById("status").textContent = "Refreshing...";
  438. await fetch("/api/refresh", {{ method: "POST" }});
  439. const response = await fetch("/api/state");
  440. const state = await response.json();
  441. applyState(state);
  442. }}
  443. async function saveAlias(fullKey) {{
  444. const input = document.querySelector(`input[data-fullkey="${{CSS.escape(fullKey)}}"]`);
  445. const alias = input.value.trim();
  446. await fetch("/api/alias", {{
  447. method: "POST",
  448. headers: {{ "Content-Type": "application/json" }},
  449. body: JSON.stringify({{ full_key: fullKey, alias: alias || null }})
  450. }});
  451. const response = await fetch("/api/state");
  452. window.__APP_STATE__ = await response.json();
  453. applyState(window.__APP_STATE__);
  454. }}
  455. async function deleteModel(fullKey) {{
  456. await fetch("/api/config/models/delete", {{
  457. method: "POST",
  458. headers: {{ "Content-Type": "application/json" }},
  459. body: JSON.stringify({{ full_key: fullKey }})
  460. }});
  461. const response = await fetch("/api/state");
  462. window.__APP_STATE__ = await response.json();
  463. applyState(window.__APP_STATE__);
  464. }}
  465. async function addModel(providerKey, modelId) {{
  466. await fetch("/api/config/models/add", {{
  467. method: "POST",
  468. headers: {{ "Content-Type": "application/json" }},
  469. body: JSON.stringify({{ provider_key: providerKey, model_id: modelId }})
  470. }});
  471. const response = await fetch("/api/state");
  472. window.__APP_STATE__ = await response.json();
  473. applyState(window.__APP_STATE__);
  474. }}
  475. function switchProvider(providerKey) {{
  476. currentProvider = providerKey;
  477. applyState(window.__APP_STATE__);
  478. }}
  479. window.__APP_STATE__ = window.__INITIAL_STATE__;
  480. applyState(window.__APP_STATE__);
  481. setInterval(async () => {{
  482. const response = await fetch("/api/state");
  483. const state = await response.json();
  484. window.__APP_STATE__ = state;
  485. applyState(state);
  486. }}, 60000);
  487. </script>
  488. </body>
  489. </html>"""