53 lines
16 KiB
Python
53 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
DIST = ROOT / "dist"
|
|
DIST.mkdir(exist_ok=True)
|
|
|
|
html = r'''<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>QuoteJudge Studio</title>
|
|
<style>
|
|
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f5f2ea;color:#17201b}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,#fff7dc 0,#f5f2ea 36%,#edf5f0 100%)}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.55}.loading{min-height:100vh;display:grid;place-items:center;color:#56645d}.product-shell{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:28px 0 42px;display:grid;gap:22px}.hero{display:grid;grid-template-columns:1fr minmax(260px,360px);gap:22px;align-items:stretch}.eyebrow{margin:0 0 8px;color:#0b7f68;font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}h1,h2,p{margin-top:0}h1{max-width:820px;margin-bottom:14px;font-size:clamp(38px,7vw,72px);line-height:.94;letter-spacing:-.055em}h2{margin-bottom:0;font-size:25px;letter-spacing:-.03em}.lede{max-width:760px;color:#48564f;font-size:18px;line-height:1.55}.session-card,.panel{border:1px solid rgba(23,32,27,.12);border-radius:22px;background:rgba(255,255,255,.76);box-shadow:0 20px 70px rgba(28,44,36,.08);backdrop-filter:blur(12px)}.session-card{padding:22px;display:grid;align-content:start;gap:8px}.session-card span{color:#65736c}.session-card strong{font-size:20px;overflow-wrap:anywhere}.session-card a,.actions button,.section-title button,.upload-box button,.quote-row button{border:0;border-radius:999px;background:#17201b;color:#fff;padding:10px 15px;text-decoration:none;text-align:center}.session-card small{color:#69756f;line-height:1.4}.studio{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(280px,.75fr);gap:22px;align-items:start}.panel{padding:22px}.section-title{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:18px}.section-title button,.upload-box button,.quote-row button{background:#e5efe9;color:#163d32}.field,.upload-box label,.slider{display:grid;gap:8px;color:#35443d;font-weight:700}.field input,.quote-row input{width:100%;border:1px solid #cfd9d3;border-radius:12px;background:#fff;color:#17201b;padding:11px 12px}.upload-box{margin:16px 0;display:flex;align-items:end;justify-content:space-between;gap:14px;padding:14px;border:1px dashed #a9bab1;border-radius:16px;background:#f9fbf8}.quotes-table{display:grid;gap:8px;overflow-x:auto}.quote-row{min-width:860px;display:grid;grid-template-columns:1.45fr .85fr .65fr .85fr .95fr auto;gap:8px;align-items:center}.quote-row.header{color:#5e6c65;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.04em}.quote-row button{padding:9px 12px}.weights{position:sticky;top:18px;display:grid;gap:18px}.slider{grid-template-columns:84px 1fr 42px;align-items:center;text-transform:capitalize}.slider input{accent-color:#0b7f68;width:100%}.slider output{text-align:right;font-variant-numeric:tabular-nums}.actions{display:flex;flex-wrap:wrap;gap:10px}.actions button{background:#0b7f68}.actions button+button{background:#17201b}.notice{margin:0;color:#56645d;line-height:1.45}.error{margin:0;border-radius:14px;background:#fff0ed;color:#9d2412;padding:12px 14px;line-height:1.45}.result-panel{display:grid;gap:16px}.pill{border-radius:999px;background:#e7f6ef;color:#08785f;padding:7px 10px;font-size:13px;font-weight:800}.result-grid{display:grid;gap:18px}.winner-card{border-radius:18px;background:linear-gradient(135deg,#0b7f68,#12241e);color:#fff;padding:20px;display:grid;gap:8px}.winner-card strong{font-size:30px;letter-spacing:-.04em}.winner-card span{color:#b8f1e1;font-weight:800}.winner-card p{margin:0;line-height:1.5;color:#eefaf6}.scores{display:grid;gap:10px}.scorebar{display:grid;grid-template-columns:minmax(120px,220px) 1fr 54px;gap:10px;align-items:center}.scorebar span,.scorebar b{font-size:14px}.scorebar div{height:12px;overflow:hidden;border-radius:999px;background:#e3ebe6}.scorebar i{display:block;height:100%;border-radius:999px;background:#0b7f68}table{width:100%;border-collapse:collapse;overflow:hidden;border-radius:16px;background:#fff}th,td{padding:12px;border-bottom:1px solid #edf1ee;text-align:left}th{color:#5b6a63;font-size:12px;text-transform:uppercase;letter-spacing:.04em}.receipt,.empty{margin:0;color:#5c6c65}@media(max-width:900px){.hero,.studio{grid-template-columns:1fr}.weights{position:static}h1{font-size:clamp(34px,12vw,56px)}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root" class="loading">Loading QuoteJudge...</div>
|
|
<script type="module">
|
|
const CONFIG_URL = './config.json';
|
|
const MAX_UPLOAD_BYTES = 64 * 1024;
|
|
const DEFAULT_QUOTES = [
|
|
{ vendor: 'Acme Bearings', unit_price: 10.75, quantity: 500, delivery_days: 12, warranty_months: 12 },
|
|
{ vendor: 'Beta Industrial', unit_price: 9.1, quantity: 500, delivery_days: 8, warranty_months: 18 },
|
|
];
|
|
const DEFAULT_WEIGHTS = { price: 50, delivery: 30, warranty: 20 };
|
|
let config, session = { authenticated: false }, quotes = structuredClone(DEFAULT_QUOTES), weights = { ...DEFAULT_WEIGHTS }, result = null, uploadFile = null, error = null, status = null;
|
|
const root = document.getElementById('root');
|
|
async function requestJson(url, init = {}) { const r = await fetch(url, { credentials: 'same-origin', ...init }); const text = await r.text(); const data = text ? JSON.parse(text) : null; if (!r.ok) throw new Error((data && (data.detail || data.message)) || `request failed: ${r.status}`); return data; }
|
|
async function loadConfig(){ return requestJson(CONFIG_URL); }
|
|
async function loadSession(){ return requestJson(config.endpoints.session); }
|
|
function signInUrl(){ const target = config?.auth?.authorizeUrl || config?.auth?.loginUrl; if(!target) return null; const next = encodeURIComponent(location.pathname + location.search + location.hash); return `${target}${target.includes('?')?'&':'?'}next=${next}`; }
|
|
async function callSkill(name,args){ if(config.auth?.invokeRequiresSession && !session?.authenticated){ const href=signInUrl(); if(href) throw new Error('sign in required'); } return requestJson(`${config.endpoints.invoke}/${encodeURIComponent(name)}`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({arguments:args})}); }
|
|
const n = (v) => { const x = Number(v); return Number.isFinite(x) ? x : 0; };
|
|
function normalizedQuotes(){ return quotes.map(q => ({ vendor: String(q.vendor||'').trim(), unit_price: n(q.unit_price), quantity: Math.trunc(n(q.quantity)), delivery_days: Math.trunc(n(q.delivery_days)), warranty_months: Math.trunc(n(q.warranty_months)) })); }
|
|
function validate(id, rows){ if(!id.trim()) return 'Comparison ID is required.'; if(!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/.test(id.trim())) return 'Comparison ID must be 1-80 safe characters.'; if(rows.length < 2) return 'Add at least two quotes.'; const vendors = new Set(); for(const q of rows){ if(!q.vendor) return 'Every quote needs a vendor name.'; const k=q.vendor.toLowerCase(); if(vendors.has(k)) return 'Vendor names must be unique.'; vendors.add(k); if(q.unit_price<=0) return `${q.vendor} needs a positive unit price.`; if(q.quantity<=0) return `${q.vendor} needs a positive quantity.`; if(q.delivery_days<0 || q.warranty_months<0) return `${q.vendor} has a negative delivery or warranty value.`; } if(n(weights.price)+n(weights.delivery)+n(weights.warranty)<=0) return 'At least one weight must be greater than zero.'; return null; }
|
|
async function fileToBase64(file){ const buffer=await file.arrayBuffer(); let binary=''; const bytes=new Uint8Array(buffer); for(let i=0;i<bytes.byteLength;i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); }
|
|
function csvToQuotes(text){ const lines=text.split(/\r?\n/).filter(l=>l.trim()); const headers=lines[0].split(',').map(x=>x.trim()); return lines.slice(1).map(line=>{ const cells=line.split(',').map(x=>x.trim()); const row=Object.fromEntries(headers.map((h,i)=>[h,cells[i]||''])); return {vendor:row.vendor,unit_price:n(row.unit_price),quantity:Math.trunc(n(row.quantity)),delivery_days:Math.trunc(n(row.delivery_days)),warranty_months:Math.trunc(n(row.warranty_months))}; }); }
|
|
function updateQuote(i,field,value){ quotes = quotes.map((q,idx)=>idx===i?{...q,[field]:value}:q); render(); }
|
|
async function handleUpload(file){ uploadFile=file || null; error=null; if(file){ if(file.size>MAX_UPLOAD_BYTES){ error=`Upload must be at most ${MAX_UPLOAD_BYTES} bytes.`; render(); return; } try{ const text=await file.text(); let parsed; if((file.type||'').includes('json') || file.name.endsWith('.json')){ const raw=JSON.parse(text); parsed=Array.isArray(raw)?raw:raw.quotes; } else parsed=csvToQuotes(text); if(Array.isArray(parsed) && parsed.length) quotes=parsed; } catch(e){ error=`Preview parse failed: ${e.message}. You can still clear the file and paste rows manually.`; } } render(); }
|
|
async function compare(ev){ ev.preventDefault(); const rows=normalizedQuotes(); const validation=validate(document.getElementById('comparisonId').value, rows); if(validation){ error=validation; render(); return; } error=null; status='Comparing quotes and saving recommendation...'; render(); try{ const comparison_id=document.getElementById('comparisonId').value.trim(); const payloadWeights={price:n(weights.price),delivery:n(weights.delivery),warranty:n(weights.warranty)}; if(uploadFile){ const media_type=uploadFile.type || (uploadFile.name.endsWith('.json')?'application/json':'text/csv'); result=await callSkill('compare_quotes_upload',{comparison_id,upload:{filename:uploadFile.name,media_type,data_base64:await fileToBase64(uploadFile)},weights:payloadWeights}); } else result=await callSkill('compare_quotes',{comparison_id,quotes:rows,weights:payloadWeights}); if(!result.ok) error=result.message || result.code; } catch(e){ error=/sign in|401/i.test(e.message)?'Session required or expired. Use Sign in to run, then retry.':e.message; } status=null; render(); }
|
|
async function reload(){ error=null; status='Reopening saved comparison...'; render(); try{ result=await callSkill('get_comparison',{comparison_id:document.getElementById('comparisonId').value.trim()}); if(!result.ok) error=result.message || result.code; } catch(e){ error=e.message; } status=null; render(); }
|
|
function render(){ const needsSignIn=Boolean(config?.auth?.invokeRequiresSession && session && !session.authenticated); const href=signInUrl(); const winner=result?.ok?result.recommendation:null; root.className='product-shell'; root.innerHTML=`<section class="hero"><div><p class="eyebrow">QuoteJudge</p><h1>Pick the best vendor quote with transparent weighted scoring.</h1><p class="lede">Paste or upload quote rows, weight price/delivery/warranty, save the recommendation, and reopen it later under your A2A Cloud user account.</p></div><aside class="session-card"><span>${session?.authenticated?'Signed in':'Signed out'}</span><strong>${session?.user?.email || session?.email || session?.org?.slug || 'A2A Cloud user'}</strong>${needsSignIn&&href?`<a href="${href}">Sign in to run</a>`:''}<small>Agent: ${config.agent.name} v${config.agent.version} · MCP /mcp</small></aside></section><form class="studio" id="studio"><section class="panel inputs"><div class="section-title"><div><p class="eyebrow">1 · Input</p><h2>Quotes</h2></div><button id="addQuote" type="button">Add quote</button></div><label class="field">Comparison ID<input id="comparisonId" value="studio-quote-v1" /></label><div class="upload-box"><label>Upload CSV/JSON (bounded browser bridge, max 64 KiB)<input id="upload" accept=".csv,.json,text/csv,text/plain,application/json" type="file" /></label>${uploadFile?'<button id="clearUpload" type="button">Use pasted rows instead</button>':''}</div><div class="quotes-table"><div class="quote-row header"><span>Vendor</span><span>Unit price</span><span>Qty</span><span>Delivery days</span><span>Warranty months</span><span></span></div>${quotes.map((q,i)=>`<div class="quote-row"><input data-i="${i}" data-f="vendor" value="${String(q.vendor).replaceAll('"','"')}"/><input data-i="${i}" data-f="unit_price" type="number" min="0" step="0.01" value="${q.unit_price}"/><input data-i="${i}" data-f="quantity" type="number" min="1" step="1" value="${q.quantity}"/><input data-i="${i}" data-f="delivery_days" type="number" min="0" step="1" value="${q.delivery_days}"/><input data-i="${i}" data-f="warranty_months" type="number" min="0" step="1" value="${q.warranty_months}"/><button type="button" data-remove="${i}" ${quotes.length<=1?'disabled':''}>Remove</button></div>`).join('')}</div></section><section class="panel weights"><div><p class="eyebrow">2 · Priorities</p><h2>Weights</h2></div>${['price','delivery','warranty'].map(k=>`<label class="slider">${k}<input data-w="${k}" type="range" min="0" max="100" value="${weights[k]}"/><output>${weights[k]}</output></label>`).join('')}<div class="actions"><button ${needsSignIn?'disabled':''} type="submit">Compare & save</button><button ${needsSignIn?'disabled':''} id="reload" type="button">Reopen saved</button></div>${needsSignIn?'<p class="notice">Sign in with A2A Cloud to run tools and access your user-scoped database records.</p>':''}${status?`<p class="notice">${status}</p>`:''}${error?`<p class="error" role="alert">${error}</p>`:''}</section></form><section class="panel result-panel"><div class="section-title"><div><p class="eyebrow">3 · Recommendation</p><h2>${winner?winner.vendor:'No comparison yet'}</h2></div>${result?.reloaded?'<span class="pill">Reloaded from Postgres</span>':''}</div>${result?.ok?`<div class="result-grid"><div class="winner-card"><strong>${winner.vendor}</strong><span>${winner.weighted_score.toFixed(1)} weighted score</span><p>${winner.rationale}</p></div><div class="scores">${result.quotes.map(q=>`<div class="scorebar"><span>${q.vendor}</span><div><i style="width:${Math.max(0,Math.min(100,q.weighted_score))}%"></i></div><b>${q.weighted_score.toFixed(1)}</b></div>`).join('')}</div><table><thead><tr><th>Vendor</th><th>Total price</th><th>Delivery</th><th>Warranty</th><th>Weighted</th></tr></thead><tbody>${result.quotes.map(q=>`<tr><td>${q.vendor}</td><td>${q.total_price.toLocaleString(undefined,{style:'currency',currency:'BRL'})}</td><td>${q.delivery_days} days</td><td>${q.warranty_months} mo</td><td>${q.weighted_score.toFixed(1)}</td></tr>`).join('')}</tbody></table><p class="receipt">Receipt persisted: ${String(result.receipt?.persisted)} · comparison_id=${result.comparison_id}</p></div>`:'<p class="empty">Run the success fixture or upload your own CSV/JSON to see a saved recommendation here.</p>'}</section>`; document.getElementById('studio').addEventListener('submit',compare); document.getElementById('reload').addEventListener('click',reload); document.getElementById('addQuote').addEventListener('click',()=>{quotes=[...quotes,{vendor:`Vendor ${quotes.length+1}`,unit_price:0,quantity:1,delivery_days:0,warranty_months:0}];render();}); document.getElementById('upload').addEventListener('change',e=>handleUpload(e.target.files?.[0])); const clear=document.getElementById('clearUpload'); if(clear) clear.addEventListener('click',()=>{uploadFile=null;render();}); document.querySelectorAll('[data-i]').forEach(el=>el.addEventListener('input',e=>updateQuote(Number(e.target.dataset.i),e.target.dataset.f,e.target.value))); document.querySelectorAll('[data-remove]').forEach(el=>el.addEventListener('click',e=>{quotes=quotes.filter((_,i)=>i!==Number(e.target.dataset.remove));render();})); document.querySelectorAll('[data-w]').forEach(el=>el.addEventListener('input',e=>{weights={...weights,[e.target.dataset.w]:Number(e.target.value)};render();})); }
|
|
try { config = await loadConfig(); try { session = await loadSession(); } catch { session = { authenticated:false }; } render(); } catch(e) { root.textContent = e.message || String(e); }
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
(DIST / "index.html").write_text(html, encoding="utf-8")
|
|
print(f"wrote {DIST / 'index.html'}")
|