Created
February 28, 2026 17:19
-
-
Save cami7ord/3d16fb7c7860c7991eddab201d1ca238 to your computer and use it in GitHub Desktop.
Stratipy Test Client — x402 payment flow over Solana
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Stratipy Test Client</title> | |
| <script src="https://unpkg.com/@solana/web3.js@1/lib/index.iife.min.js"></script> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: #f5f5f5; | |
| color: #333; | |
| display: flex; | |
| justify-content: center; | |
| padding: 2rem; | |
| } | |
| .container { max-width: 640px; width: 100%; } | |
| h1 { font-size: 1.4rem; margin-bottom: 1rem; } | |
| h2 { font-size: 1.1rem; margin-bottom: 0.5rem; color: #555; } | |
| /* Wallet bar */ | |
| #wallet-bar { | |
| display: none; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-bottom: 1rem; | |
| padding: 0.5rem 0.75rem; | |
| background: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| font-size: 0.85rem; | |
| } | |
| #wallet-bar .wallet-label { color: #888; } | |
| #wallet-bar .wallet-address { font-family: monospace; color: #333; } | |
| #connect-wallet-btn { | |
| padding: 0.4rem 0.8rem; | |
| background: #7c3aed; | |
| color: #fff; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| } | |
| #connect-wallet-btn:hover { background: #6d28d9; } | |
| #connect-wallet-btn:disabled { opacity: 0.5; cursor: default; } | |
| #wallet-status { font-size: 0.85rem; } | |
| #wallet-status.error { color: #dc2626; } | |
| #wallet-status.success { color: #15803d; } | |
| #wallet-status.pending { color: #888; } | |
| /* Strategy selector */ | |
| #strategy-list { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; } | |
| .strategy-btn { | |
| padding: 0.5rem 1rem; | |
| border: 2px solid #ddd; | |
| border-radius: 8px; | |
| background: #fff; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| transition: all 0.15s; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| } | |
| .strategy-btn:hover { border-color: #888; } | |
| .strategy-btn.selected { border-color: #4a90d9; background: #eef4fc; } | |
| .price-badge { | |
| font-size: 0.7rem; | |
| background: #f0fdf4; | |
| color: #15803d; | |
| padding: 0.15rem 0.4rem; | |
| border-radius: 4px; | |
| font-weight: 600; | |
| border: 1px solid #bbf7d0; | |
| } | |
| /* Payment banner */ | |
| #payment-banner { | |
| display: none; | |
| padding: 0.6rem 0.75rem; | |
| margin-bottom: 0.75rem; | |
| border-radius: 8px; | |
| font-size: 0.85rem; | |
| background: #fefce8; | |
| border: 1px solid #fde68a; | |
| color: #854d0e; | |
| } | |
| #payment-banner.success { | |
| background: #f0fdf4; | |
| border-color: #bbf7d0; | |
| color: #15803d; | |
| } | |
| #payment-banner.error { | |
| background: #fef2f2; | |
| border-color: #fecaca; | |
| color: #991b1b; | |
| } | |
| #payment-banner a { color: inherit; font-weight: 600; } | |
| /* Chat area */ | |
| #chat-section { display: none; } | |
| #messages { | |
| background: #fff; | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| height: 400px; | |
| overflow-y: auto; | |
| padding: 1rem; | |
| margin-bottom: 0.75rem; | |
| } | |
| .msg { | |
| margin-bottom: 0.75rem; | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 6px; | |
| max-width: 85%; | |
| word-wrap: break-word; | |
| white-space: pre-wrap; | |
| } | |
| .msg.user { | |
| background: #4a90d9; | |
| color: #fff; | |
| margin-left: auto; | |
| } | |
| .msg.ai { background: #e9e9e9; } | |
| .msg.error { background: #fdd; color: #900; } | |
| .msg.system { background: #ffe; color: #666; font-style: italic; text-align: center; max-width: 100%; } | |
| /* Input */ | |
| #input-area { display: flex; gap: 0.5rem; } | |
| #message-input { | |
| flex: 1; | |
| padding: 0.6rem 0.75rem; | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| font-size: 0.95rem; | |
| outline: none; | |
| } | |
| #message-input:focus { border-color: #4a90d9; } | |
| #send-btn { | |
| padding: 0.6rem 1.2rem; | |
| background: #4a90d9; | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-size: 0.95rem; | |
| } | |
| #send-btn:disabled { opacity: 0.5; cursor: default; } | |
| #status { margin-top: 0.5rem; font-size: 0.8rem; color: #888; } | |
| /* File upload */ | |
| #file-upload-area { | |
| display: none; | |
| padding: 1.5rem; | |
| border: 2px dashed #ccc; | |
| border-radius: 8px; | |
| text-align: center; | |
| background: #fafafa; | |
| margin-bottom: 0.75rem; | |
| } | |
| #file-upload-area label { | |
| cursor: pointer; | |
| color: #4a90d9; | |
| font-weight: 500; | |
| } | |
| #file-upload-area label:hover { text-decoration: underline; } | |
| #file-input { display: none; } | |
| #file-name { margin-top: 0.5rem; font-size: 0.85rem; color: #666; } | |
| /* Select setup */ | |
| #select-setup-area { | |
| display: none; | |
| padding: 1.5rem; | |
| border: 1px solid #ddd; | |
| border-radius: 8px; | |
| background: #fafafa; | |
| margin-bottom: 0.75rem; | |
| text-align: center; | |
| } | |
| #select-setup-area label { | |
| display: block; | |
| margin-bottom: 0.5rem; | |
| font-weight: 500; | |
| color: #555; | |
| } | |
| #select-setup-area select { | |
| padding: 0.5rem 0.75rem; | |
| border: 1px solid #ddd; | |
| border-radius: 6px; | |
| font-size: 0.95rem; | |
| margin-right: 0.5rem; | |
| } | |
| #select-start-btn { | |
| padding: 0.5rem 1rem; | |
| background: #4a90d9; | |
| color: #fff; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.95rem; | |
| } | |
| #select-start-btn:hover { background: #3a7bc8; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Stratipy Test Client</h1> | |
| <div id="wallet-bar"> | |
| <span class="wallet-label">Solana Wallet:</span> | |
| <span id="wallet-address" class="wallet-address"></span> | |
| <span id="wallet-status"></span> | |
| <button id="connect-wallet-btn" onclick="connectWallet()">Connect Wallet</button> | |
| </div> | |
| <section id="strategy-section"> | |
| <h2>Select a strategy</h2> | |
| <div id="strategy-list"><em>Loading strategies...</em></div> | |
| </section> | |
| <section id="chat-section"> | |
| <div id="payment-banner"></div> | |
| <div id="messages"></div> | |
| <div id="file-upload-area"> | |
| <label for="file-input"></label> | |
| <input id="file-input" type="file" /> | |
| <div id="file-name"></div> | |
| </div> | |
| <div id="select-setup-area"> | |
| <label id="select-label"></label> | |
| <select id="select-input"></select> | |
| <button id="select-start-btn">Start</button> | |
| </div> | |
| <div id="input-area"> | |
| <input id="message-input" type="text" placeholder="Type a message..." autocomplete="off" /> | |
| <button id="send-btn">Send</button> | |
| </div> | |
| <div id="status"></div> | |
| </section> | |
| </div> | |
| <script> | |
| const API_BASE = window.location.origin; | |
| let strategyId = null; | |
| let conversationId = null; | |
| let eventSource = null; | |
| let currentSetup = null; | |
| let strategiesData = []; | |
| let walletPublicKey = null; | |
| const strategyList = document.getElementById('strategy-list'); | |
| const chatSection = document.getElementById('chat-section'); | |
| const messages = document.getElementById('messages'); | |
| const messageInput = document.getElementById('message-input'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| const status = document.getElementById('status'); | |
| const fileUploadArea = document.getElementById('file-upload-area'); | |
| const fileInput = document.getElementById('file-input'); | |
| const fileName = document.getElementById('file-name'); | |
| const inputArea = document.getElementById('input-area'); | |
| const selectSetupArea = document.getElementById('select-setup-area'); | |
| const selectLabel = document.getElementById('select-label'); | |
| const selectInput = document.getElementById('select-input'); | |
| const selectStartBtn = document.getElementById('select-start-btn'); | |
| const walletBar = document.getElementById('wallet-bar'); | |
| const walletAddress = document.getElementById('wallet-address'); | |
| const connectWalletBtn = document.getElementById('connect-wallet-btn'); | |
| const walletStatus = document.getElementById('wallet-status'); | |
| const paymentBanner = document.getElementById('payment-banner'); | |
| // --- Solana SPL constants --- | |
| const TOKEN_PROGRAM_ID = new solanaWeb3.PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); | |
| const ASSOCIATED_TOKEN_PROGRAM_ID = new solanaWeb3.PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); | |
| const USDC_MINTS = { | |
| 'solana-mainnet': new solanaWeb3.PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'), | |
| 'solana-devnet': new solanaWeb3.PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'), | |
| }; | |
| // --- Wallet --- | |
| function getPhantomProvider() { | |
| if (window.phantom?.solana?.isPhantom) return window.phantom.solana; | |
| return null; | |
| } | |
| function setWalletStatus(text, type) { | |
| walletStatus.textContent = text; | |
| walletStatus.className = type || ''; | |
| } | |
| async function connectWallet() { | |
| const provider = getPhantomProvider(); | |
| if (!provider) { | |
| setWalletStatus('Phantom not detected. Install from phantom.app', 'error'); | |
| return; | |
| } | |
| connectWalletBtn.disabled = true; | |
| setWalletStatus('Connecting...', 'pending'); | |
| try { | |
| const resp = await provider.connect(); | |
| walletPublicKey = resp.publicKey; | |
| const addr = walletPublicKey.toBase58(); | |
| walletAddress.textContent = addr.slice(0, 4) + '...' + addr.slice(-4); | |
| connectWalletBtn.style.display = 'none'; | |
| setWalletStatus('Connected', 'success'); | |
| } catch (e) { | |
| connectWalletBtn.disabled = false; | |
| setWalletStatus('Connection rejected', 'error'); | |
| } | |
| } | |
| // --- SPL helpers (no external lib needed) --- | |
| function getAssociatedTokenAddress(walletPk, mintPk) { | |
| const [ata] = solanaWeb3.PublicKey.findProgramAddressSync( | |
| [walletPk.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mintPk.toBuffer()], | |
| ASSOCIATED_TOKEN_PROGRAM_ID, | |
| ); | |
| return ata; | |
| } | |
| function createTransferCheckedInstruction(source, mint, destination, owner, amount, decimals) { | |
| const data = new Uint8Array(10); | |
| const view = new DataView(data.buffer); | |
| data[0] = 12; // TransferChecked instruction index | |
| view.setBigUint64(1, BigInt(amount), true); // little-endian | |
| data[9] = decimals; | |
| const keys = [ | |
| { pubkey: source, isSigner: false, isWritable: true }, | |
| { pubkey: mint, isSigner: false, isWritable: false }, | |
| { pubkey: destination, isSigner: false, isWritable: true }, | |
| { pubkey: owner, isSigner: true, isWritable: false }, | |
| ]; | |
| return new solanaWeb3.TransactionInstruction({ | |
| keys, | |
| programId: TOKEN_PROGRAM_ID, | |
| data, | |
| }); | |
| } | |
| // --- Payment banner --- | |
| function showPaymentBanner(html, type) { | |
| paymentBanner.innerHTML = html; | |
| paymentBanner.className = type || ''; | |
| paymentBanner.style.display = 'block'; | |
| } | |
| function hidePaymentBanner() { | |
| paymentBanner.style.display = 'none'; | |
| paymentBanner.className = ''; | |
| paymentBanner.innerHTML = ''; | |
| } | |
| function explorerUrl(txId, network) { | |
| const cluster = network === 'solana-mainnet' ? '' : '?cluster=devnet'; | |
| return `https://explorer.solana.com/tx/${txId}${cluster}`; | |
| } | |
| // --- Strategies --- | |
| async function loadStrategies() { | |
| try { | |
| const res = await fetch(`${API_BASE}/strategies`); | |
| const data = await res.json(); | |
| strategiesData = data; | |
| strategyList.innerHTML = ''; | |
| const hasPaid = data.some(s => s.price != null || s.messagePrice != null); | |
| if (hasPaid) walletBar.style.display = 'flex'; | |
| data.forEach(s => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'strategy-btn'; | |
| const nameSpan = document.createElement('span'); | |
| nameSpan.textContent = s.name || s.id; | |
| btn.appendChild(nameSpan); | |
| if (s.price != null) { | |
| const badge = document.createElement('span'); | |
| badge.className = 'price-badge'; | |
| badge.textContent = (s.price / 1_000_000).toFixed(2) + ' USDC'; | |
| btn.appendChild(badge); | |
| } | |
| if (s.messagePrice != null) { | |
| const badge = document.createElement('span'); | |
| badge.className = 'price-badge'; | |
| badge.textContent = (s.messagePrice / 1_000_000).toFixed(2) + ' USDC/msg'; | |
| btn.appendChild(badge); | |
| } | |
| btn.title = s.description || ''; | |
| btn.addEventListener('click', () => selectStrategy(s.id, btn)); | |
| strategyList.appendChild(btn); | |
| }); | |
| } catch (e) { | |
| strategyList.innerHTML = '<em>Failed to load strategies</em>'; | |
| } | |
| } | |
| function getStrategy(id) { | |
| return strategiesData.find(s => s.id === id); | |
| } | |
| function selectStrategy(id, btn) { | |
| document.querySelectorAll('.strategy-btn').forEach(b => b.classList.remove('selected')); | |
| btn.classList.add('selected'); | |
| strategyId = id; | |
| currentSetup = getStrategy(id)?.setup || null; | |
| startConversation(); | |
| } | |
| async function startConversation() { | |
| if (eventSource) { eventSource.close(); eventSource = null; } | |
| messages.innerHTML = ''; | |
| fileInput.value = ''; | |
| fileName.textContent = ''; | |
| hidePaymentBanner(); | |
| chatSection.style.display = 'block'; | |
| // Hide all input areas first | |
| fileUploadArea.style.display = 'none'; | |
| selectSetupArea.style.display = 'none'; | |
| inputArea.style.display = 'none'; | |
| if (currentSetup?.type === 'file') { | |
| document.querySelector('#file-upload-area label').textContent = currentSetup.label; | |
| fileInput.setAttribute('accept', currentSetup.accept); | |
| fileUploadArea.style.display = 'block'; | |
| } else if (currentSetup?.type === 'select') { | |
| selectLabel.textContent = currentSetup.label; | |
| selectInput.innerHTML = ''; | |
| currentSetup.options.forEach(opt => { | |
| const option = document.createElement('option'); | |
| option.value = opt.value; | |
| option.textContent = opt.label; | |
| selectInput.appendChild(option); | |
| }); | |
| selectSetupArea.style.display = 'block'; | |
| } else { | |
| inputArea.style.display = 'flex'; | |
| } | |
| messageInput.disabled = false; | |
| sendBtn.disabled = false; | |
| setStatus('Starting conversation...'); | |
| try { | |
| const res = await fetch(`${API_BASE}/strategies/${strategyId}/conversations`, { | |
| method: 'POST' | |
| }); | |
| if (res.status === 402) { | |
| await handlePaymentFlow(res); | |
| return; | |
| } | |
| const data = await res.json(); | |
| conversationId = data.conversationId; | |
| if (currentSetup?.type === 'file') { | |
| setStatus('Select a file to begin.'); | |
| } else if (currentSetup?.type === 'select') { | |
| setStatus('Choose an option and click Start.'); | |
| } else { | |
| setStatus('Connected. Send a message to begin.'); | |
| messageInput.focus(); | |
| } | |
| } catch (e) { | |
| setStatus('Failed to start conversation'); | |
| } | |
| } | |
| // --- x402 Payment Flow --- | |
| /** | |
| * Signs a Solana payment from a 402 response. | |
| * Returns the base64-encoded PAYMENT-SIGNATURE header value, or null on failure. | |
| */ | |
| async function signPayment(response402) { | |
| const provider = getPhantomProvider(); | |
| if (!provider) { | |
| showPaymentBanner( | |
| 'Phantom wallet not found. Install it from ' + | |
| '<a href="https://phantom.app" target="_blank">phantom.app</a>', 'error', | |
| ); | |
| setStatus(''); | |
| return null; | |
| } | |
| // Auto-connect if not yet connected | |
| if (!walletPublicKey) { | |
| try { | |
| const resp = await provider.connect(); | |
| walletPublicKey = resp.publicKey; | |
| walletAddress.textContent = walletPublicKey.toBase58().slice(0, 4) + '...' + | |
| walletPublicKey.toBase58().slice(-4); | |
| connectWalletBtn.style.display = 'none'; | |
| } catch (e) { | |
| showPaymentBanner('Wallet connection rejected', 'error'); | |
| setStatus(''); | |
| return null; | |
| } | |
| } | |
| // Decode PAYMENT-REQUIRED header | |
| const paymentRequiredB64 = response402.headers.get('PAYMENT-REQUIRED'); | |
| if (!paymentRequiredB64) { | |
| showPaymentBanner('Missing payment requirements from server', 'error'); | |
| setStatus(''); | |
| return null; | |
| } | |
| let paymentRequired; | |
| try { | |
| paymentRequired = JSON.parse(atob(paymentRequiredB64)); | |
| } catch (e) { | |
| showPaymentBanner('Failed to decode payment requirements', 'error'); | |
| setStatus(''); | |
| return null; | |
| } | |
| // Find Solana payment option | |
| const req = paymentRequired.accepts.find( | |
| a => a.scheme === 'exact' && a.network.startsWith('solana'), | |
| ); | |
| if (!req) { | |
| showPaymentBanner('No compatible Solana payment option available', 'error'); | |
| setStatus(''); | |
| return null; | |
| } | |
| const amountUsdc = (parseInt(req.maxAmountRequired) / 1_000_000).toFixed(2); | |
| const networkLabel = req.network === 'solana-mainnet' ? 'Mainnet' : 'Devnet'; | |
| showPaymentBanner( | |
| `Payment required: ${amountUsdc} USDC on Solana ${networkLabel}. Confirm in your wallet...`, | |
| ); | |
| setStatus('Awaiting wallet signature...'); | |
| const mint = USDC_MINTS[req.network]; | |
| if (!mint) { | |
| showPaymentBanner(`Unsupported network: ${req.network}`, 'error'); | |
| setStatus(''); | |
| return null; | |
| } | |
| try { | |
| const rpcUrl = req.network === 'solana-mainnet' | |
| ? 'https://api.mainnet-beta.solana.com' | |
| : 'https://api.devnet.solana.com'; | |
| const connection = new solanaWeb3.Connection(rpcUrl); | |
| const recipientPk = new solanaWeb3.PublicKey(req.payTo); | |
| const senderAta = getAssociatedTokenAddress(walletPublicKey, mint); | |
| const recipientAta = getAssociatedTokenAddress(recipientPk, mint); | |
| const ix = createTransferCheckedInstruction( | |
| senderAta, mint, recipientAta, walletPublicKey, | |
| parseInt(req.maxAmountRequired), 6, | |
| ); | |
| const { blockhash } = await connection.getLatestBlockhash(); | |
| const tx = new solanaWeb3.Transaction(); | |
| tx.recentBlockhash = blockhash; | |
| tx.feePayer = walletPublicKey; | |
| tx.add(ix); | |
| const signed = await provider.signTransaction(tx); | |
| const serializedTx = btoa(String.fromCharCode(...signed.serialize())); | |
| const paymentPayload = { | |
| x402Version: paymentRequired.x402Version, | |
| scheme: req.scheme, | |
| network: req.network, | |
| payload: { transaction: serializedTx }, | |
| }; | |
| return btoa(JSON.stringify(paymentPayload)); | |
| } catch (e) { | |
| if (e.message?.includes('User rejected')) { | |
| showPaymentBanner('Transaction rejected by user', 'error'); | |
| } else { | |
| showPaymentBanner(`Payment error: ${e.message}`, 'error'); | |
| } | |
| setStatus(''); | |
| return null; | |
| } | |
| } | |
| /** Shows payment-response banner with explorer link if available. */ | |
| function showPaymentResponseBanner(response) { | |
| const paymentResponseB64 = response.headers.get('PAYMENT-RESPONSE'); | |
| let txLink = ''; | |
| if (paymentResponseB64) { | |
| try { | |
| const pr = JSON.parse(atob(paymentResponseB64)); | |
| if (pr.transactionId) { | |
| const network = pr.network || 'solana-devnet'; | |
| txLink = ` <a href="${explorerUrl(pr.transactionId, network)}" target="_blank">View on Explorer</a>`; | |
| } | |
| } catch (_) {} | |
| } | |
| showPaymentBanner(`Payment successful!${txLink}`, 'success'); | |
| } | |
| async function handlePaymentFlow(response402) { | |
| const signatureHeader = await signPayment(response402); | |
| if (!signatureHeader) return; | |
| showPaymentBanner('Submitting payment...'); | |
| setStatus('Submitting payment to server...'); | |
| try { | |
| const paidRes = await fetch(`${API_BASE}/strategies/${strategyId}/conversations`, { | |
| method: 'POST', | |
| headers: { 'PAYMENT-SIGNATURE': signatureHeader }, | |
| }); | |
| if (!paidRes.ok) { | |
| const errText = await paidRes.text(); | |
| showPaymentBanner(`Payment failed: ${errText}`, 'error'); | |
| setStatus('Payment failed'); | |
| return; | |
| } | |
| showPaymentResponseBanner(paidRes); | |
| const data = await paidRes.json(); | |
| conversationId = data.conversationId; | |
| setStatus('Connected. Send a message to begin.'); | |
| messageInput.focus(); | |
| } catch (e) { | |
| showPaymentBanner(`Payment error: ${e.message}`, 'error'); | |
| setStatus(''); | |
| } | |
| } | |
| fileInput.addEventListener('change', async () => { | |
| const file = fileInput.files[0]; | |
| if (!file || !conversationId) return; | |
| fileName.textContent = file.name; | |
| setStatus('Uploading CSV...'); | |
| const reader = new FileReader(); | |
| reader.onload = async (e) => { | |
| const csvText = e.target.result; | |
| appendMessage(`Uploaded: ${file.name}`, 'user'); | |
| try { | |
| await fetch(`${API_BASE}/strategies/${strategyId}/conversations/${conversationId}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text: csvText }) | |
| }); | |
| fileUploadArea.style.display = 'none'; | |
| inputArea.style.display = 'flex'; | |
| currentSetup = null; | |
| connectSSE(); | |
| } catch (err) { | |
| appendMessage('Failed to upload file', 'error'); | |
| setStatus('Upload failed'); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| }); | |
| selectStartBtn.addEventListener('click', async () => { | |
| const selected = selectInput.value; | |
| if (!selected || !conversationId) return; | |
| appendMessage(selected, 'user'); | |
| setStatus('Starting...'); | |
| try { | |
| await fetch(`${API_BASE}/strategies/${strategyId}/conversations/${conversationId}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text: selected }) | |
| }); | |
| selectSetupArea.style.display = 'none'; | |
| inputArea.style.display = 'flex'; | |
| currentSetup = null; | |
| connectSSE(); | |
| } catch (err) { | |
| appendMessage('Failed to start', 'error'); | |
| setStatus('Failed'); | |
| } | |
| }); | |
| async function sendMessage() { | |
| const text = messageInput.value.trim(); | |
| if (!text || !conversationId) return; | |
| messageInput.value = ''; | |
| appendMessage(text, 'user'); | |
| sendBtn.disabled = true; | |
| setStatus('Waiting for AI...'); | |
| const url = `${API_BASE}/strategies/${strategyId}/conversations/${conversationId}`; | |
| const body = JSON.stringify({ text }); | |
| const headers = { 'Content-Type': 'application/json' }; | |
| try { | |
| let res = await fetch(url, { method: 'POST', headers, body }); | |
| if (res.status === 402) { | |
| const signatureHeader = await signPayment(res); | |
| if (!signatureHeader) { | |
| sendBtn.disabled = false; | |
| return; | |
| } | |
| showPaymentBanner('Submitting payment...'); | |
| setStatus('Submitting payment to server...'); | |
| res = await fetch(url, { | |
| method: 'POST', | |
| headers: { ...headers, 'PAYMENT-SIGNATURE': signatureHeader }, | |
| body, | |
| }); | |
| if (!res.ok) { | |
| const errText = await res.text(); | |
| showPaymentBanner(`Payment failed: ${errText}`, 'error'); | |
| setStatus('Payment failed'); | |
| sendBtn.disabled = false; | |
| return; | |
| } | |
| showPaymentResponseBanner(res); | |
| } | |
| connectSSE(); | |
| } catch (e) { | |
| appendMessage('Failed to send message', 'error'); | |
| sendBtn.disabled = false; | |
| } | |
| } | |
| function connectSSE() { | |
| if (eventSource) eventSource.close(); | |
| const url = `${API_BASE}/strategies/${strategyId}/conversations/${conversationId}/events`; | |
| eventSource = new EventSource(url); | |
| eventSource.onmessage = (e) => { | |
| try { | |
| const data = JSON.parse(e.data); | |
| appendMessage(data.text, 'ai'); | |
| sendBtn.disabled = false; | |
| setStatus(''); | |
| } catch (err) { | |
| appendMessage(e.data, 'ai'); | |
| } | |
| }; | |
| eventSource.addEventListener('finish', () => { | |
| appendMessage('Conversation ended.', 'system'); | |
| messageInput.disabled = true; | |
| sendBtn.disabled = true; | |
| setStatus('Finished'); | |
| eventSource.close(); | |
| eventSource = null; | |
| }); | |
| eventSource.onerror = () => { | |
| // EventSource auto-reconnects; close it if conversation is done | |
| if (messageInput.disabled) { | |
| eventSource.close(); | |
| eventSource = null; | |
| } | |
| }; | |
| } | |
| function appendMessage(text, type) { | |
| const div = document.createElement('div'); | |
| div.className = `msg ${type}`; | |
| div.textContent = text; | |
| messages.appendChild(div); | |
| messages.scrollTop = messages.scrollHeight; | |
| } | |
| function setStatus(text) { | |
| status.textContent = text; | |
| } | |
| sendBtn.addEventListener('click', sendMessage); | |
| messageInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !sendBtn.disabled) sendMessage(); | |
| }); | |
| // Pick up existing Phantom connection on page load | |
| async function checkExistingConnection() { | |
| const provider = getPhantomProvider(); | |
| if (!provider) return; | |
| try { | |
| const resp = await provider.connect({ onlyIfTrusted: true }); | |
| walletPublicKey = resp.publicKey; | |
| const addr = walletPublicKey.toBase58(); | |
| walletAddress.textContent = addr.slice(0, 4) + '...' + addr.slice(-4); | |
| connectWalletBtn.style.display = 'none'; | |
| setWalletStatus('Connected', 'success'); | |
| } catch (_) { | |
| // Not previously connected — user will click the button | |
| } | |
| } | |
| loadStrategies(); | |
| checkExistingConnection(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment