| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"> |
| <title>Adaptive UI</title> |
| <style> |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} |
| :root{ |
| --bg:#fff;--surface:#f8f9fa;--border:#e2e8f0;--text:#1a202c; |
| --muted:#718096;--accent:#3b82f6;--accent-h:#2563eb; |
| --success:#22c55e;--danger:#ef4444;--warn:#f59e0b; |
| --radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08); |
| } |
| @media(prefers-color-scheme:dark){:root{ |
| --bg:#1a202c;--surface:#2d3748;--border:#4a5568;--text:#f7fafc;--muted:#a0aec0; |
| }} |
| body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px; |
| line-height:1.5;color:var(--text);background:var(--bg);padding:12px 14px 110px;} |
|
|
| /* ββ utils ββ */ |
| .title{font-size:15px;font-weight:600;margin-bottom:10px} |
| .muted{color:var(--muted);font-size:12px} |
| .loading{padding:24px;text-align:center;color:var(--muted)} |
| .error{color:var(--danger);padding:10px;background:#fff5f5;border:1px solid #fed7d7;border-radius:var(--radius)} |
| .btn{display:inline-flex;align-items:center;gap:5px;padding:7px 14px;border:none; |
| border-radius:var(--radius);background:var(--accent);color:#fff;font-size:13px; |
| font-weight:500;cursor:pointer;transition:background .15s} |
| .btn:hover{background:var(--accent-h)} |
| .btn:disabled{opacity:.5;cursor:not-allowed} |
| .btn-ghost{background:transparent;color:var(--accent);border:1px solid var(--accent)} |
| .btn-ghost:hover{background:var(--accent);color:#fff} |
| .chip{display:inline-block;padding:2px 8px;border-radius:99px;font-size:11px; |
| background:var(--surface);border:1px solid var(--border);color:var(--muted)} |
|
|
| /* ββ reply bar ββ */ |
| #reply-bar{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius); |
| padding:10px 12px;margin-bottom:10px;font-size:13px;display:none} |
| #reply-bar.thinking{color:var(--muted);font-style:italic} |
|
|
| /* ββ chat bar ββ */ |
| #chat-bar{position:fixed;bottom:0;left:0;right:0;background:var(--bg); |
| border-top:1px solid var(--border);padding:10px 14px;display:flex;gap:8px;z-index:100} |
| #chat-input{flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius); |
| background:var(--surface);color:var(--text);font-size:13px;outline:none} |
| #chat-input:focus{border-color:var(--accent)} |
| #chat-send{padding:8px 14px;border:none;border-radius:var(--radius);background:var(--accent); |
| color:#fff;font-size:18px;cursor:pointer;transition:background .15s;line-height:1} |
| #chat-send:hover{background:var(--accent-h)} |
|
|
| /* ββ form ββ */ |
| .form-field{margin-bottom:12px} |
| .form-field label{display:block;font-weight:500;margin-bottom:4px} |
| .form-field .hint{color:var(--muted);font-size:12px;margin-bottom:4px} |
| .form-field input:not([type=range]),.form-field select,.form-field textarea{ |
| width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:var(--radius); |
| background:var(--surface);color:var(--text);font-size:14px;outline:none;transition:border-color .15s} |
| .form-field input:focus,.form-field select:focus,.form-field textarea:focus{border-color:var(--accent)} |
| .req{color:var(--danger);margin-left:2px} |
| .number-row{display:flex;align-items:center;gap:4px} |
| .step-btn{width:30px;height:34px;border:1px solid var(--border);background:var(--surface); |
| color:var(--text);border-radius:var(--radius);font-size:16px;cursor:pointer; |
| display:flex;align-items:center;justify-content:center;flex-shrink:0} |
| .step-btn:hover{background:var(--border)} |
| .number-row input{text-align:center} |
| .range-row{display:flex;align-items:center;gap:8px} |
| .range-row input[type=range]{flex:1;accent-color:var(--accent)} |
| .range-display{min-width:42px;text-align:right;font-weight:600;color:var(--accent)} |
| .form-status{margin-top:8px;font-size:13px;padding:6px 10px;border-radius:var(--radius);display:none} |
| .form-status.ok{display:block;background:#f0fdf4;color:#166534} |
| .form-status.err{display:block;background:#fff5f5;color:var(--danger)} |
|
|
| /* ββ chart ββ */ |
| .chart-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px} |
| .chart-bars{display:flex;align-items:flex-end;gap:6px;height:160px;padding-bottom:0} |
| .bar-col{display:flex;flex-direction:column;align-items:center;gap:3px;flex:1;min-width:32px;position:relative} |
| .bar-fill{width:100%;background:var(--accent);border-radius:4px 4px 0 0;cursor:pointer; |
| transition:background .15s,height .25s ease;position:relative;min-height:4px} |
| .bar-fill:hover{background:var(--accent-h)} |
| .bar-fill.selected{background:var(--warn);outline:2px solid var(--warn)} |
| .bar-label{font-size:10px;color:var(--muted);text-align:center;max-width:52px; |
| overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| .bar-val{font-size:10px;color:var(--muted)} |
| .bar-edit{display:none;width:100%;padding:3px 4px;border:1px solid var(--accent); |
| border-radius:4px;font-size:12px;text-align:center;background:var(--bg);color:var(--text)} |
| .bar-col.editing .bar-fill{display:none} |
| .bar-col.editing .bar-edit{display:block} |
| .bar-tooltip{position:absolute;top:-28px;left:50%;transform:translateX(-50%); |
| background:#1a202c;color:#fff;font-size:11px;padding:3px 6px;border-radius:4px; |
| white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .15s} |
| .bar-fill:hover + .bar-tooltip{opacity:1} |
| .chart-hint{font-size:11px;color:var(--muted);margin-top:6px} |
|
|
| /* ββ card ββ */ |
| .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius); |
| box-shadow:var(--shadow);overflow:hidden} |
| .card-item{display:flex;justify-content:space-between;align-items:center; |
| padding:8px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .12s} |
| .card-item:last-child{border-bottom:none} |
| .card-item:hover{background:var(--border)} |
| .card-item .key{color:var(--muted);font-size:13px} |
| .card-item .val{font-weight:600;display:flex;align-items:center;gap:6px} |
| .card-item .ask-icon{opacity:0;font-size:12px;color:var(--accent);transition:opacity .12s} |
| .card-item:hover .ask-icon{opacity:1} |
| .card-header{padding:10px 12px;border-bottom:1px solid var(--border)} |
|
|
| /* ββ table ββ */ |
| .table-wrap{overflow-x:auto} |
| table{width:100%;border-collapse:collapse} |
| th{background:var(--surface);font-weight:600;text-align:left;padding:8px 10px; |
| border-bottom:2px solid var(--border);cursor:pointer;user-select:none;white-space:nowrap} |
| th:hover{color:var(--accent)} |
| th .sort-arrow{font-size:10px;margin-left:4px;opacity:.4} |
| th.asc .sort-arrow::after{content:" β²"} |
| th.desc .sort-arrow::after{content:" βΌ"} |
| th:not(.asc):not(.desc) .sort-arrow::after{content:" β
"} |
| td{padding:7px 10px;border-bottom:1px solid var(--border);font-size:13px} |
| tr:last-child td{border-bottom:none} |
| tbody tr{cursor:pointer;transition:background .12s} |
| tbody tr:hover td{background:var(--surface)} |
| tbody tr.selected td{background:#eff6ff} |
|
|
| /* ββ figures ββ */ |
| .fig-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin-bottom:12px} |
| .fig-cell{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px} |
| .fig-cell label{display:block;font-size:11px;color:var(--muted);margin-bottom:4px} |
| .fig-cell .fig-unit{font-size:10px;color:var(--muted);margin-top:3px} |
| .fig-cell input{width:100%;padding:6px;border:1px solid var(--border);border-radius:4px; |
| background:var(--bg);color:var(--text);font-size:16px;font-weight:600;text-align:center;outline:none} |
| .fig-cell input:focus{border-color:var(--accent)} |
| </style> |
| </head> |
| <body> |
|
|
| <div id="reply-bar"></div> |
| <div id="root"><p class="loading">Loadingβ¦</p></div> |
|
|
| <div id="chat-bar"> |
| <input id="chat-input" placeholder="Ask about this dataβ¦" /> |
| <button id="chat-send">β</button> |
| </div> |
|
|
| <script> |
| |
| let _spec = null; |
| |
| |
| async function getSpec() { |
| if (window.openai?.ready) { |
| return new Promise(res => { |
| window.openai.ready(async api => { |
| const s = await api.state.get(); |
| res(s?.structuredContent ?? null); |
| api.state.onChanged(ns => render(ns?.structuredContent ?? null)); |
| }); |
| }); |
| } |
| return new Promise(res => { |
| let done = false; |
| window.addEventListener("message", ev => { |
| const d = ev.data; |
| if (d?.type === "mcp:structuredContent") { |
| if (!done) { done = true; res(d.payload); } |
| else render(d.payload); |
| } |
| if (d?.type === "mcp:toolResult") showReply(d.text, d.spec); |
| }); |
| window.parent?.postMessage({ type: "mcp:ready" }, "*"); |
| setTimeout(() => { if (!done) { done = true; res(null); } }, 4000); |
| }); |
| } |
| |
| async function sendToChat(userText) { |
| showThinking(); |
| try { |
| if (window.openai?.callTool) { |
| const res = await window.openai.callTool("respond", { |
| messages: [{ role: "user", content: userText }] |
| }); |
| showReply(res?.content?.[0]?.text ?? "", res?.structuredContent); |
| } else { |
| window.parent?.postMessage({ type: "mcp:toolCall", tool: "respond", |
| args: { messages: [{ role: "user", content: userText }] } }, "*"); |
| showReply("Request sent β awaiting responseβ¦"); |
| } |
| } catch(e) { showReply("Error: " + e.message); } |
| } |
| |
| function showThinking() { |
| const bar = document.getElementById("reply-bar"); |
| bar.className = "thinking"; bar.textContent = "Thinkingβ¦"; bar.style.display = "block"; |
| } |
| function showReply(text, spec) { |
| const bar = document.getElementById("reply-bar"); |
| bar.className = ""; bar.style.display = text ? "block" : "none"; |
| bar.textContent = text || ""; |
| if (spec) render(spec); |
| } |
| |
| |
| function render(spec) { |
| _spec = spec; |
| const root = document.getElementById("root"); |
| if (!spec) { root.innerHTML = `<p class="muted">No UI data.</p>`; return; } |
| const { component, props = {}, _widget, ...rest } = spec; |
| const p = Object.keys(props).length ? props : rest; |
| try { |
| const map = { form: renderForm, chart: renderChart, |
| card: renderCard, table: renderTable, figures: renderFigures }; |
| root.innerHTML = (map[component] ?? (() => `<p class="muted">[${component}]</p>`))(p); |
| if (component === "form") attachForm(p); |
| if (component === "chart") attachChart(p); |
| if (component === "card") attachCard(); |
| if (component === "table") attachTable(p); |
| if (component === "figures") attachFigures(p); |
| } catch(e) { root.innerHTML = `<p class="error">${e.message}</p>`; } |
| } |
| |
| |
| function renderForm(p) { |
| const fields = (p.fields || []).map(f => { |
| const req = f.required ? `<span class="req">*</span>` : ""; |
| const hint = f.hint ? `<p class="hint">${esc(f.hint)}</p>` : ""; |
| return `<div class="form-field"> |
| <label for="f_${esc(f.name)}">${esc(f.label || f.name)}${req}</label> |
| ${hint}${buildInput(f)} |
| </div>`; |
| }).join(""); |
| return `${p.title ? `<p class="title">${esc(p.title)}</p>` : ""} |
| <form id="adaptiveForm">${fields} |
| <button type="submit" class="btn" id="submitBtn">${esc(p.submitLabel || "Submit")}</button> |
| <div class="form-status" id="formStatus"></div> |
| </form>`; |
| } |
| |
| function buildInput(f) { |
| const id = `f_${esc(f.name)}`, name = esc(f.name), req = f.required ? "required" : ""; |
| const type = f.type || "text"; |
| if (type === "number" || type === "currency" || type === "integer") { |
| const step = f.step ?? (type === "integer" ? 1 : type === "currency" ? 0.01 : 1); |
| const prefix = type === "currency" ? `<span style="padding:0 4px;color:var(--muted)">$</span>` : ""; |
| return `<div class="number-row"> |
| <button type="button" class="step-btn" data-for="${id}" data-d="-${step}">β</button> |
| ${prefix}<input type="number" id="${id}" name="${name}" ${req} |
| value="${f.default ?? 0}" min="${f.min ?? ""}" max="${f.max ?? ""}" step="${step}" style="flex:1"> |
| <button type="button" class="step-btn" data-for="${id}" data-d="${step}">+</button> |
| </div>`; |
| } |
| if (type === "range") { |
| return `<div class="range-row"> |
| <input type="range" id="${id}" name="${name}" ${req} |
| min="${f.min ?? 0}" max="${f.max ?? 100}" step="${f.step ?? 1}" value="${f.default ?? 0}" |
| oninput="document.getElementById('rv_${esc(f.name)}').textContent=this.value"> |
| <span class="range-display" id="rv_${esc(f.name)}">${f.default ?? 0}</span> |
| </div>`; |
| } |
| if (type === "select") { |
| const opts = (f.options || []).map(o => |
| `<option value="${esc(o.value ?? o)}">${esc(o.label ?? o)}</option>`).join(""); |
| return `<select id="${id}" name="${name}" ${req}>${opts}</select>`; |
| } |
| if (type === "textarea") return `<textarea id="${id}" name="${name}" ${req} rows="3"></textarea>`; |
| return `<input type="${esc(type)}" id="${id}" name="${name}" ${req} |
| placeholder="${esc(f.placeholder || "")}" value="${esc(f.default || "")}">`; |
| } |
| |
| function attachForm(p) { |
| document.querySelectorAll(".step-btn").forEach(btn => { |
| btn.addEventListener("click", () => { |
| const el = document.getElementById(btn.dataset.for); |
| if (el) { el.value = +(el.value || 0) + +btn.dataset.d; el.dispatchEvent(new Event("change")); } |
| }); |
| }); |
| document.getElementById("adaptiveForm")?.addEventListener("submit", async ev => { |
| ev.preventDefault(); |
| const btn = document.getElementById("submitBtn"); |
| const status = document.getElementById("formStatus"); |
| btn.disabled = true; |
| const data = Object.fromEntries(new FormData(ev.target).entries()); |
| const summary = Object.entries(data).map(([k, v]) => `${k}: ${v}`).join(", "); |
| status.className = "form-status"; |
| try { |
| await sendToChat(`Form submitted β ${p.title || "response"}: ${summary}`); |
| status.className = "form-status ok"; status.textContent = p.successMessage || "Submitted!"; |
| } catch(e) { |
| status.className = "form-status err"; status.textContent = "Error: " + e.message; |
| } finally { btn.disabled = false; } |
| }); |
| } |
| |
| |
| function renderChart(p) { |
| const data = p.data || {}; |
| const labels = data.labels || p.labels || []; |
| const ds = data.datasets || p.datasets || []; |
| const vals = ds[0]?.data || data.values || []; |
| const max = Math.max(...vals, 1); |
| const bars = labels.map((lbl, i) => { |
| const v = vals[i] ?? 0; |
| const h = Math.max(Math.round((v / max) * 140), 4); |
| return `<div class="bar-col" data-idx="${i}" data-label="${esc(String(lbl))}" data-val="${v}"> |
| <span class="bar-val">${v}</span> |
| <div class="bar-fill" style="height:${h}px" title="${esc(String(lbl))}: ${v}"></div> |
| <div class="bar-tooltip">${esc(String(lbl))}: ${v} β click to edit</div> |
| <input class="bar-edit" type="number" value="${v}" data-idx="${i}"> |
| <span class="bar-label">${esc(String(lbl))}</span> |
| </div>`; |
| }).join(""); |
| return `<div class="chart-header"> |
| ${p.title ? `<p class="title" style="margin:0">${esc(p.title)}</p>` : "<span></span>"} |
| <button class="btn btn-ghost" id="analyzeBtn" style="font-size:12px;padding:4px 10px">Analyze β</button> |
| </div> |
| <div class="chart-bars" id="chartBars">${bars}</div> |
| <p class="chart-hint">Click a bar to edit its value Β· Click Analyze to discuss the data</p>`; |
| } |
| |
| function attachChart(p) { |
| const data = p.data || {}; |
| const labels = data.labels || p.labels || []; |
| const ds = data.datasets || p.datasets || []; |
| const vals = [...(ds[0]?.data || data.values || [])]; |
| |
| document.querySelectorAll(".bar-col").forEach(col => { |
| const idx = +col.dataset.idx; |
| col.querySelector(".bar-fill")?.addEventListener("click", () => { |
| document.querySelectorAll(".bar-col").forEach(c => c.classList.remove("editing")); |
| col.classList.add("editing"); |
| col.querySelector(".bar-edit")?.focus(); |
| }); |
| col.querySelector(".bar-edit")?.addEventListener("keydown", ev => { |
| if (ev.key === "Enter") { |
| const newVal = +ev.target.value; |
| vals[idx] = newVal; |
| sendToChat(`Updated ${esc(String(labels[idx]))} to ${newVal} in "${esc(p.title || "chart")}". What does this change mean?`); |
| col.classList.remove("editing"); |
| col.querySelector(".bar-val").textContent = newVal; |
| const max2 = Math.max(...vals, 1); |
| const h = Math.max(Math.round((newVal / max2) * 140), 4); |
| col.querySelector(".bar-fill").style.height = h + "px"; |
| } |
| if (ev.key === "Escape") col.classList.remove("editing"); |
| }); |
| }); |
| |
| document.getElementById("analyzeBtn")?.addEventListener("click", () => { |
| const pairs = labels.map((l, i) => `${l}: ${vals[i] ?? 0}`).join(", "); |
| sendToChat(`Analyze this chart β "${esc(p.title || "data")}": ${pairs}`); |
| }); |
| } |
| |
| |
| function renderCard(p) { |
| const items = (p.items || []).map((it, i) => { |
| const [k, v] = typeof it === "object" |
| ? [it.label ?? it.key ?? "", it.value ?? ""] |
| : ["", it]; |
| return `<div class="card-item" data-idx="${i}" data-key="${esc(String(k))}" data-val="${esc(String(v))}"> |
| <span class="key">${esc(String(k))}</span> |
| <span class="val">${esc(String(v))} <span class="ask-icon">β ask</span></span> |
| </div>`; |
| }).join(""); |
| return `<div class="card"> |
| ${p.title || p.body ? `<div class="card-header"> |
| ${p.title ? `<p class="title" style="margin:0">${esc(p.title)}</p>` : ""} |
| ${p.body ? `<p style="margin-top:4px;color:var(--muted);font-size:13px">${esc(p.body)}</p>` : ""} |
| </div>` : ""} |
| ${items} |
| </div>`; |
| } |
| |
| function attachCard() { |
| document.querySelectorAll(".card-item").forEach(el => { |
| el.addEventListener("click", () => |
| sendToChat(`Tell me more about "${el.dataset.key}": ${el.dataset.val}`)); |
| }); |
| } |
| |
| |
| function renderTable(p) { |
| const cols = p.columns || [], rows = p.rows || []; |
| const hdr = cols.map((c, i) => |
| `<th data-col="${i}"><span>${esc(c)}</span><span class="sort-arrow"></span></th>`).join(""); |
| const body = rows.map((row, ri) => { |
| const cells = Array.isArray(row) |
| ? row.map(c => `<td>${esc(String(c))}</td>`).join("") |
| : cols.map(c => `<td>${esc(String(row[c] ?? ""))}</td>`).join(""); |
| return `<tr data-ri="${ri}">${cells}</tr>`; |
| }).join(""); |
| return `${p.title ? `<p class="title">${esc(p.title)}</p>` : ""} |
| <div class="table-wrap"> |
| <table><thead><tr>${hdr}</tr></thead><tbody>${body}</tbody></table> |
| </div>`; |
| } |
| |
| function attachTable(p) { |
| const cols = p.columns || [], rows = p.rows || []; |
| let sortCol = -1, sortDir = 1; |
| |
| document.querySelectorAll("th[data-col]").forEach(th => { |
| th.addEventListener("click", () => { |
| const ci = +th.dataset.col; |
| sortDir = sortCol === ci ? -sortDir : 1; sortCol = ci; |
| document.querySelectorAll("th").forEach(t => { t.classList.remove("asc","desc"); }); |
| th.classList.add(sortDir > 0 ? "asc" : "desc"); |
| const tbody = document.querySelector("tbody"); |
| const trs = [...tbody.querySelectorAll("tr")]; |
| trs.sort((a, b) => { |
| const av = a.cells[ci]?.textContent ?? "", bv = b.cells[ci]?.textContent ?? ""; |
| return isNaN(+av) ? av.localeCompare(bv) * sortDir : (+av - +bv) * sortDir; |
| }); |
| trs.forEach(tr => tbody.appendChild(tr)); |
| }); |
| }); |
| |
| document.querySelectorAll("tbody tr").forEach(tr => { |
| tr.addEventListener("click", () => { |
| document.querySelectorAll("tbody tr").forEach(t => t.classList.remove("selected")); |
| tr.classList.add("selected"); |
| const ri = +tr.dataset.ri, row = rows[ri]; |
| const summary = Array.isArray(row) |
| ? cols.map((c, i) => `${c}: ${row[i]}`).join(", ") |
| : cols.map(c => `${c}: ${row[c] ?? ""}`).join(", "); |
| sendToChat(`Analyze this row β ${summary}`); |
| }); |
| }); |
| } |
| |
| |
| function renderFigures(p) { |
| const cells = (p.fields || []).map((f, i) => |
| `<div class="fig-cell"> |
| <label>${esc(f.label || f.name)}</label> |
| <input type="number" id="fig_${i}" value="${f.value ?? 0}" step="${f.step ?? 1}" placeholder="0"> |
| ${f.unit ? `<div class="fig-unit">${esc(f.unit)}</div>` : ""} |
| </div>`).join(""); |
| return `${p.title ? `<p class="title">${esc(p.title)}</p>` : ""} |
| <div class="fig-grid">${cells}</div> |
| <button class="btn" id="figAnalyze">${esc(p.cta || "Analyze my figures")}</button>`; |
| } |
| |
| function attachFigures(p) { |
| document.getElementById("figAnalyze")?.addEventListener("click", () => { |
| const pairs = (p.fields || []).map((f, i) => { |
| const v = document.getElementById(`fig_${i}`)?.value ?? 0; |
| return `${f.label || f.name}: ${v}${f.unit ? " " + f.unit : ""}`; |
| }).join(", "); |
| sendToChat(`${p.cta || "Analyze"}: ${pairs}`); |
| }); |
| } |
| |
| |
| function esc(s) { |
| return String(s ?? "").replace(/&/g,"&").replace(/</g,"<") |
| .replace(/>/g,">").replace(/"/g,"""); |
| } |
| |
| |
| document.getElementById("chat-send").addEventListener("click", () => { |
| const inp = document.getElementById("chat-input"); |
| const t = inp.value.trim(); |
| if (t) { inp.value = ""; sendToChat(t); } |
| }); |
| document.getElementById("chat-input").addEventListener("keydown", ev => { |
| if (ev.key === "Enter") document.getElementById("chat-send").click(); |
| }); |
| |
| |
| getSpec().then(render).catch(e => { |
| document.getElementById("root").innerHTML = `<p class="error">Bridge error: ${e.message}</p>`; |
| }); |
| </script> |
| </body> |
| </html> |
|
|