| #!/usr/bin/env python3 | |
| """ | |
| Rewards Editor - Local GUI for editing rewards.json | |
| Run: python tools/rewards_editor.py | |
| Opens at http://localhost:8765 | |
| """ | |
| import json | |
| import os | |
| import sys | |
| from http.server import HTTPServer, SimpleHTTPRequestHandler | |
| from pathlib import Path | |
| REWARDS_FILE = Path(__file__).resolve().parent.parent / "assets" / "rewards.json" | |
| HTML_PAGE = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Rewards Editor</title> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f0f13;color:#e0e0e0;padding:1.5rem} | |
| h1{font-size:1.4rem;margin-bottom:1rem;color:#fff} | |
| .toolbar{display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap} | |
| .toolbar button{padding:.5rem 1rem;border-radius:8px;border:1px solid #333;background:#1a1a2e;color:#e0e0e0;cursor:pointer;font-size:.85rem;font-weight:600;transition:all .15s} | |
| .toolbar button:hover{background:#2a2a4e;border-color:#555} | |
| .toolbar button.primary{background:#10b981;border-color:#10b981;color:#fff} | |
| .toolbar button.primary:hover{background:#059669} | |
| .toolbar button.danger{background:#dc2626;border-color:#dc2626;color:#fff} | |
| .toolbar button.danger:hover{background:#b91c1c} | |
| .toolbar .count{color:#888;font-size:.85rem;align-self:center;margin-left:auto} | |
| .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:1rem} | |
| .card{background:#1a1a2e;border:1px solid #2a2a3e;border-radius:10px;padding:1rem;display:flex;flex-direction:column;gap:.6rem;transition:border-color .15s} | |
| .card:hover{border-color:#444} | |
| .card-header{display:flex;align-items:center;gap:.5rem} | |
| .card-icon{font-size:1.5rem;width:40px;height:40px;display:flex;align-items:center;justify-content:center;background:#0f0f13;border-radius:8px;border:1px solid #333} | |
| .card-title{flex:1} | |
| .card-title input{width:100%;background:transparent;border:none;color:#fff;font-size:.95rem;font-weight:600;outline:none} | |
| .card-title input:focus{border-bottom:1px solid #10b981} | |
| .card-tier{font-size:.7rem;font-weight:700;padding:.2rem .5rem;border-radius:6px;background:rgba(26,107,255,.1);border:1px solid rgba(26,107,255,.2);color:#1a6bff} | |
| .field{display:flex;flex-direction:column;gap:.2rem} | |
| .field label{font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#888} | |
| .field input,.field select,.field textarea{background:#0f0f13;border:1px solid #2a2a3e;border-radius:6px;padding:.45rem .6rem;color:#e0e0e0;font-size:.82rem;outline:none;font-family:inherit} | |
| .field input:focus,.field select:focus,.field textarea:focus{border-color:#10b981} | |
| .field textarea{min-height:60px;resize:vertical} | |
| .row{display:grid;grid-template-columns:1fr 1fr;gap:.5rem} | |
| .row-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.5rem} | |
| .card-actions{display:flex;gap:.4rem;margin-top:.3rem} | |
| .card-actions button{flex:1;padding:.4rem;border-radius:6px;border:1px solid #333;background:#0f0f13;color:#e0e0e0;cursor:pointer;font-size:.78rem;font-weight:500;transition:all .12s} | |
| .card-actions button:hover{background:#2a2a4e} | |
| .card-actions button.del{color:#dc2626;border-color:rgba(220,38,38,.3)} | |
| .card-actions button.del:hover{background:rgba(220,38,38,.1)} | |
| .toast{position:fixed;bottom:1.5rem;right:1.5rem;padding:.7rem 1.2rem;border-radius:8px;font-size:.85rem;font-weight:600;z-index:9999;animation:slideIn .3s ease} | |
| .toast.ok{background:#10b981;color:#fff} | |
| .toast.err{background:#dc2626;color:#fff} | |
| @keyframes slideIn{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}} | |
| .preview{margin-top:1.5rem;background:#0f0f13;border:1px solid #2a2a3e;border-radius:10px;padding:1rem;max-height:400px;overflow:auto} | |
| .preview pre{font-family:'Fira Code',monospace;font-size:.75rem;color:#aaa;white-space:pre-wrap} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Rewards Editor</h1> | |
| <div class="toolbar"> | |
| <button class="primary" onclick="saveRewards()">Save</button> | |
| <button onclick="addReward()">+ Add Reward</button> | |
| <button onclick="loadRewards()">Reload</button> | |
| <button onclick="togglePreview()">Preview JSON</button> | |
| <span class="count" id="count"></span> | |
| </div> | |
| <div class="grid" id="grid"></div> | |
| <div class="preview" id="preview" style="display:none"><pre id="previewJson"></pre></div> | |
| <script> | |
| let rewards=[]; | |
| const CATEGORIES=['spend','diversity','volume','tokens','streak','special']; | |
| const REWARD_TYPES=['percentage_discount','fixed_price','bonus_credits']; | |
| const CRITERIA_TYPES=['total_spend_usd','total_requests','total_tokens','unique_models_tried','days_visited','consecutive_days','multi_criteria']; | |
| function toast(msg,ok=true){const t=document.createElement('div');t.className='toast '+(ok?'ok':'err');t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),2500)} | |
| async function loadRewards(){ | |
| try{const r=await fetch('/api/rewards');rewards=await r.json();render()}catch(e){toast('Failed to load: '+e.message,false)} | |
| } | |
| async function saveRewards(){ | |
| try{await fetch('/api/rewards',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(rewards)});toast('Saved!')}catch(e){toast('Failed to save: '+e.message,false)} | |
| } | |
| function addReward(){ | |
| rewards.push({id:'new_reward_'+Date.now(),name:'New Reward',description:'Description',icon:'⭐',tier:1,category:'spend',chain:'spend',criteria:{type:'total_spend_usd',threshold:10},reward:{type:'percentage_discount',model_id:'model-name',discount_percent:5,label:'5% off model'}}); | |
| render();toast('Added new reward') | |
| } | |
| function deleteReward(i){if(confirm('Delete this reward?')){rewards.splice(i,1);render();toast('Deleted')}} | |
| function moveReward(i,dir){ | |
| const j=i+dir;if(j<0||j>=rewards.length)return; | |
| [rewards[i],rewards[j]]=[rewards[j],rewards[i]];render() | |
| } | |
| function updateField(i,key,val){ | |
| if(key.includes('.')){const p=key.split('.');rewards[i][p[0]][p[1]]=val} | |
| else rewards[i][key]=val; | |
| } | |
| function render(){ | |
| const g=document.getElementById('grid');g.innerHTML=''; | |
| document.getElementById('count').textContent=rewards.length+' rewards'; | |
| rewards.forEach((r,i)=>{ | |
| const d=document.createElement('div');d.className='card'; | |
| d.innerHTML=` | |
| <div class="card-header"> | |
| <div class="card-icon"><input value="${r.icon||''}" onchange="updateField(${i},'icon',this.value);this.parentElement.childNodes[0].textContent=this.value" style="width:36px;text-align:center;font-size:1.3rem;background:transparent;border:none;color:#fff" /></div> | |
| <div class="card-title"><input value="${esc(r.name)}" onchange="updateField(${i},'name',this.value)"/></div> | |
| <span class="card-tier">T${r.tier||1}</span> | |
| </div> | |
| <div class="field"><label>Description</label><input value="${esc(r.description)}" onchange="updateField(${i},'description',this.value)"/></div> | |
| <div class="row"> | |
| <div class="field"><label>Category</label><select onchange="updateField(${i},'category',this.value);updateField(${i},'chain',this.value)">${CATEGORIES.map(c=>`<option value="${c}" ${r.category===c?'selected':''}>${c}</option>`).join('')}</select></div> | |
| <div class="field"><label>Tier</label><input type="number" min="1" value="${r.tier||1}" onchange="updateField(${i},'tier',parseInt(this.value))"/></div> | |
| </div> | |
| <div style="border-top:1px solid #2a2a3e;padding-top:.5rem;margin-top:.2rem"> | |
| <div style="font-size:.7rem;font-weight:600;color:#888;text-transform:uppercase;margin-bottom:.4rem">Criteria</div> | |
| <div class="row"> | |
| <div class="field"><label>Type</label><select onchange="updateField(${i},'criteria.type',this.value)">${CRITERIA_TYPES.map(c=>`<option value="${c}" ${r.criteria?.type===c?'selected':''}>${c}</option>`).join('')}</select></div> | |
| <div class="field"><label>Threshold</label><input type="number" value="${r.criteria?.threshold||0}" onchange="updateField(${i},'criteria.threshold',parseFloat(this.value))"/></div> | |
| </div> | |
| </div> | |
| <div style="border-top:1px solid #2a2a3e;padding-top:.5rem;margin-top:.2rem"> | |
| <div style="font-size:.7rem;font-weight:600;color:#888;text-transform:uppercase;margin-bottom:.4rem">Reward</div> | |
| <div class="row"> | |
| <div class="field"><label>Type</label><select onchange="updateField(${i},'reward.type',this.value)">${REWARD_TYPES.map(c=>`<option value="${c}" ${r.reward?.type===c?'selected':''}>${c}</option>`).join('')}</select></div> | |
| <div class="field"><label>Model ID</label><input value="${esc(r.reward?.model_id||'')}" onchange="updateField(${i},'reward.model_id',this.value)"/></div> | |
| </div> | |
| <div class="row" style="margin-top:.4rem"> | |
| <div class="field"><label>Discount %</label><input type="number" min="0" max="100" value="${r.reward?.discount_percent||0}" onchange="updateField(${i},'reward.discount_percent',parseFloat(this.value))"/></div> | |
| <div class="field"><label>Fixed Price (per M)</label><input type="number" step="0.01" value="${r.reward?.price_per_million_tokens||0}" onchange="updateField(${i},'reward.price_per_million_tokens',parseFloat(this.value))"/></div> | |
| </div> | |
| <div class="field" style="margin-top:.4rem"><label>Label</label><input value="${esc(r.reward?.label||'')}" onchange="updateField(${i},'reward.label',this.value)"/></div> | |
| </div> | |
| <div class="card-actions"> | |
| <button onclick="moveReward(${i},-1)">Up</button> | |
| <button onclick="moveReward(${i},1)">Down</button> | |
| <button class="del" onclick="deleteReward(${i})">Delete</button> | |
| </div>`; | |
| g.appendChild(d); | |
| }); | |
| } | |
| function esc(s){return String(s||'').replace(/"/g,'"').replace(/</g,'<')} | |
| function togglePreview(){ | |
| const p=document.getElementById('preview'); | |
| if(p.style.display==='none'){p.style.display='block';document.getElementById('previewJson').textContent=JSON.stringify(rewards,null,2)} | |
| else p.style.display='none'; | |
| } | |
| loadRewards(); | |
| </script> | |
| </body> | |
| </html>""" | |
| class RewardsHandler(SimpleHTTPRequestHandler): | |
| def do_GET(self): | |
| if self.path == "/" or self.path == "/index.html": | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.end_headers() | |
| self.wfile.write(HTML_PAGE.encode("utf-8")) | |
| elif self.path == "/api/rewards": | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self.end_headers() | |
| try: | |
| data = json.loads(REWARDS_FILE.read_text(encoding="utf-8")) | |
| except Exception: | |
| data = [] | |
| self.wfile.write(json.dumps(data, indent=2).encode("utf-8")) | |
| else: | |
| self.send_response(404) | |
| self.end_headers() | |
| def do_POST(self): | |
| if self.path == "/api/rewards": | |
| length = int(self.headers.get("Content-Length", 0)) | |
| body = self.rfile.read(length) | |
| try: | |
| data = json.loads(body) | |
| REWARDS_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| REWARDS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json") | |
| self.end_headers() | |
| self.wfile.write(b'{"ok":true}') | |
| except Exception as e: | |
| self.send_response(500) | |
| self.send_header("Content-Type", "application/json") | |
| self.end_headers() | |
| self.wfile.write(json.dumps({"error": str(e)}).encode("utf-8")) | |
| else: | |
| self.send_response(404) | |
| self.end_headers() | |
| def log_message(self, format, *args): | |
| print(f"[rewards-editor] {args[0]}") | |
| def main(): | |
| port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 | |
| print(f"Rewards Editor running at http://localhost:{port}") | |
| print(f"Editing: {REWARDS_FILE}") | |
| server = HTTPServer(("127.0.0.1", port), RewardsHandler) | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| print("\nStopped.") | |
| server.server_close() | |
| if __name__ == "__main__": | |
| main() | |
Xet Storage Details
- Size:
- 12.2 kB
- Xet hash:
- ed323b88964bc68a6df03ac1b7549dcdf40049d312f321768b369a29b3b4cdff
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.