diff --git a/README.md b/README.md index 1dab512..0660161 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,16 @@ StellarSearch is a pay-per-query web search API for autonomous AI agents. Every ## Real stack (no mocks) -| Layer | Real package / service | -|---|---| -| Payment protocol | `@x402/express` + `@x402/stellar` + `@x402/core` | -| Blockchain | Stellar Testnet (via Horizon API) | -| Facilitator | OpenZeppelin x402 (`channels.openzeppelin.com`) | -| Wallet connect | `@stellar/freighter-api` (real Freighter extension) | -| Balances / tx | Stellar Horizon REST API (live, not mocked) | -| Search results | Serper.dev API (real Google search results) | -| AI assistant | `groq-sdk` · Llama 3.3 70B (real Groq API) | -| Frontend | React 18, TypeScript, Tailwind CSS, Framer Motion | +| Layer | Real package / service | +| ---------------- | --------------------------------------------------- | +| Payment protocol | `@x402/express` + `@x402/stellar` + `@x402/core` | +| Blockchain | Stellar Testnet (via Horizon API) | +| Facilitator | OpenZeppelin x402 (`channels.openzeppelin.com`) | +| Wallet connect | `@stellar/freighter-api` (real Freighter extension) | +| Balances / tx | Stellar Horizon REST API (live, not mocked) | +| Search results | Serper.dev API (real Google search results) | +| AI assistant | `groq-sdk` · Llama 3.3 70B (real Groq API) | +| Frontend | React 18, TypeScript, Tailwind CSS, Framer Motion | --- @@ -40,11 +40,11 @@ npm install ### 2. Get your keys (all free) -| Key | Where to get it | -|---|---| +| Key | Where to get it | +| --------------------------- | ------------------------------------------------------------------------------------------------------------- | | `STELLAR_RECEIVING_ADDRESS` | [Stellar Lab](https://laboratory.stellar.org/#account-creator?network=test) — generate + fund testnet keypair | -| `SERPER_API_KEY` | [serper.dev](https://serper.dev/) — free tier: 2.5k queries/month | -| `GROQ_API_KEY` | [console.groq.com/keys](https://console.groq.com/keys) — free | +| `SERPER_API_KEY` | [serper.dev](https://serper.dev/) — free tier: 2.5k queries/month | +| `GROQ_API_KEY` | [console.groq.com/keys](https://console.groq.com/keys) — free | ### 3. Configure @@ -141,6 +141,7 @@ Browser (Freighter) → GET /search?q=... ### Payment Integrity & Replay Protection To guarantee that each payment identifier authorizes **exactly one provider call**, StellarSearch tracks consumed payment identifiers across Express (`server/index.ts`) and Vercel (`api/search.ts`) runtimes: + - **Payload Invalidation:** Extracts transaction hashes (or SHA-256 fallback hashes of payment headers) and invalidates consumed payloads for a 300-second window. - **Concurrency Throttling:** Rapid parallel requests using identical payment payloads are throttled so only one search query proceeds; concurrent duplicates immediately receive HTTP 402 (`Payment payload already consumed`). @@ -498,10 +499,10 @@ The `supply-chain` CI job generates a **CycloneDX SBOM** from the committed lock ## Hackathon requirements -| Requirement | ✓ | -|---|---| -| Open-source repo + README | ✅ | -| 2–3 min video demo | Record showing: connect Freighter → search → see 402 → payment settles → results | -| Real Stellar testnet transactions | ✅ Every search settles 0.001 USDC via OpenZeppelin facilitator | -| x402 protocol | ✅ `@x402/express` + `@x402/stellar` | -| Addresses explicit demand signal | ✅ "pay-per-query web search instead of monthly subscriptions" | +| Requirement | ✓ | +| --------------------------------- | -------------------------------------------------------------------------------- | +| Open-source repo + README | ✅ | +| 2–3 min video demo | Record showing: connect Freighter → search → see 402 → payment settles → results | +| Real Stellar testnet transactions | ✅ Every search settles 0.001 USDC via OpenZeppelin facilitator | +| x402 protocol | ✅ `@x402/express` + `@x402/stellar` | +| Addresses explicit demand signal | ✅ "pay-per-query web search instead of monthly subscriptions" | diff --git a/api/ai/chat.test.ts b/api/ai/chat.test.ts index 1f6cc17..7a07471 100644 --- a/api/ai/chat.test.ts +++ b/api/ai/chat.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from "vitest"; -const mockCreate = vi.fn() +const mockCreate = vi.fn(); vi.mock('groq-sdk', () => ({ default: class { diff --git a/api/ai/chat.ts b/api/ai/chat.ts index 128d046..5aa698c 100644 --- a/api/ai/chat.ts +++ b/api/ai/chat.ts @@ -2,9 +2,25 @@ import type { VercelRequest, VercelResponse } from '@vercel/node' import Groq from 'groq-sdk' import { readServerConfig } from '../../src/lib/config' -export default async function handler(req: VercelRequest, res: VercelResponse) { - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }) + if (role === "system") { + return { + ok: false, + error: "Client messages may only use user or assistant roles", + }; + } + + if (role !== "user" && role !== "assistant") { + return { + ok: false, + error: "Client messages must use user or assistant roles", + }; + } + + if (typeof content !== "string") { + return { ok: false, error: "Message content must be a string" }; + } + + sanitized.push({ role, content }); } const { messages, model: requestedModel } = (req.body || {}) as { @@ -36,8 +52,8 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const delta = chunk.choices?.[0]?.delta?.content if (delta) sendEvent('delta', { content: delta }) } - sendEvent('done', { model }) - res.end() + sendEvent("done", { model }); + res.end(); } catch (err: any) { if (controller.signal.aborted) return res.end() console.error('[groq stream error]', err?.message) diff --git a/server/index.ts b/server/index.ts index 1725765..af70608 100644 --- a/server/index.ts +++ b/server/index.ts @@ -56,7 +56,7 @@ import type { import { buildReconciliationRecord, type ReconciliationRoute } from '../src/lib/reconciliation.js' import { appendReconciliationRecord } from './reconciliationStore.js' -dotenv.config() +dotenv.config(); let config try { @@ -76,10 +76,12 @@ const limiter = rateLimit({ standardHeaders: true, legacyHeaders: true, handler: (_req: Request, res: Response) => { - res.setHeader('Retry-After', '60') - res.status(429).json({ error: 'Too many requests, please try again later.' }) + res.setHeader("Retry-After", "60"); + res + .status(429) + .json({ error: "Too many requests, please try again later." }); }, -}) +}); // ─── Security Headers & Middleware ──────────────────────────────────────── app.use( @@ -89,27 +91,27 @@ app.use( defaultSrc: ["'self'"], connectSrc: [ "'self'", - 'https://horizon-testnet.stellar.org', - 'https://horizon.stellar.org', - 'https://soroban-testnet.stellar.org', - 'https://soroban-rpc.mainnet.stellar.org', - 'https://google.serper.dev', - 'https://www.x402.org', - 'https://channels.openzeppelin.com', - 'http://localhost:*', - 'ws://localhost:*', + "https://horizon-testnet.stellar.org", + "https://horizon.stellar.org", + "https://soroban-testnet.stellar.org", + "https://soroban-rpc.mainnet.stellar.org", + "https://google.serper.dev", + "https://www.x402.org", + "https://channels.openzeppelin.com", + "http://localhost:*", + "ws://localhost:*", ], scriptSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", 'data:', 'https:'], + imgSrc: ["'self'", "data:", "https:"], }, }, - crossOriginResourcePolicy: { policy: 'cross-origin' }, - }) -) -app.use(cors(buildCorsOptions())) -app.use(express.json()) -app.use(limiter) + crossOriginResourcePolicy: { policy: "cross-origin" }, + }), +); +app.use(cors(buildCorsOptions())); +app.use(express.json()); +app.use(limiter); // ─── In-memory stats ────────────────────────────────────────────────────── const stats = { @@ -117,7 +119,7 @@ const stats = { totalUsdcSettled: 0, latencies: [] as number[], startTime: Date.now(), -} +}; // ─── Batch idempotency & async job stores (issues #324, #325) ──────────── export const MAX_BATCH_SIZE = 10 @@ -274,24 +276,26 @@ const groq = GROQ_API_KEY ? new Groq({ apiKey: GROQ_API_KEY }) : undefined // ─── x402 payment guard on /search ─────────────────────────────────────── // paymentMiddlewareFromConfig is the recommended API per official Stellar docs. // It uses the Coinbase public facilitator (no API key needed for testnet). -const x402Accepts = [{ - scheme: 'exact', - price: parseFloat(AMOUNT_USDC), - amount: AMOUNT_STROOPS, - network: NETWORK, - payTo: RECEIVING_ADDRESS, -}] +const x402Accepts = [ + { + scheme: "exact", + price: parseFloat(AMOUNT_USDC), + amount: AMOUNT_STROOPS, + network: NETWORK, + payTo: RECEIVING_ADDRESS, + }, +]; const x402Routes = { - 'GET /search': { + "GET /search": { accepts: x402Accepts, description: `StellarSearch: pay-per-query web search — ${AMOUNT_USDC} USDC on Stellar`, }, - 'GET /images': { + "GET /images": { accepts: x402Accepts, description: `StellarSearch: pay-per-query image search — ${AMOUNT_USDC} USDC on Stellar`, }, - 'GET /news': { + "GET /news": { accepts: x402Accepts, description: `StellarSearch: pay-per-query news search — ${AMOUNT_USDC} USDC on Stellar`, }, @@ -311,23 +315,23 @@ const x402Routes = { }, } -const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }) -const schemes = [{ network: NETWORK, server: new ExactStellarScheme() }] +const facilitatorClient = new HTTPFacilitatorClient({ url: FACILITATOR_URL }); +const schemes = [{ network: NETWORK, server: new ExactStellarScheme() }]; // Apply middleware to all routes, not just /search // ─── Payment Logging Middleware ────────────────────────────────────────── app.use((req, res, next) => { - if (req.path === '/search') { + if (req.path === "/search") { const { q } = req.query as Record; - const truncatedQ = q ? String(q).substring(0, 50) : ''; + const truncatedQ = q ? String(q).substring(0, 50) : ""; - res.on('finish', () => { - let paymentStatus = 'error'; - if (res.statusCode === 200) paymentStatus = 'paid'; - else if (res.statusCode === 402) paymentStatus = '402'; + res.on("finish", () => { + let paymentStatus = "error"; + if (res.statusCode === 200) paymentStatus = "paid"; + else if (res.statusCode === 402) paymentStatus = "402"; - logger.info('Payment attempt', { + logger.info("Payment attempt", { timestamp: new Date().toISOString(), ip: req.ip, query: truncatedQ, @@ -338,31 +342,31 @@ app.use((req, res, next) => { next(); }); -app.use(paymentMiddlewareFromConfig(x402Routes, facilitatorClient, schemes)) +app.use(paymentMiddlewareFromConfig(x402Routes, facilitatorClient, schemes)); // ─── Payment Replay Protection Middleware ───────────────────────────────── app.use((req, res, next) => { - const paidRoutes = ['/search', '/images', '/news'] + const paidRoutes = ["/search", "/images", "/news"]; if (paidRoutes.includes(req.path)) { const paymentHeader = - req.headers['payment-signature'] || - req.headers['x-payment'] || - req.headers['X-PAYMENT'] || - req.headers['x-payment-response'] || - req.headers['authorization'] + req.headers["payment-signature"] || + req.headers["x-payment"] || + req.headers["X-PAYMENT"] || + req.headers["x-payment-response"] || + req.headers["authorization"]; if (paymentHeader) { - const consumption = consumePaymentPayload(paymentHeader) + const consumption = consumePaymentPayload(paymentHeader); if (!consumption.ok) { - return res.status(402).json({ error: consumption.error }) + return res.status(402).json({ error: consumption.error }); } // Captured for reconciliation — links this request to the settled // payment identifier without ever touching query content. ;(req as any).paymentId = consumption.paymentId } } - next() -}) + next(); +}); // Builds and persists a ReconciliationRecord for a paid route. Never throws — // a logging failure must not affect the response already sent to the client. @@ -402,19 +406,22 @@ export const MAX_QUERY_LENGTH = 256 export function validateQuery( q: unknown, ): { ok: true; cleanQ: string } | { ok: false; error: string } { - if (typeof q !== 'string' || !q.trim()) { - return { ok: false, error: 'Missing required parameter: q' } + if (typeof q !== "string" || !q.trim()) { + return { ok: false, error: "Missing required parameter: q" }; } if (q.length > MAX_QUERY_LENGTH) { - return { ok: false, error: `Query too long. Maximum ${MAX_QUERY_LENGTH} characters.` } + return { + ok: false, + error: `Query too long. Maximum ${MAX_QUERY_LENGTH} characters.`, + }; } // Strip null bytes and ASCII control characters (C0 + DEL) to prevent // log injection and odd Serper behavior. - const cleanQ = q.replace(/[\x00-\x1F\x7F]/g, '').trim() + const cleanQ = q.replace(/[\x00-\x1F\x7F]/g, "").trim(); if (!cleanQ) { - return { ok: false, error: 'Query contains no valid characters.' } + return { ok: false, error: "Query contains no valid characters." }; } - return { ok: true, cleanQ } + return { ok: true, cleanQ }; } // ─── GET /search ────────────────────────────────────────────────────────── @@ -439,43 +446,45 @@ app.get('/search', async (req: Request, res: Response) => { const requestBody: Record = { q: cleanQ, num: Math.min(parseInt(count) || 5, 20), - } + }; // Add freshness filter if provided (Serper supports date filters) if (freshness) { const dateFilters: Record = { - 'pd': 'qdr:d', // past day - 'pw': 'qdr:w', // past week - 'pm': 'qdr:m', // past month - } + pd: "qdr:d", // past day + pw: "qdr:w", // past week + pm: "qdr:m", // past month + }; if (dateFilters[freshness]) { - requestBody.tbs = dateFilters[freshness] + requestBody.tbs = dateFilters[freshness]; } } - const serperRes = await fetch('https://google.serper.dev/search', { - method: 'POST', + const serperRes = await fetch("https://google.serper.dev/search", { + method: "POST", headers: { - 'X-API-KEY': SERPER_API_KEY, - 'Content-Type': 'application/json', + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(requestBody), - }) + }); if (!serperRes.ok) { - const err = await serperRes.text() - console.error('[serper]', serperRes.status, err) - const errorBody: ApiErrorResponse = { error: `Serper.dev API error: ${serperRes.status}` } - return res.status(502).json(errorBody) + const err = await serperRes.text(); + console.error("[serper]", serperRes.status, err); + const errorBody: ApiErrorResponse = { + error: `Serper.dev API error: ${serperRes.status}`, + }; + return res.status(502).json(errorBody); } - const data: unknown = await serperRes.json() - const latencyMs = Date.now() - t0 + const data: unknown = await serperRes.json(); + const latencyMs = Date.now() - t0; - stats.totalQueries++ - stats.totalUsdcSettled += 0.001 - stats.latencies.push(latencyMs) - if (stats.latencies.length > 200) stats.latencies.shift() + stats.totalQueries++; + stats.totalUsdcSettled += 0.001; + stats.latencies.push(latencyMs); + if (stats.latencies.length > 200) stats.latencies.shift(); const results = normalizeOrganicResults(data) const queryMeta = normalizeQueryMetadata(data, cleanQ) @@ -484,16 +493,20 @@ app.get('/search', async (req: Request, res: Response) => { txHash = (req.headers['x-payment-response'] as string) || null // ── Optional AI suggestions via Groq ────────────────────────────────── - let suggestions: string[] = [] - if (req.query.suggestions === '1' && results.length > 0) { + let suggestions: string[] = []; + if (req.query.suggestions === "1" && results.length > 0) { try { - const topSnippets = results.slice(0, 3).map((r) => r.description).join(' | ') + const topSnippets = results + .slice(0, 3) + .map((r) => r.description) + .join(" | "); const suggCompletion = await groq.chat.completions.create({ - model: 'llama-3.3-70b-versatile', + model: "llama-3.3-70b-versatile", messages: [ { - role: 'system', - content: 'You are a search assistant. Given a query and top result snippets, return exactly 3 related search queries the user might want to explore next. Output only a JSON array of 3 strings, no explanation.', + role: "system", + content: + "You are a search assistant. Given a query and top result snippets, return exactly 3 related search queries the user might want to explore next. Output only a JSON array of 3 strings, no explanation.", }, { role: 'user', @@ -502,20 +515,23 @@ app.get('/search', async (req: Request, res: Response) => { ], max_tokens: 120, temperature: 0.7, - }) - const raw = suggCompletion.choices[0]?.message?.content || '[]' - const match = raw.match(/\[[\s\S]*\]/) + }); + const raw = suggCompletion.choices[0]?.message?.content || "[]"; + const match = raw.match(/\[[\s\S]*\]/); if (match) { - const parsed = JSON.parse(match[0]) + const parsed = JSON.parse(match[0]); if (Array.isArray(parsed)) { suggestions = parsed - .filter((s: unknown): s is string => typeof s === 'string' && s.trim().length > 0) + .filter( + (s: unknown): s is string => + typeof s === "string" && s.trim().length > 0, + ) .map((s: string) => s.trim()) - .slice(0, 3) + .slice(0, 3); } } } catch (err: any) { - console.warn('[suggestions] Groq error:', err.message) + console.warn("[suggestions] Groq error:", err.message); } } @@ -529,11 +545,11 @@ app.get('/search', async (req: Request, res: Response) => { count: results.length, network: NETWORK, paidAmount: AMOUNT_USDC, - currency: 'USDC', + currency: "USDC", txHash, latencyMs, suggestions, - } + }; // Record opted-in receipt (cap 50, in-memory) try { @@ -552,7 +568,7 @@ app.get('/search', async (req: Request, res: Response) => { } finally { recordReconciliation({ req, route: '/search', requestId, providerDelivered, resultCount, txHash }) } -}) +}); // ─── GET /images ────────────────────────────────────────────────────────── app.get('/images', async (req: Request, res: Response) => { @@ -576,31 +592,33 @@ app.get('/images', async (req: Request, res: Response) => { const serperRes = await fetch('https://google.serper.dev/images', { method: 'POST', headers: { - 'X-API-KEY': SERPER_API_KEY, - 'Content-Type': 'application/json', + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify({ q: cleanQ, num: Math.min(parseInt(count) || 10, 10), }), - }) + }); if (!serperRes.ok) { - const err = await serperRes.text() - console.error('[serper images]', serperRes.status, err) - const errorBody: ApiErrorResponse = { error: `Serper.dev API error: ${serperRes.status}` } - return res.status(502).json(errorBody) + const err = await serperRes.text(); + console.error("[serper images]", serperRes.status, err); + const errorBody: ApiErrorResponse = { + error: `Serper.dev API error: ${serperRes.status}`, + }; + return res.status(502).json(errorBody); } - const data: unknown = await serperRes.json() - const latencyMs = Date.now() - t0 + const data: unknown = await serperRes.json(); + const latencyMs = Date.now() - t0; - stats.totalQueries++ - stats.totalUsdcSettled += parseFloat(AMOUNT_USDC) - stats.latencies.push(latencyMs) - if (stats.latencies.length > 200) stats.latencies.shift() + stats.totalQueries++; + stats.totalUsdcSettled += parseFloat(AMOUNT_USDC); + stats.latencies.push(latencyMs); + if (stats.latencies.length > 200) stats.latencies.shift(); - const results = normalizeImageResults(data) + const results = normalizeImageResults(data); txHash = (req.headers['x-payment-response'] as string) || null @@ -610,10 +628,10 @@ app.get('/images', async (req: Request, res: Response) => { count: results.length, network: NETWORK, paidAmount: AMOUNT_USDC, - currency: 'USDC', + currency: "USDC", txHash, latencyMs, - } + }; providerDelivered = true resultCount = results.length @@ -625,7 +643,7 @@ app.get('/images', async (req: Request, res: Response) => { } finally { recordReconciliation({ req, route: '/images', requestId, providerDelivered, resultCount, txHash }) } -}) +}); // ─── GET /news ──────────────────────────────────────────────────────────── app.get('/news', async (req: Request, res: Response) => { @@ -649,44 +667,46 @@ app.get('/news', async (req: Request, res: Response) => { const requestBody: Record = { q: cleanQ, num: Math.min(parseInt(count) || 10, 20), - } + }; if (freshness) { const dateFilters: Record = { - 'pd': 'qdr:d', - 'pw': 'qdr:w', - 'pm': 'qdr:m', - } + pd: "qdr:d", + pw: "qdr:w", + pm: "qdr:m", + }; if (dateFilters[freshness]) { - requestBody.tbs = dateFilters[freshness] + requestBody.tbs = dateFilters[freshness]; } } - const serperRes = await fetch('https://google.serper.dev/news', { - method: 'POST', + const serperRes = await fetch("https://google.serper.dev/news", { + method: "POST", headers: { - 'X-API-KEY': SERPER_API_KEY, - 'Content-Type': 'application/json', + "X-API-KEY": SERPER_API_KEY, + "Content-Type": "application/json", }, body: JSON.stringify(requestBody), - }) + }); if (!serperRes.ok) { - const err = await serperRes.text() - console.error('[serper news]', serperRes.status, err) - const errorBody: ApiErrorResponse = { error: `Serper.dev API error: ${serperRes.status}` } - return res.status(502).json(errorBody) + const err = await serperRes.text(); + console.error("[serper news]", serperRes.status, err); + const errorBody: ApiErrorResponse = { + error: `Serper.dev API error: ${serperRes.status}`, + }; + return res.status(502).json(errorBody); } - const data: unknown = await serperRes.json() - const latencyMs = Date.now() - t0 + const data: unknown = await serperRes.json(); + const latencyMs = Date.now() - t0; - stats.totalQueries++ - stats.totalUsdcSettled += parseFloat(AMOUNT_USDC) - stats.latencies.push(latencyMs) - if (stats.latencies.length > 200) stats.latencies.shift() + stats.totalQueries++; + stats.totalUsdcSettled += parseFloat(AMOUNT_USDC); + stats.latencies.push(latencyMs); + if (stats.latencies.length > 200) stats.latencies.shift(); - const results = normalizeNewsResults(data) + const results = normalizeNewsResults(data); txHash = (req.headers['x-payment-response'] as string) || null @@ -696,10 +716,10 @@ app.get('/news', async (req: Request, res: Response) => { count: results.length, network: NETWORK, paidAmount: AMOUNT_USDC, - currency: 'USDC', + currency: "USDC", txHash, latencyMs, - } + }; providerDelivered = true resultCount = results.length @@ -1058,29 +1078,36 @@ app.get('/jobs', (_req: Request, res: Response) => { }) // ─── GET /health ────────────────────────────────────────────────────────── -app.get('/health', (_req: Request, res: Response) => { +app.get("/health", (_req: Request, res: Response) => { const avg = stats.latencies.length - ? Math.round(stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length) - : 0 - - const up = Math.floor((Date.now() - stats.startTime) / 1000) - const uptime = up < 60 ? `${up}s` : up < 3600 ? `${Math.floor(up / 60)}m` : `${Math.floor(up / 3600)}h` + ? Math.round( + stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length, + ) + : 0; + + const up = Math.floor((Date.now() - stats.startTime) / 1000); + const uptime = + up < 60 + ? `${up}s` + : up < 3600 + ? `${Math.floor(up / 60)}m` + : `${Math.floor(up / 3600)}h`; res.json({ - status: 'ok', - network: NETWORK, - pricePerQuery: '0.001 USDC', - protocol: 'x402', - facilitator: FACILITATOR_URL, - totalQueries: stats.totalQueries, - totalUsdcSettled: stats.totalUsdcSettled.toFixed(4), - avgLatencyMs: avg, + status: "ok", + network: NETWORK, + pricePerQuery: "0.001 USDC", + protocol: "x402", + facilitator: FACILITATOR_URL, + totalQueries: stats.totalQueries, + totalUsdcSettled: stats.totalUsdcSettled.toFixed(4), + avgLatencyMs: avg, uptime, - serperApiConfigured: !!SERPER_API_KEY, - groqApiConfigured: !!GROQ_API_KEY, + serperApiConfigured: !!SERPER_API_KEY, + groqApiConfigured: !!GROQ_API_KEY, receivingAddressConfigured: !!RECEIVING_ADDRESS, - }) -}) + }); +}); // ─── POST /ai/chat ──────────────────────────────────────────────────────── // Streams responses as Server-Sent Events when the client sends @@ -1095,105 +1122,126 @@ app.post('/ai/chat', async (req: Request, res: Response) => { model?: string } - if (!messages?.length) { - return res.status(400).json({ error: 'messages array required' }) + return { ok: true, value: sanitized }; +} + +app.post("/ai/chat", async (req: Request, res: Response) => { + const { + messages, + model: requestedModel, + context: trustedContext, + } = req.body as { + messages?: { role: "system" | "user" | "assistant"; content: string }[]; + model?: string; + context?: string; + }; + + const sanitizedMessages = sanitizeClientMessages(messages); + if (!sanitizedMessages.ok) { + return res.status(400).json({ error: sanitizedMessages.error }); } // Available models whitelist const AVAILABLE_MODELS = [ - 'llama-3.3-70b-versatile', - 'llama-3.1-8b-instant', - 'mixtral-8x7b-32768', - ] - + "llama-3.3-70b-versatile", + "llama-3.1-8b-instant", + "mixtral-8x7b-32768", + ]; + // Use requested model if valid, otherwise fall back to default - const model = requestedModel && AVAILABLE_MODELS.includes(requestedModel) - ? requestedModel - : 'llama-3.3-70b-versatile' + const model = + requestedModel && AVAILABLE_MODELS.includes(requestedModel) + ? requestedModel + : "llama-3.3-70b-versatile"; const wantsStream = - (req.headers.accept || '').includes('text/event-stream') || + (req.headers.accept || "").includes("text/event-stream") || (req.body as any)?.stream === true || - req.query.stream === '1' + req.query.stream === "1"; + + const trustedSystemContext = + typeof trustedContext === "string" && trustedContext.trim() + ? trustedContext.trim() + : null; const groqMessages = [ { - role: 'system' as const, + role: "system" as const, content: - 'You are StellarSearch AI, a concise research assistant. Help users craft better search queries and understand results. Keep responses under 200 words.', + "You are StellarSearch AI, a concise research assistant. Help users craft better search queries and understand results. Keep responses under 200 words.", }, - ...messages, - ] + ...(trustedSystemContext + ? [{ role: "system" as const, content: trustedSystemContext }] + : []), + ...sanitizedMessages.value, + ]; if (!wantsStream) { try { const completion = await groq.chat.completions.create({ model, messages: groqMessages, - max_tokens: 512, + max_tokens: 512, temperature: 0.7, - }) + }); - const content = completion.choices[0]?.message?.content || 'No response.' - return res.json({ content, model: completion.model }) + const content = completion.choices[0]?.message?.content || "No response."; + return res.json({ content, model: completion.model }); } catch (err: any) { - console.error('[groq error]', err.message) - return res.status(500).json({ error: `Groq AI error: ${err.message}` }) + console.error("[groq error]", err.message); + return res.status(500).json({ error: `Groq AI error: ${err.message}` }); } } // SSE path - res.setHeader('Content-Type', 'text/event-stream') - res.setHeader('Cache-Control', 'no-cache, no-transform') - res.setHeader('Connection', 'keep-alive') + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); // Disable proxy buffering (e.g. nginx) so chunks flush immediately - res.setHeader('X-Accel-Buffering', 'no') - res.flushHeaders?.() + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders?.(); const sendEvent = (event: string, data: Record) => { - res.write(`event: ${event}\n`) - res.write(`data: ${JSON.stringify(data)}\n\n`) - } + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; // Abort the Groq stream if the client disconnects mid-response. - const controller = new AbortController() - req.on('close', () => controller.abort()) + const controller = new AbortController(); + req.on("close", () => controller.abort()); try { const stream = await groq.chat.completions.create( { model, messages: groqMessages, - max_tokens: 512, + max_tokens: 512, temperature: 0.7, stream: true, }, { signal: controller.signal }, - ) + ); for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta?.content - if (delta) sendEvent('delta', { content: delta }) + const delta = chunk.choices[0]?.delta?.content; + if (delta) sendEvent("delta", { content: delta }); } - sendEvent('done', { model }) - res.end() + sendEvent("done", { model }); + res.end(); } catch (err: any) { - if (controller.signal.aborted) return res.end() - console.error('[groq stream error]', err.message) - sendEvent('error', { error: `Groq AI error: ${err.message}` }) - res.end() + if (controller.signal.aborted) return res.end(); + console.error("[groq stream error]", err.message); + sendEvent("error", { error: `Groq AI error: ${err.message}` }); + res.end(); } -}) - - - +}); // ─── GET / ──────────────────────────────────────────────────────────────── -app.get('/', (_req: Request, res: Response) => { +app.get("/", (_req: Request, res: Response) => { res.json({ - name: 'StellarSearch', - version: '1.0.0', - description: 'Pay-per-query web search for AI agents via x402 on Stellar', + name: "StellarSearch", + version: "1.0.0", + description: "Pay-per-query web search for AI agents via x402 on Stellar", endpoints: { 'GET /search?q=': '0.001 USDC via x402', 'GET /images?q=': '0.001 USDC via x402 — image results', @@ -1214,16 +1262,16 @@ app.get('/', (_req: Request, res: Response) => { }) // ─── Start ──────────────────────────────────────────────────────────────── -if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { +if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") { app.listen(PORT, () => { - console.log(`\n🚀 StellarSearch on http://localhost:${PORT}`) - console.log(` Network: ${NETWORK}`) - console.log(` Facilitator: ${FACILITATOR_URL}`) - console.log(` Serper: ${SERPER_API_KEY ? '✓' : '✗ MISSING'}`) - console.log(` Groq: ${GROQ_API_KEY ? '✓' : '✗ MISSING'}`) - console.log(` Receiving: ${RECEIVING_ADDRESS || '✗ MISSING'}`) - console.log(` ${getCorsStartupMessage()}\n`) - }) + console.log(`\n🚀 StellarSearch on http://localhost:${PORT}`); + console.log(` Network: ${NETWORK}`); + console.log(` Facilitator: ${FACILITATOR_URL}`); + console.log(` Serper: ${SERPER_API_KEY ? "✓" : "✗ MISSING"}`); + console.log(` Groq: ${GROQ_API_KEY ? "✓" : "✗ MISSING"}`); + console.log(` Receiving: ${RECEIVING_ADDRESS || "✗ MISSING"}`); + console.log(` ${getCorsStartupMessage()}\n`); + }); } -export default app +export default app; diff --git a/src/components/ai/GroqAssistant.tsx b/src/components/ai/GroqAssistant.tsx index 4779c96..afdc374 100644 --- a/src/components/ai/GroqAssistant.tsx +++ b/src/components/ai/GroqAssistant.tsx @@ -5,34 +5,38 @@ import { Bot, Send, X, ChevronDown } from 'lucide-react' import type { SearchResult } from '../../hooks/useSearch' interface Message { - role: 'system' | 'user' | 'assistant' - content: string - model?: string // Add model info to messages + role: "system" | "user" | "assistant"; + content: string; + model?: string; // Add model info to messages } interface LastSearch { - query: string - results: SearchResult[] + query: string; + results: SearchResult[]; } interface Props { - lastSearch?: LastSearch | null + lastSearch?: LastSearch | null; } // Available Groq models const AVAILABLE_MODELS = [ - { id: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70B', description: 'Most capable' }, - { id: 'llama-3.1-8b-instant', label: 'Llama 3.1 8B', description: 'Fastest' }, - { id: 'mixtral-8x7b-32768', label: 'Mixtral 8x7B', description: 'Balanced' }, -] as const + { + id: "llama-3.3-70b-versatile", + label: "Llama 3.3 70B", + description: "Most capable", + }, + { id: "llama-3.1-8b-instant", label: "Llama 3.1 8B", description: "Fastest" }, + { id: "mixtral-8x7b-32768", label: "Mixtral 8x7B", description: "Balanced" }, +] as const; -type ModelId = typeof AVAILABLE_MODELS[number]['id'] +type ModelId = (typeof AVAILABLE_MODELS)[number]["id"]; const SYSTEM_INTRO: Message = { - role: 'assistant', + role: "assistant", content: "Hi! I'm your AI research assistant powered by Groq. I can help you craft better search queries, summarise results, or explain topics. Each search costs 0.001 USDC on Stellar. What would you like to research?", -} +}; const SERVER_URL = readBrowserConfig().apiBaseUrl @@ -43,46 +47,50 @@ async function consumeSSE( onDelta: (delta: string) => void, onModel?: (model: string) => void, ): Promise { - const reader = body.getReader() - const decoder = new TextDecoder('utf-8') - let buffer = '' + const reader = body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; while (true) { - const { value, done } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); // SSE events are delimited by a blank line. - let blankLine: number - while ((blankLine = buffer.indexOf('\n\n')) !== -1) { - const rawEvent = buffer.slice(0, blankLine) - buffer = buffer.slice(blankLine + 2) + let blankLine: number; + while ((blankLine = buffer.indexOf("\n\n")) !== -1) { + const rawEvent = buffer.slice(0, blankLine); + buffer = buffer.slice(blankLine + 2); - let event = 'message' - let data = '' - for (const line of rawEvent.split('\n')) { - if (line.startsWith('event:')) event = line.slice(6).trim() - else if (line.startsWith('data:')) data += line.slice(5).trim() + let event = "message"; + let data = ""; + for (const line of rawEvent.split("\n")) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) data += line.slice(5).trim(); } - if (!data) continue + if (!data) continue; - if (event === 'delta') { + if (event === "delta") { try { - const { content } = JSON.parse(data) as { content?: string } - if (content) onDelta(content) - } catch { /* malformed chunk; skip */ } - } else if (event === 'done') { + const { content } = JSON.parse(data) as { content?: string }; + if (content) onDelta(content); + } catch { + /* malformed chunk; skip */ + } + } else if (event === "done") { try { - const { model } = JSON.parse(data) as { model?: string } - if (model && onModel) onModel(model) - } catch { /* ignore malformed done event */ } - return - } else if (event === 'error') { + const { model } = JSON.parse(data) as { model?: string }; + if (model && onModel) onModel(model); + } catch { + /* ignore malformed done event */ + } + return; + } else if (event === "error") { try { - const { error } = JSON.parse(data) as { error?: string } - throw new Error(error || 'stream error') + const { error } = JSON.parse(data) as { error?: string }; + throw new Error(error || "stream error"); } catch (e) { - throw e instanceof Error ? e : new Error('stream error') + throw e instanceof Error ? e : new Error("stream error"); } } } @@ -92,127 +100,146 @@ async function consumeSSE( // Build a system message that gives Groq context from the user's most recent // paid search so follow-up questions can be answered without re-asking. function buildSearchContextMessage(s: LastSearch): Message { - const top = s.results.slice(0, 3).map((r, i) => - `${i + 1}. ${r.title} — ${r.url}\n ${r.description}` - ).join('\n') + const top = s.results + .slice(0, 3) + .map((r, i) => `${i + 1}. ${r.title} — ${r.url}\n ${r.description}`) + .join("\n"); return { - role: 'system', + role: "system", content: `Context — the user's most recent search:\n` + `Query: "${s.query}"\n` + `Top results:\n${top}\n\n` + `Use this when answering follow-up questions. Do not repeat the list verbatim.`, - } + }; } export function GroqAssistant({ lastSearch }: Props = {}) { - const [open, setOpen] = useState(false) - const [messages, setMessages] = useState([SYSTEM_INTRO]) - const [input, setInput] = useState('') - const [loading, setLoading] = useState(false) - const [selectedModel, setSelectedModel] = useState('llama-3.3-70b-versatile') - const [showModelDropdown, setShowModelDropdown] = useState(false) - const bottomRef = useRef(null) - const contextInjectedFor = useRef(null) + const [open, setOpen] = useState(false); + const [messages, setMessages] = useState([SYSTEM_INTRO]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [selectedModel, setSelectedModel] = useState( + "llama-3.3-70b-versatile", + ); + const [showModelDropdown, setShowModelDropdown] = useState(false); + const bottomRef = useRef(null); + const contextInjectedFor = useRef(null); useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); // Inject a search-context system message the first time the assistant is // opened after a search completes. Re-inject if the query changes. useEffect(() => { - if (!open || !lastSearch || !lastSearch.results.length) return - if (contextInjectedFor.current === lastSearch.query) return - contextInjectedFor.current = lastSearch.query - setMessages(prev => [...prev, buildSearchContextMessage(lastSearch)]) - }, [open, lastSearch]) + if (!open || !lastSearch || !lastSearch.results.length) return; + if (contextInjectedFor.current === lastSearch.query) return; + contextInjectedFor.current = lastSearch.query; + setMessages((prev) => [...prev, buildSearchContextMessage(lastSearch)]); + }, [open, lastSearch]); const send = async () => { - if (!input.trim() || loading) return - const userMsg: Message = { role: 'user', content: input.trim() } - const history = [...messages, userMsg] - setMessages(history) - setInput('') - setLoading(true) + if (!input.trim() || loading) return; + const userMsg: Message = { role: "user", content: input.trim() }; + const history = [...messages, userMsg]; + setMessages(history); + setInput(""); + setLoading(true); // Insert a placeholder assistant message we'll stream tokens into. - setMessages(prev => [...prev, { role: 'assistant', content: '', model: selectedModel }]) + setMessages((prev) => [ + ...prev, + { role: "assistant", content: "", model: selectedModel }, + ]); + + const trustedContext = history + .filter((m) => m.role === "system") + .map((m) => m.content) + .join("\n\n"); const payload = JSON.stringify({ - messages: history.map(m => ({ role: m.role, content: m.content })), + messages: history + .filter((m) => m.role !== "system") + .map((m) => ({ role: m.role, content: m.content })), + context: trustedContext || undefined, model: selectedModel, - }) + }); try { const res = await fetch(`${SERVER_URL}/ai/chat`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', + "Content-Type": "application/json", + Accept: "text/event-stream", }, body: payload, - }) - if (!res.ok) throw new Error(`Server error ${res.status}`) + }); + if (!res.ok) throw new Error(`Server error ${res.status}`); - const isSSE = res.headers.get('content-type')?.includes('text/event-stream') + const isSSE = res.headers + .get("content-type") + ?.includes("text/event-stream"); if (isSSE && res.body) { await consumeSSE( res.body, - delta => { - setMessages(prev => { - const next = [...prev] - const last = next[next.length - 1] - if (last?.role === 'assistant') { - next[next.length - 1] = { ...last, content: last.content + delta } + (delta) => { + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant") { + next[next.length - 1] = { + ...last, + content: last.content + delta, + }; } - return next - }) + return next; + }); }, - model => { + (model) => { // Update the model info in the last message - setMessages(prev => { - const next = [...prev] - const last = next[next.length - 1] - if (last?.role === 'assistant') { - next[next.length - 1] = { ...last, model } + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + if (last?.role === "assistant") { + next[next.length - 1] = { ...last, model }; } - return next - }) - } - ) + return next; + }); + }, + ); } else { // Non-streaming fallback: server returned JSON. - const data = await res.json() - setMessages(prev => { - const next = [...prev] - next[next.length - 1] = { - role: 'assistant', - content: data.content ?? 'No response.', - model: data.model || selectedModel - } - return next - }) + const data = await res.json(); + setMessages((prev) => { + const next = [...prev]; + next[next.length - 1] = { + role: "assistant", + content: data.content ?? "No response.", + model: data.model || selectedModel, + }; + return next; + }); } } catch (err: any) { - setMessages(prev => { - const next = [...prev] + setMessages((prev) => { + const next = [...prev]; next[next.length - 1] = { - role: 'assistant', + role: "assistant", content: `⚠️ Could not reach AI server: ${err.message}. Make sure the backend is running with GROQ_API_KEY set.`, model: selectedModel, - } - return next - }) + }; + return next; + }); } finally { - setLoading(false) + setLoading(false); } - } + }; const getModelLabel = (modelId: string) => { - const model = AVAILABLE_MODELS.find(m => m.id === modelId) - return model?.label || modelId - } + const model = AVAILABLE_MODELS.find((m) => m.id === modelId); + return model?.label || modelId; + }; return ( <> @@ -220,14 +247,17 @@ export function GroqAssistant({ lastSearch }: Props = {}) { setOpen(true)} className="fixed bottom-6 right-6 z-40 w-12 h-12 rounded-full flex items-center justify-center" - style={{ background: 'rgba(0,245,255,0.15)', border: '1px solid rgba(0,245,255,0.4)' }} + style={{ + background: "rgba(0,245,255,0.15)", + border: "1px solid rgba(0,245,255,0.4)", + }} whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }} animate={{ boxShadow: [ - '0 0 15px rgba(0,245,255,0.3)', - '0 0 35px rgba(0,245,255,0.6)', - '0 0 15px rgba(0,245,255,0.3)', + "0 0 15px rgba(0,245,255,0.3)", + "0 0 35px rgba(0,245,255,0.6)", + "0 0 15px rgba(0,245,255,0.3)", ], }} transition={{ duration: 2, repeat: Infinity }} @@ -244,32 +274,34 @@ export function GroqAssistant({ lastSearch }: Props = {}) { exit={{ opacity: 0, y: 20, scale: 0.95 }} className="fixed bottom-20 right-6 z-40 w-96 rounded-2xl overflow-hidden flex flex-col" style={{ - height: '480px', - background: 'rgba(6,13,20,0.96)', - border: '1px solid rgba(0,245,255,0.2)', - backdropFilter: 'blur(20px)', + height: "480px", + background: "rgba(6,13,20,0.96)", + border: "1px solid rgba(0,245,255,0.2)", + backdropFilter: "blur(20px)", }} > {/* Header */}
- GROQ AI + + GROQ AI + {/* Model Selector Dropdown */}
- + {/* Dropdown Menu */} {showModelDropdown && ( @@ -279,26 +311,32 @@ export function GroqAssistant({ lastSearch }: Props = {}) { exit={{ opacity: 0, y: -5 }} className="absolute top-full left-0 mt-1 w-48 rounded-lg overflow-hidden z-50" style={{ - background: 'rgba(6,13,20,0.98)', - border: '1px solid rgba(0,245,255,0.2)', + background: "rgba(6,13,20,0.98)", + border: "1px solid rgba(0,245,255,0.2)", }} > - {AVAILABLE_MODELS.map(model => ( + {AVAILABLE_MODELS.map((model) => ( ))} @@ -316,45 +354,56 @@ export function GroqAssistant({ lastSearch }: Props = {}) { {/* Messages */}
- {messages.filter(m => m.role !== 'system').map((msg, i) => ( - -
m.role !== "system") + .map((msg, i) => ( + - {msg.content} -
- {/* Show model metadata for assistant messages */} - {msg.role === 'assistant' && msg.model && ( -
- {getModelLabel(msg.model)} +
+ {msg.content}
- )} - - ))} + {/* Show model metadata for assistant messages */} + {msg.role === "assistant" && msg.model && ( +
+ {getModelLabel(msg.model)} +
+ )} + + ))} {loading && (
- {[0, 1, 2].map(j => ( + {[0, 1, 2].map((j) => ( ))}
@@ -369,20 +418,20 @@ export function GroqAssistant({ lastSearch }: Props = {}) { setInput(e.target.value)} - onKeyDown={e => e.key === 'Enter' && !e.shiftKey && send()} + onChange={(e) => setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && send()} placeholder="Ask anything..." disabled={loading} className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder:text-white/25 outline-none focus:border-neon-cyan/30 disabled:opacity-50" - style={{ caretColor: '#00f5ff' }} + style={{ caretColor: "#00f5ff" }} />