Spaces:
Runtime error
Runtime error
File size: 41,057 Bytes
8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 f3b9eef 8d0b054 191a27f 8d0b054 f3b9eef 8d0b054 f3b9eef 8d0b054 f3b9eef 8d0b054 191a27f f3b9eef 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 191a27f 8d0b054 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pay-Per-Inference w/x402+CDP Payouts</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-7xl">
<header class="text-center mb-12">
<h1 class="text-4xl font-bold text-gray-900 mb-4">π€ Pay-Per-Inference w/x402+CDP Payouts π€</h1>
<p class="text-xl text-gray-600">Monetize your models on HF Spaces with fastapi-x402</p>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-800">
<strong>Demo Mode:</strong> Using testnet (Base Sepolia)
</p>
</div>
<!-- Wallet Connection -->
<div class="mt-6">
<div class="flex gap-3 justify-center">
<button id="connect-wallet" class="btn btn-primary">
π¦ Connect MetaMask
</button>
<button id="disconnect-wallet" class="btn btn-secondary hidden">
π Disconnect Wallet
</button>
</div>
<div id="wallet-status" class="mt-2 text-sm"></div>
</div>
</header>
<!-- Service Cards -->
<div class="grid lg:grid-cols-3 md:grid-cols-2 grid-cols-1 gap-8">
<!-- Text Generation Service -->
<div class="service-card bg-white rounded-lg shadow-md p-8">
<h2 class="text-2xl font-semibold mb-2">π Text Generation</h2>
<div class="price-tag">$0.01 per request</div>
<p class="text-gray-600 mb-4">Generate creative text using DistilGPT-2</p>
<div class="form-group">
<label class="block text-sm font-medium mb-2">Prompt:</label>
<textarea
id="text-prompt"
placeholder="Once upon a time..."
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows="3"
>The future of payments and AI is</textarea>
<button
id="text-btn"
class="mt-3 w-full px-6 py-4 rounded-full font-semibold text-white text-lg bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transform transition-all duration-200 ease-in-out hover:scale-105 hover:shadow-lg active:scale-95 shadow-md hover:shadow-xl disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Generate Text
</button>
</div>
<div id="text-result" class="result-box mt-4"></div>
</div>
<!-- Sentiment Analysis Service -->
<div class="service-card bg-white rounded-lg shadow-md p-8">
<h2 class="text-2xl font-semibold mb-2">π Sentiment Analysis</h2>
<div class="price-tag">$0.005 per request</div>
<p class="text-gray-600 mb-4">Analyze text sentiment using RoBERTa</p>
<div class="form-group">
<label class="block text-sm font-medium mb-2">Text to analyze:</label>
<textarea
id="sentiment-text"
placeholder="I love this new technology!"
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows="3"
>This Coinbase x402+CDP hackathon has been a lot of fun!!!</textarea>
<button
id="sentiment-btn"
class="mt-3 w-full px-6 py-4 rounded-full font-semibold text-white text-lg bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transform transition-all duration-200 ease-in-out hover:scale-105 hover:shadow-lg active:scale-95 shadow-md hover:shadow-xl disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Analyze Sentiment
</button>
</div>
<div id="sentiment-result" class="result-box mt-4"></div>
</div>
<!-- Image Generation Service -->
<div class="service-card bg-white rounded-lg shadow-md p-8">
<h2 class="text-2xl font-semibold mb-2">πΌοΈ Image Generation</h2>
<div class="price-tag">$0.02 per request</div>
<p class="text-gray-600 mb-4">Generate images using Amazon Nova Canvas</p>
<div class="form-group">
<label class="block text-sm font-medium mb-2">Prompt:</label>
<textarea
id="image-prompt"
placeholder="A futuristic city skyline at sunset..."
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
rows="3"
>A happy developer accepting x402 payments on HuggingFace</textarea>
<button
id="image-btn"
class="mt-3 w-full px-6 py-4 rounded-full font-semibold text-white text-lg bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transform transition-all duration-200 ease-in-out hover:scale-105 hover:shadow-lg active:scale-95 shadow-md hover:shadow-xl disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Generate Image
</button>
</div>
<div id="image-result" class="result-box mt-4"></div>
</div>
</div>
<!-- How it Works -->
<div class="mt-12 bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-semibold mb-4">π§ How it Works</h3>
<ol class="list-decimal list-inside space-y-2 text-gray-700">
<li>Click a service button to make a request</li>
<li>FastAPI returns <code class="bg-gray-100 px-1 rounded">402 Payment Required</code> with payment details</li>
<li>Your x402-compatible client (like MetaMask) signs the payment</li>
<li>FastAPI verifies the payment with the facilitator</li>
<li>AI service processes your request and returns results</li>
</ol>
<div class="mt-4 p-3 bg-yellow-50 rounded-lg">
<p class="text-sm text-yellow-800">
<strong>Note:</strong> This demo requires an x402-compatible client.
Try the <a href="/debug" class="text-blue-600 underline">debug endpoint</a> to check configuration.
</p>
</div>
</div>
</div>
<script type="module">
import * as viem from 'https://esm.sh/viem@2.23.1';
// Global wallet state
let walletClient = null;
let currentAccount = null;
// Initialize wallet connection on page load
document.addEventListener('DOMContentLoaded', initWallet);
// Detect if user is on mobile
function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
// Check if we're in MetaMask mobile app browser
function isMetaMaskMobile() {
return navigator.userAgent.includes('MetaMaskMobile');
}
async function initWallet() {
const connectBtn = document.getElementById('connect-wallet');
const disconnectBtn = document.getElementById('disconnect-wallet');
const statusDiv = document.getElementById('wallet-status');
// Check if MetaMask is available
if (typeof window.ethereum === 'undefined') {
if (isMobile()) {
// Mobile users - provide better guidance
statusDiv.innerHTML = `
<div class="text-orange-600 text-sm">
π± On mobile?
<a href="https://metamask.app.link/dapp/${window.location.host}${window.location.pathname}"
class="text-blue-600 underline" target="_blank">
Open in MetaMask App
</a>
</div>
`;
connectBtn.textContent = 'Open in MetaMask App';
connectBtn.onclick = () => {
// Try to open in MetaMask mobile app
window.open(`https://metamask.app.link/dapp/${window.location.host}${window.location.pathname}`, '_blank');
};
} else {
// Desktop users
statusDiv.innerHTML = '<span class="text-red-600">β MetaMask not installed</span>';
connectBtn.disabled = true;
connectBtn.textContent = 'Install MetaMask';
connectBtn.onclick = () => window.open('https://metamask.io/', '_blank');
}
return;
}
// MetaMask is available - check if already connected
try {
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
if (accounts.length > 0) {
await connectWallet();
}
} catch (error) {
console.log('No existing connection');
}
connectBtn.onclick = connectWallet;
disconnectBtn.onclick = disconnectWallet;
// Listen for account changes in MetaMask
if (window.ethereum) {
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
}
// Show platform-specific status
if (isMetaMaskMobile()) {
statusDiv.innerHTML = '<span class="text-green-600">π± MetaMask Mobile App detected</span>';
} else if (isMobile()) {
statusDiv.innerHTML = '<span class="text-blue-600">π± Mobile browser with MetaMask support</span>';
}
}
async function connectWallet() {
const connectBtn = document.getElementById('connect-wallet');
const disconnectBtn = document.getElementById('disconnect-wallet');
const statusDiv = document.getElementById('wallet-status');
try {
// Show current status
statusDiv.innerHTML = '<span class="text-blue-600">π Connecting to MetaMask...</span>';
// Force fresh account access - this will show account selection if multiple accounts
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
console.log('π Available accounts:', accounts);
// Get the currently selected account immediately
const currentAccounts = await window.ethereum.request({ method: 'eth_accounts' });
console.log('π― Current account in MetaMask:', currentAccounts[0]);
// Switch to Base Sepolia if not already on it
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0x14a34' }], // Base Sepolia chainId in hex
});
} catch (switchError) {
// Chain not added, try to add it
if (switchError.code === 4902) {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: '0x14a34',
chainName: 'Base Sepolia',
nativeCurrency: {
name: 'ETH',
symbol: 'ETH',
decimals: 18,
},
rpcUrls: ['https://sepolia.base.org'],
blockExplorerUrls: ['https://sepolia-explorer.base.org'],
}],
});
} else {
throw switchError;
}
}
// Create wallet client with viem
walletClient = viem.createWalletClient({
transport: viem.custom(window.ethereum),
chain: viem.baseSepolia, // Base Sepolia testnet
});
// Get the currently selected account (fresh from MetaMask)
const freshAccounts = await window.ethereum.request({ method: 'eth_accounts' });
currentAccount = freshAccounts[0]; // Use the currently selected account
console.log('π― Using account:', currentAccount);
// Update UI - show disconnect button, hide connect button
connectBtn.classList.add('hidden');
disconnectBtn.classList.remove('hidden');
disconnectBtn.textContent = `π ${currentAccount.slice(0, 6)}...${currentAccount.slice(-4)}`;
statusDiv.innerHTML = `
<div class="text-green-600 text-center">
π Connected: ${currentAccount.slice(0, 8)}...${currentAccount.slice(-6)}<br>
<span class="text-xs text-gray-600">Ready for payments on Base Sepolia</span>
</div>
`;
console.log('β
Wallet connected:', currentAccount);
} catch (error) {
console.error('β Failed to connect wallet:', error);
let errorMessage = 'Connection failed';
if (error.code === 4001) {
errorMessage = 'User rejected connection';
} else if (error.code === -32002) {
errorMessage = 'Connection request pending';
}
statusDiv.innerHTML = `<span class="text-red-600">β ${errorMessage}</span>`;
}
}
async function disconnectWallet() {
const connectBtn = document.getElementById('connect-wallet');
const disconnectBtn = document.getElementById('disconnect-wallet');
const statusDiv = document.getElementById('wallet-status');
try {
// Try to disconnect from MetaMask (this may not work in all versions)
if (window.ethereum && window.ethereum.request) {
await window.ethereum.request({
method: 'wallet_revokePermissions',
params: [{ eth_accounts: {} }]
});
}
} catch (error) {
console.log('Note: MetaMask permission revocation not available:', error.message);
}
// Reset wallet state
walletClient = null;
currentAccount = null;
// Update UI - show connect button, hide disconnect button
connectBtn.classList.remove('hidden');
connectBtn.disabled = false;
connectBtn.textContent = 'π¦ Connect MetaMask';
disconnectBtn.classList.add('hidden');
statusDiv.innerHTML = `
<div class="text-gray-600">
π° Disconnected.
<span class="text-sm">To switch accounts, change in MetaMask first, then reconnect.</span>
</div>
`;
console.log('π Wallet disconnected (app-level)');
}
// Handle account changes (when user switches accounts in MetaMask)
function handleAccountsChanged(accounts) {
console.log('π Accounts changed:', accounts);
if (accounts.length === 0) {
// No accounts connected - disconnect
disconnectWallet();
} else if (currentAccount && accounts[0] !== currentAccount) {
// Account switched - update UI
const disconnectBtn = document.getElementById('disconnect-wallet');
const statusDiv = document.getElementById('wallet-status');
currentAccount = accounts[0];
disconnectBtn.textContent = `π ${currentAccount.slice(0, 6)}...${currentAccount.slice(-4)}`;
statusDiv.innerHTML = `<span class="text-blue-600">π Switched to ${currentAccount.slice(0, 6)}...${currentAccount.slice(-4)}</span>`;
console.log('β
Account switched to:', currentAccount);
// Update wallet client with new account
if (walletClient) {
walletClient = viem.createWalletClient({
transport: viem.custom(window.ethereum),
chain: viem.baseSepolia,
});
}
}
}
// Handle chain changes
function handleChainChanged(chainId) {
console.log('π Chain changed to:', chainId);
const statusDiv = document.getElementById('wallet-status');
if (chainId === '0x14a34') {
statusDiv.innerHTML = '<span class="text-green-600">β
Connected to Base Sepolia</span>';
} else {
statusDiv.innerHTML = '<span class="text-orange-600">β οΈ Please switch to Base Sepolia network</span>';
}
}
// Create x402 payment signature using TransferWithAuthorization EIP-712 (matches facilitator exactly)
async function createPaymentSignature(paymentRequirements) {
if (!walletClient || !currentAccount) {
throw new Error('Wallet not connected');
}
console.log('π§ Creating payment signature...');
// Wait a bit to ensure wallet is fully ready (helps with race conditions)
await new Promise(resolve => setTimeout(resolve, 100));
// Double-check wallet state
try {
const accounts = await walletClient.getAddresses();
if (!accounts || accounts.length === 0) {
throw new Error('No accounts available');
}
currentAccount = accounts[0]; // Refresh current account
console.log('π Wallet verification - current account:', currentAccount);
} catch (error) {
console.error('β Wallet verification failed:', error);
throw new Error('Wallet not ready for signing');
}
// Generate nonce and deadline - ensuring proper format to avoid BigInt issues
const currentTime = Math.floor(Date.now() / 1000);
const validAfter = currentTime - 60; // Valid 1 minute ago (account for clock skew)
const validBefore = currentTime + paymentRequirements.maxTimeoutSeconds; // Valid until timeout
// Create a proper 32-byte nonce (64 hex chars) that won't cause BigInt issues
const randomBytes = new Uint8Array(32);
crypto.getRandomValues(randomBytes);
const nonce = '0x' + Array.from(randomBytes, byte => byte.toString(16).padStart(2, '0')).join('');
console.log('π Generated nonce:', nonce, 'length:', nonce.length);
// EIP-712 domain for TransferWithAuthorization (matches facilitator exactly)
const domain = {
name: paymentRequirements.extra?.name || 'USDC',
version: paymentRequirements.extra?.version || '2',
chainId: 84532, // Base Sepolia
verifyingContract: paymentRequirements.asset,
};
// EIP-712 types for TransferWithAuthorization (matches facilitator)
const types = {
TransferWithAuthorization: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' },
{ name: 'validBefore', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' },
],
};
// Create the authorization message (ensure all values are strings, no BigInt)
const message = {
from: String(currentAccount),
to: String(paymentRequirements.payTo),
value: String(paymentRequirements.maxAmountRequired), // Ensure string
validAfter: String(validAfter),
validBefore: String(validBefore),
nonce: String(nonce),
};
console.log('π Signing TransferWithAuthorization message:', message);
console.log('π Domain:', domain);
console.log('π Message types:', typeof message.value, typeof message.validAfter, typeof message.validBefore);
// Sign the EIP-712 TransferWithAuthorization message
const signature = await walletClient.signTypedData({
account: currentAccount,
domain,
types,
primaryType: 'TransferWithAuthorization',
message,
});
// Create payment payload (ensure all values are strings to avoid BigInt issues)
const paymentPayload = {
x402Version: 1,
scheme: 'exact',
network: 'base-sepolia',
payload: {
signature: String(signature),
authorization: {
from: String(currentAccount),
to: String(paymentRequirements.payTo),
value: String(paymentRequirements.maxAmountRequired),
validAfter: String(validAfter),
validBefore: String(validBefore),
nonce: String(nonce),
},
},
};
console.log('π PaymentPayload before JSON.stringify:', paymentPayload);
console.log('π Authorization value type:', typeof paymentPayload.payload.authorization.value);
// Encode as base64 for X-Payment header (handle any BigInt values)
let paymentJson;
try {
paymentJson = JSON.stringify(paymentPayload, (key, value) => {
// Log any BigInt values we encounter
if (typeof value === 'bigint') {
console.warn('β οΈ Found BigInt in paymentPayload:', key, value);
return value.toString();
}
return value;
});
} catch (error) {
console.error('β JSON.stringify error:', error);
console.error('β PaymentPayload causing error:', paymentPayload);
throw new Error(`Failed to serialize payment: ${error.message}`);
}
const paymentHeader = btoa(paymentJson);
console.log('β
TransferWithAuthorization signature created');
// Debug: decode and check the payment header for any issues
try {
const decodedPayload = JSON.parse(atob(paymentHeader));
console.log('π Decoded payment payload:', decodedPayload);
// Check timing - this might be the issue
const now = Math.floor(Date.now() / 1000);
const validAfter = Number(decodedPayload.payload.authorization.validAfter);
const validBefore = Number(decodedPayload.payload.authorization.validBefore);
console.log('π Current time:', now);
console.log('π ValidAfter:', validAfter, '(diff:', now - validAfter, 'seconds)');
console.log('π ValidBefore:', validBefore, '(diff:', validBefore - now, 'seconds)');
// Check timing issues
if (now < validAfter) {
console.warn('β οΈ Payment not valid yet! Current time is before validAfter');
}
if (now >= validBefore) {
console.warn('β οΈ Payment expired! Current time is after validBefore');
}
// Check authorization structure
const auth = decodedPayload.payload.authorization;
console.log('π Authorization:', {
from: auth.from,
to: auth.to,
value: auth.value,
nonce: auth.nonce
});
} catch (error) {
console.error('β Failed to decode payment header:', error);
}
return paymentHeader;
}
// Display API results
function displayResult(endpoint, result, resultDiv) {
if (endpoint.includes('text')) {
resultDiv.innerHTML = `
<div class="success-result">
<h4 class="font-semibold text-green-600 mb-2">Generated Text</h4>
<p class="text-gray-800 italic">"${result.result}"</p>
<small class="text-gray-500">Model: ${result.model}</small>
</div>
`;
} else if (endpoint.includes('sentiment')) {
const sentiment = result.sentiment.toUpperCase();
const sentimentEmoji = sentiment === 'POSITIVE' ? 'π' :
sentiment === 'NEGATIVE' ? 'π' : 'π';
resultDiv.innerHTML = `
<div class="success-result">
<h4 class="font-semibold text-green-600 mb-2"> Sentiment Analysis</h4>
<div class="sentiment-result">
<p class="text-lg">${sentimentEmoji} <strong>${sentiment}</strong></p>
<p class="text-sm text-gray-600">Confidence: ${(result.confidence * 100).toFixed(1)}%</p>
<small class="text-gray-500">Model: ${result.model}</small>
</div>
</div>
`;
} else if (endpoint.includes('image')) {
resultDiv.innerHTML = `
<div class="success-result">
<h4 class="font-semibold text-green-600 mb-2">πΌοΈ Generated Image</h4>
<div class="image-result text-center">
<img src="data:image/png;base64,${result.image}"
alt="${result.prompt}"
class="w-full max-w-sm mx-auto rounded-lg shadow-md mb-2">
<p class="text-sm text-gray-600 italic">"${result.prompt}"</p>
<small class="text-gray-500">Model: ${result.model}</small>
</div>
</div>
`;
}
}
// Enhanced API call handler with proper x402 support
async function callApi(endpoint, data, buttonId, resultId) {
const button = document.getElementById(buttonId);
const resultDiv = document.getElementById(resultId);
button.disabled = true;
button.textContent = 'Processing...';
resultDiv.innerHTML = '<div class="loading">π Loading...</div>';
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data, (key, value) => {
// Handle BigInt values
if (typeof value === 'bigint') {
return value.toString();
}
return value;
})
});
// Handle 402 Payment Required - Create payment and retry
if (response.status === 402) {
const paymentInfo = await response.json();
if (!walletClient) {
resultDiv.innerHTML = `
<div class="payment-required">
<h4 class="text-lg font-semibold text-orange-600 mb-2">π³ Payment Required</h4>
<p class="text-sm text-gray-600 mb-2">Please connect your MetaMask wallet first.</p>
<div class="mt-3 p-3 bg-gray-50 rounded text-xs">
<p class="font-semibold mb-2">402 Response Details:</p>
<pre class="text-gray-700 overflow-x-auto">${JSON.stringify(paymentInfo, null, 2)}</pre>
</div>
</div>
`;
return;
}
try {
resultDiv.innerHTML = '<div class="loading">π³ Creating payment signature...</div>';
// Extract payment requirements from the first accept option
const paymentRequirements = paymentInfo.accepts[0];
// Create payment signature with retry logic for BigInt issues
let paymentHeader;
let attempts = 0;
const maxAttempts = 2;
while (attempts < maxAttempts) {
try {
paymentHeader = await createPaymentSignature(paymentRequirements);
break; // Success, exit retry loop
} catch (error) {
attempts++;
console.warn(`β οΈ Payment signature attempt ${attempts} failed:`, error.message);
if (error.message.includes('loop of type') || error.message.includes('bigint')) {
if (attempts < maxAttempts) {
console.log('π Retrying payment signature creation...');
await new Promise(resolve => setTimeout(resolve, 500)); // Wait before retry
continue;
}
}
throw error; // Re-throw if not a BigInt error or max attempts reached
}
}
// Ensure we have a valid payment header before proceeding
if (!paymentHeader) {
throw new Error('Failed to create payment signature');
}
resultDiv.innerHTML = '<div class="loading">π Processing payment...</div>';
console.log('π³ Sending payment request to:', endpoint);
console.log('π³ Payment header defined:', paymentHeader !== undefined);
console.log('π³ Payment header length:', paymentHeader?.length);
console.log('π³ Payment header preview:', paymentHeader?.substring(0, 100) + '...');
// Verify headers before sending
const requestHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Payment': paymentHeader
};
console.log('π³ Request headers:', requestHeaders);
// Retry the request with payment header
const paidResponse = await fetch(endpoint, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(data, (key, value) => {
// Handle BigInt values
if (typeof value === 'bigint') {
console.warn('β οΈ Found BigInt in request body:', key, value);
return value.toString();
}
return value;
})
});
console.log('π³ Payment response status:', paidResponse.status);
console.log('π³ Payment response headers:', Object.fromEntries(paidResponse.headers.entries()));
if (!paidResponse.ok) {
const errorText = await paidResponse.text();
console.error('β Payment failed - response body:', errorText);
// If it's a 402, try to parse and show the specific validation error
if (paidResponse.status === 402) {
try {
const errorObj = JSON.parse(errorText);
console.error('β 402 Payment verification failed:', errorObj);
console.error('β Error message:', errorObj.error);
// Log the payment details for comparison
const sentPayload = JSON.parse(atob(paymentHeader));
console.error('β Sent payload:', sentPayload);
console.error('β Expected requirements:', errorObj.accepts?.[0]);
// Check for specific error patterns
if (errorObj.error) {
if (errorObj.error.includes('signature')) {
console.error('π SIGNATURE VERIFICATION FAILED');
} else if (errorObj.error.includes('nonce')) {
console.error('π NONCE ISSUE');
} else if (errorObj.error.includes('timing') || errorObj.error.includes('valid')) {
console.error('π TIMING ISSUE');
} else if (errorObj.error.includes('funds')) {
console.error('π INSUFFICIENT FUNDS');
} else {
console.error('π OTHER VALIDATION ERROR:', errorObj.error);
}
}
} catch (parseError) {
console.error('β Could not parse error response:', parseError);
}
}
let error;
try {
error = JSON.parse(errorText);
} catch {
error = { error: errorText };
}
throw new Error(error.detail || error.error || `Payment failed (${paidResponse.status})`);
}
// Payment successful, process the result
const result = await paidResponse.json();
displayResult(endpoint, result, resultDiv);
return;
} catch (paymentError) {
resultDiv.innerHTML = `
<div class="error-result">
<h4 class="font-semibold text-red-600 mb-2">π³ Payment Failed</h4>
<p class="text-red-700">${paymentError.message}</p>
<p class="text-sm text-gray-600 mt-2">Make sure you have testnet USDC on Base Sepolia.</p>
</div>
`;
return;
}
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.error || 'Request failed');
}
const result = await response.json();
displayResult(endpoint, result, resultDiv);
} catch (error) {
resultDiv.innerHTML = `
<div class="error-result">
<h4 class="font-semibold text-red-600 mb-2">β Error</h4>
<p class="text-red-700">${error.message}</p>
</div>
`;
} finally {
button.disabled = false;
if (button.id.includes('text')) {
button.textContent = 'Generate Text';
} else if (button.id.includes('sentiment')) {
button.textContent = 'Analyze Sentiment';
} else if (button.id.includes('image')) {
button.textContent = 'Generate Image';
}
}
}
// Event listeners
document.getElementById('text-btn').addEventListener('click', () => {
const prompt = document.getElementById('text-prompt').value.trim();
if (!prompt) {
alert('Please enter a prompt!');
return;
}
callApi('/generate-text', { prompt }, 'text-btn', 'text-result');
});
document.getElementById('sentiment-btn').addEventListener('click', () => {
const text = document.getElementById('sentiment-text').value.trim();
if (!text) {
alert('Please enter text to analyze!');
return;
}
callApi('/analyze-sentiment', { text }, 'sentiment-btn', 'sentiment-result');
});
document.getElementById('image-btn').addEventListener('click', () => {
const prompt = document.getElementById('image-prompt').value.trim();
if (!prompt) {
alert('Please enter an image prompt!');
return;
}
callApi('/generate-image', { prompt }, 'image-btn', 'image-result');
});
// Allow Enter key to submit
document.getElementById('text-prompt').addEventListener('keypress', (e) => {
if (e.key === 'Enter') document.getElementById('text-btn').click();
});
document.getElementById('image-prompt').addEventListener('keypress', (e) => {
if (e.key === 'Enter') document.getElementById('image-btn').click();
});
</script>
</body>
</html>
|