From a703cdfb88f99407dccf279e9c1f73d05b9ced2f Mon Sep 17 00:00:00 2001 From: Urugonda Vishnu Date: Tue, 31 Mar 2026 22:57:00 +0530 Subject: [PATCH] Add customer-discovery-app: AI-powered market research tool Dispatches 10 parallel TinyFish web agents to analyze similar companies, extract customer data (case studies, testimonials, pricing, features), and synthesize findings into actionable customer segment profiles. Tech: Next.js 16, TypeScript, TinyFish SDK, OpenRouter, Zustand, Tailwind, shadcn/ui --- customer-discovery-app/.env.example | 7 + customer-discovery-app/.gitignore | 37 + customer-discovery-app/README.md | 134 + .../app/api/analyze-company/route.ts | 115 + .../app/api/synthesize/route.ts | 114 + .../app/api/tinyfish-agent/route.ts | 118 + .../app/api/tinyfish-agents-batch/route.ts | 169 + customer-discovery-app/app/globals.css | 241 ++ customer-discovery-app/app/layout.tsx | 24 + customer-discovery-app/app/page.tsx | 95 + customer-discovery-app/components.json | 21 + .../components/agent-card.tsx | 175 + .../components/agent-grid.tsx | 596 ++++ .../components/app-sidebar.tsx | 250 ++ .../components/results-view.tsx | 398 +++ .../components/seed-input-form.tsx | 235 ++ .../components/synthesis-view.tsx | 296 ++ .../components/ui/alert.tsx | 66 + .../components/ui/badge.tsx | 46 + .../components/ui/button.tsx | 60 + customer-discovery-app/components/ui/card.tsx | 92 + .../components/ui/dialog.tsx | 143 + .../components/ui/input.tsx | 21 + .../components/ui/label.tsx | 24 + .../components/ui/progress.tsx | 31 + .../components/ui/separator.tsx | 28 + customer-discovery-app/components/ui/tabs.tsx | 66 + .../components/ui/tooltip.tsx | 61 + .../hooks/use-auto-synthesis.ts | 100 + customer-discovery-app/lib/discovery-store.ts | 109 + customer-discovery-app/lib/openrouter.ts | 119 + customer-discovery-app/lib/types.ts | 70 + customer-discovery-app/lib/utils.ts | 6 + customer-discovery-app/next.config.mjs | 11 + customer-discovery-app/package-lock.json | 2834 +++++++++++++++++ customer-discovery-app/package.json | 43 + customer-discovery-app/pnpm-lock.yaml | 5 + customer-discovery-app/postcss.config.mjs | 8 + customer-discovery-app/public/icon.svg | 26 + customer-discovery-app/tsconfig.json | 41 + customer-discovery-app/vercel.json | 16 + 41 files changed, 7051 insertions(+) create mode 100644 customer-discovery-app/.env.example create mode 100644 customer-discovery-app/.gitignore create mode 100644 customer-discovery-app/README.md create mode 100644 customer-discovery-app/app/api/analyze-company/route.ts create mode 100644 customer-discovery-app/app/api/synthesize/route.ts create mode 100644 customer-discovery-app/app/api/tinyfish-agent/route.ts create mode 100644 customer-discovery-app/app/api/tinyfish-agents-batch/route.ts create mode 100644 customer-discovery-app/app/globals.css create mode 100644 customer-discovery-app/app/layout.tsx create mode 100644 customer-discovery-app/app/page.tsx create mode 100644 customer-discovery-app/components.json create mode 100644 customer-discovery-app/components/agent-card.tsx create mode 100644 customer-discovery-app/components/agent-grid.tsx create mode 100644 customer-discovery-app/components/app-sidebar.tsx create mode 100644 customer-discovery-app/components/results-view.tsx create mode 100644 customer-discovery-app/components/seed-input-form.tsx create mode 100644 customer-discovery-app/components/synthesis-view.tsx create mode 100644 customer-discovery-app/components/ui/alert.tsx create mode 100644 customer-discovery-app/components/ui/badge.tsx create mode 100644 customer-discovery-app/components/ui/button.tsx create mode 100644 customer-discovery-app/components/ui/card.tsx create mode 100644 customer-discovery-app/components/ui/dialog.tsx create mode 100644 customer-discovery-app/components/ui/input.tsx create mode 100644 customer-discovery-app/components/ui/label.tsx create mode 100644 customer-discovery-app/components/ui/progress.tsx create mode 100644 customer-discovery-app/components/ui/separator.tsx create mode 100644 customer-discovery-app/components/ui/tabs.tsx create mode 100644 customer-discovery-app/components/ui/tooltip.tsx create mode 100644 customer-discovery-app/hooks/use-auto-synthesis.ts create mode 100644 customer-discovery-app/lib/discovery-store.ts create mode 100644 customer-discovery-app/lib/openrouter.ts create mode 100644 customer-discovery-app/lib/types.ts create mode 100644 customer-discovery-app/lib/utils.ts create mode 100644 customer-discovery-app/next.config.mjs create mode 100644 customer-discovery-app/package-lock.json create mode 100644 customer-discovery-app/package.json create mode 100644 customer-discovery-app/pnpm-lock.yaml create mode 100644 customer-discovery-app/postcss.config.mjs create mode 100644 customer-discovery-app/public/icon.svg create mode 100644 customer-discovery-app/tsconfig.json create mode 100644 customer-discovery-app/vercel.json diff --git a/customer-discovery-app/.env.example b/customer-discovery-app/.env.example new file mode 100644 index 000000000..91335e85f --- /dev/null +++ b/customer-discovery-app/.env.example @@ -0,0 +1,7 @@ +# OpenRouter API Key - Used for company analysis and synthesis +# Get your key at https://openrouter.ai/keys +OPENROUTER_API_KEY= + +# TinyFish API Key - Used for TinyFish browser agent research +# Get your key at https://tinyfish.ai +TINYFISH_API_KEY= diff --git a/customer-discovery-app/.gitignore b/customer-discovery-app/.gitignore new file mode 100644 index 000000000..42d989191 --- /dev/null +++ b/customer-discovery-app/.gitignore @@ -0,0 +1,37 @@ +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (keep .env.example tracked) +.env +.env*.local +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# OS files +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/customer-discovery-app/README.md b/customer-discovery-app/README.md new file mode 100644 index 000000000..1018bbeae --- /dev/null +++ b/customer-discovery-app/README.md @@ -0,0 +1,134 @@ +# Customer Discovery + +**Live:** [https://customer-discovery-app-ten.vercel.app](https://customer-discovery-app-ten.vercel.app) + +Customer Discovery is an AI-powered market research tool that identifies customer segments by analyzing companies similar to a given seed company. It uses the TinyFish SDK to dispatch up to 10 parallel web agents — one per similar company — that each navigate the target website, extract case studies, testimonials, pricing, features, and customer types, then synthesizes all findings into actionable customer segment profiles. + +## Demo + +https://github.com/user-attachments/assets/3a7fd32d-8d8d-4233-9cce-ce3ddeeb360d + +## TinyFish SDK Usage + +The app uses the TinyFish TypeScript SDK (`@tiny-fish/sdk`) to stream live events from parallel browser agents. Each agent navigates a company's website, inspects key pages (customers, pricing, about), and returns structured JSON findings: + +```typescript +import { TinyFish } from "@tiny-fish/sdk"; + +const client = new TinyFish({ apiKey }); + +const agentStream = await client.agent.stream({ + url: company.url, + goal: `You are a customer discovery agent analyzing ${company.name} (${company.url}). + +IMPORTANT: Stay ONLY on this company's website. Do NOT visit external websites. + +STEP 1 — Navigate to the homepage and get an overview of what the company does. + +STEP 2 — Quickly check these pages if they exist (skip if not found): +- /customers or /case-studies +- /pricing +- /about + +STEP 3 — Extract this structured data: +- Company overview (1-2 sentences) +- Customer types they serve (e.g., startups, enterprises, agencies, SMBs) +- Case studies: customer name, industry, brief summary (up to 5) +- Testimonials: quote, author, company, role (up to 5) +- Pricing tiers: plan names and who each is for +- Key features: main product capabilities (up to 8) +- Integrations: tools/platforms they connect with + +STEP 4 — Return findings as JSON: +{ + "overview": "What the company does", + "customerTypes": ["type1", "type2"], + "caseStudies": [{"customer": "name", "industry": "industry", "summary": "summary"}], + "testimonials": [{"quote": "quote", "author": "name", "company": "company", "role": "role"}], + "pricingTiers": ["Free - for individuals", "Pro $X/mo - for teams"], + "keyFeatures": ["feature1", "feature2"], + "integrations": ["integration1", "integration2"] +} + +FINAL RULE: Return ONLY the JSON object above. No planning text, no step reports, no reasoning traces.`, +}); + +for await (const event of agentStream) { + // STREAMING_URL → live browser preview (iframe) + // PROGRESS → agent step updates + // COMPLETE → structured company findings JSON +} +``` + +All 10 agents run concurrently via a single batch SSE connection, bypassing browser connection limits. Events are streamed in real time — each agent card shows a live browser preview while browsing. + +## How to Run + +### Prerequisites + +- Node.js 18+ +- A TinyFish API key ([get one here](https://agent.tinyfish.ai)) +- An OpenRouter API key ([get one here](https://openrouter.ai/keys)) + +### Setup + +1. Install dependencies: + +```bash +cd customer-discovery-app +npm install +``` + +2. Create a `.env` file with your API keys: + +``` +OPENROUTER_API_KEY=your_openrouter_api_key_here +TINYFISH_API_KEY=your_tinyfish_api_key_here +``` + +3. Start the dev server: + +```bash +npm run dev +``` + +4. Open [http://localhost:3000](http://localhost:3000) + +## Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────┐ +│ User (Browser) │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Next.js Frontend (Tailwind + shadcn/ui + Framer) │ │ +│ │ │ │ +│ │ 1. Enter seed company name + URL │ │ +│ │ 2. AI finds 10 similar companies (via OpenRouter) │ │ +│ │ 3. 10 TinyFish agents launch in parallel │ │ +│ │ 4. Watch live browser previews as agents research │ │ +│ │ 5. View synthesized customer segments + insights │ │ +│ └───────────┬────────────────────────────┬───────────────┘ │ +└──────────────┼────────────────────────────┼──────────────────┘ + │ │ + POST /api/analyze-company POST /api/tinyfish-agents-batch + │ │ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────────┐ +│ OpenRouter API │ │ TinyFish SDK (agent.tinyfish.ai) │ +│ │ │ │ +│ Gemini Flash model │ │ client.agent.stream() x 10 │ +│ finds 10 similar │ │ Each agent navigates one company │ +│ companies + profile │ │ │ +└──────────────────────┘ │ SSE Stream Events: │ + │ • STREAMING_URL → live preview │ + POST /api/synthesize │ • PROGRESS → step updates │ + │ │ • COMPLETE → structured JSON │ + ▼ └─────┬────┬────┬────┬───────────────┘ +┌──────────────────────┐ │ │ │ │ +│ OpenRouter API │ ▼ ▼ ▼ ▼ +│ │ ┌──────┐┌──────┐┌──────┐ ... (10 companies) +│ Combines all agent │ │ Co.A ││ Co.B ││ Co.C │ +│ findings into │ │ site ││ site ││ site │ +│ customer segments │ └──────┘└──────┘└──────┘ +└──────────────────────┘ +``` diff --git a/customer-discovery-app/app/api/analyze-company/route.ts b/customer-discovery-app/app/api/analyze-company/route.ts new file mode 100644 index 000000000..6f2e186aa --- /dev/null +++ b/customer-discovery-app/app/api/analyze-company/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { SimilarCompany } from "@/lib/types"; +import { callOpenRouterWithRetry, parseOpenRouterJson } from "@/lib/openrouter"; + +interface AnalysisResult { + companyProfile: { + industry: string; + positioning: string; + targetMarket: string; + }; + similarCompanies: SimilarCompany[]; +} + +export async function POST(request: NextRequest) { + try { + const { companyName, companyUrl } = await request.json(); + + if (!companyName || !companyUrl) { + return NextResponse.json( + { error: "Company name and URL are required" }, + { status: 400 } + ); + } + + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "OPENROUTER_API_KEY is not configured" }, + { status: 500 } + ); + } + + const messages = [ + { + role: "system" as const, + content: `You are an expert market research analyst specializing in competitive intelligence and customer discovery. You have deep knowledge of SaaS, technology companies, and business models across industries. Return ONLY valid JSON — no markdown, no code fences.`, + }, + { + role: "user" as const, + content: `Analyze the company "${companyName}" (${companyUrl}). + +1. Provide a brief company profile: + - Industry/sector they operate in + - Their market positioning and value proposition + - Who their target market is + +2. Find 10 similar companies that: + - Operate in the same or adjacent industry + - Serve similar customer segments + - Have comparable business models + - Are REAL companies with working websites (not fictional) + +For each similar company provide: +- name: Company name +- url: Website URL (must be real and working, starting with https://) +- description: 1-2 sentence description +- industry: Their industry/category +- targetAudience: Who they sell to + +Return this exact JSON structure: +{ + "companyProfile": { + "industry": "string", + "positioning": "string", + "targetMarket": "string" + }, + "similarCompanies": [ + { + "name": "Company Name", + "url": "https://company.com", + "description": "Brief description", + "industry": "Industry", + "targetAudience": "Target audience" + } + ] +} + +Return exactly 10 similar companies.`, + }, + ]; + + const { text } = await callOpenRouterWithRetry(messages, apiKey); + const result = parseOpenRouterJson(text); + + if (!result.companyProfile || !result.similarCompanies) { + return NextResponse.json( + { error: "Invalid response structure from AI" }, + { status: 500 } + ); + } + + const similarCompanies: SimilarCompany[] = result.similarCompanies + .filter((c) => c && c.name && c.url && c.url.startsWith("http")) + .slice(0, 10); + + return NextResponse.json({ + companyProfile: result.companyProfile, + similarCompanies, + }); + } catch (error) { + console.error("Error analyzing company:", error); + + const errorMessage = + error instanceof Error ? error.message : "Failed to analyze company"; + + if (errorMessage.includes("429") || errorMessage.includes("quota")) { + return NextResponse.json( + { error: "API rate limit exceeded. Please wait a moment and try again." }, + { status: 429 } + ); + } + + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/customer-discovery-app/app/api/synthesize/route.ts b/customer-discovery-app/app/api/synthesize/route.ts new file mode 100644 index 000000000..5f07d9427 --- /dev/null +++ b/customer-discovery-app/app/api/synthesize/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { CompanyFindings, CustomerSegment } from "@/lib/types"; +import { callOpenRouterWithRetry, parseOpenRouterJson } from "@/lib/openrouter"; + +interface SynthesisResult { + customerSegments: CustomerSegment[]; + insights: string[]; +} + +export async function POST(request: NextRequest) { + try { + const { seedCompany, companyProfile, findings } = await request.json(); + + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "OPENROUTER_API_KEY is not configured" }, + { status: 500 } + ); + } + + const findingsText = (findings as CompanyFindings[]) + .map( + (f) => ` +Company: ${f.companyName || "Unknown"} +Website: ${f.website || "N/A"} +Overview: ${f.overview || "No overview"} +Customer Types: ${f.customerTypes?.length ? f.customerTypes.join(", ") : "N/A"} +Case Studies: ${f.caseStudies?.length ? f.caseStudies.map((cs) => `${cs.customer} (${cs.industry})`).join(", ") : "None found"} +Testimonials: ${f.testimonials?.length ?? 0} found +Pricing: ${f.pricingTiers?.length ? f.pricingTiers.join(", ") : "N/A"} +Features: ${f.keyFeatures?.length ? f.keyFeatures.join(", ") : "N/A"} +Integrations: ${f.integrations?.length ? f.integrations.join(", ") : "N/A"}` + ) + .join("\n---\n"); + + const messages = [ + { + role: "system" as const, + content: `You are a senior market research analyst specializing in customer segmentation and competitive intelligence. You synthesize research data from multiple similar companies into actionable customer segment profiles. Return ONLY valid JSON — no markdown, no code fences.`, + }, + { + role: "user" as const, + content: `Analyze this research data and identify customer segments for companies like ${seedCompany.name}. + +SEED COMPANY: ${seedCompany.name} (${seedCompany.url}) +INDUSTRY: ${companyProfile.industry} +POSITIONING: ${companyProfile.positioning} +TARGET MARKET: ${companyProfile.targetMarket} + +RESEARCH DATA FROM ${(findings as CompanyFindings[]).length} SIMILAR COMPANIES: +${findingsText} + +Based on this data, identify: +1. 4-6 distinct customer segments that companies in this space typically serve +2. Key characteristics that define each segment +3. Buying signals that indicate a prospect belongs to each segment +4. Strategic insights for targeting these segments + +Return this JSON structure: +{ + "customerSegments": [ + { + "name": "Segment Name", + "description": "2-3 sentence description of this segment", + "characteristics": ["characteristic1", "characteristic2", "characteristic3"], + "companyExamples": ["company1", "company2"], + "signals": ["buying signal 1", "buying signal 2"] + } + ], + "insights": [ + "Strategic insight 1", + "Strategic insight 2", + "Strategic insight 3" + ] +}`, + }, + ]; + + const { text } = await callOpenRouterWithRetry(messages, apiKey); + const result = parseOpenRouterJson(text); + + const customerSegments = (result.customerSegments || []).filter( + (seg) => + seg && + seg.name && + seg.description && + Array.isArray(seg.characteristics) + ); + + const insights = (result.insights || []).filter( + (i) => typeof i === "string" && i.length > 0 + ); + + return NextResponse.json({ + customerSegments, + insights, + }); + } catch (error) { + console.error("Error synthesizing:", error); + + const errorMessage = + error instanceof Error ? error.message : "Failed to synthesize"; + + if (errorMessage.includes("429") || errorMessage.includes("quota")) { + return NextResponse.json( + { error: "API rate limit exceeded. Please wait a moment and try again." }, + { status: 429 } + ); + } + + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/customer-discovery-app/app/api/tinyfish-agent/route.ts b/customer-discovery-app/app/api/tinyfish-agent/route.ts new file mode 100644 index 000000000..5e569311b --- /dev/null +++ b/customer-discovery-app/app/api/tinyfish-agent/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { TinyFish } from "@tiny-fish/sdk"; + +const AGENT_TIMEOUT = 300000; // 5 minutes + +export async function POST(request: NextRequest) { + try { + const { company } = await request.json(); + + const apiKey = process.env.TINYFISH_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "TINYFISH_API_KEY is not configured" }, + { status: 500 } + ); + } + + if (!company || !company.url) { + return NextResponse.json( + { error: "Company with URL is required" }, + { status: 400 } + ); + } + + const goal = `You are a customer discovery agent analyzing ${company.name} (${company.url}). + +IMPORTANT: Stay ONLY on this company's website. Do NOT visit external websites. + +STEP 1 — Navigate to the homepage and get an overview of what the company does. + +STEP 2 — Quickly check these pages if they exist (skip if not found): +- /customers or /case-studies +- /pricing +- /about + +STEP 3 — Extract this structured data: +- Company overview (1-2 sentences) +- Customer types they serve (e.g., startups, enterprises, agencies, SMBs) +- Case studies: customer name, industry, brief summary (up to 5) +- Testimonials: quote, author, company, role (up to 5) +- Pricing tiers: plan names and who each is for +- Key features: main product capabilities (up to 8) +- Integrations: tools/platforms they connect with + +STEP 4 — Return findings as JSON: +{ + "overview": "What the company does", + "customerTypes": ["type1", "type2"], + "caseStudies": [{"customer": "name", "industry": "industry", "summary": "summary"}], + "testimonials": [{"quote": "quote", "author": "name", "company": "company", "role": "role"}], + "pricingTiers": ["Free - for individuals", "Pro $X/mo - for teams"], + "keyFeatures": ["feature1", "feature2"], + "integrations": ["integration1", "integration2"] +} + +Be fast and factual. Do not invent information. If a page doesn't exist, skip it and return empty arrays. + +FINAL RULE: Return ONLY the JSON object above. No planning text, no step reports, no reasoning traces.`; + + const client = new TinyFish({ apiKey }); + const encoder = new TextEncoder(); + + const responseStream = new ReadableStream({ + async start(controller) { + let agentStream: Awaited> | null = null; + + const timeout = setTimeout(async () => { + if (agentStream) { + try { await agentStream.close(); } catch {} + } + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "ERROR", error: "Timeout: Agent exceeded 5 minutes" })}\n\n` + ) + ); + try { controller.close(); } catch {} + }, AGENT_TIMEOUT); + + try { + agentStream = await client.agent.stream({ url: company.url, goal }); + + for await (const event of agentStream) { + // Direct passthrough of SDK events (same pattern as dataset-finder) + const line = `data: ${JSON.stringify({ ...event, companyName: company.name, companyUrl: company.url, companyDescription: company.description || "" })}\n\n`; + controller.enqueue(encoder.encode(line)); + } + + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + clearTimeout(timeout); + controller.close(); + } catch (error) { + clearTimeout(timeout); + console.error("TinyFish stream error:", error); + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "ERROR", error: error instanceof Error ? error.message : "Stream error" })}\n\n` + ) + ); + try { controller.close(); } catch {} + } + }, + }); + + return new Response(responseStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (error) { + console.error("TinyFish agent error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "TinyFish agent error" }, + { status: 500 } + ); + } +} diff --git a/customer-discovery-app/app/api/tinyfish-agents-batch/route.ts b/customer-discovery-app/app/api/tinyfish-agents-batch/route.ts new file mode 100644 index 000000000..fa55024d3 --- /dev/null +++ b/customer-discovery-app/app/api/tinyfish-agents-batch/route.ts @@ -0,0 +1,169 @@ +import { NextRequest, NextResponse } from "next/server"; +import { TinyFish } from "@tiny-fish/sdk"; + +const AGENT_TIMEOUT = 300000; // 5 minutes + +interface AgentRequest { + id: string; + company: { + name: string; + url: string; + description?: string; + }; +} + +function buildGoal(company: { name: string; url: string }) { + return `You are a customer discovery agent analyzing ${company.name} (${company.url}). + +IMPORTANT: Stay ONLY on this company's website. Do NOT visit external websites. + +STEP 1 — Navigate to the homepage and get an overview of what the company does. + +STEP 2 — Quickly check these pages if they exist (skip if not found): +- /customers or /case-studies +- /pricing +- /about + +STEP 3 — Extract this structured data: +- Company overview (1-2 sentences) +- Customer types they serve (e.g., startups, enterprises, agencies, SMBs) +- Case studies: customer name, industry, brief summary (up to 5) +- Testimonials: quote, author, company, role (up to 5) +- Pricing tiers: plan names and who each is for +- Key features: main product capabilities (up to 8) +- Integrations: tools/platforms they connect with + +STEP 4 — Return findings as JSON: +{ + "overview": "What the company does", + "customerTypes": ["type1", "type2"], + "caseStudies": [{"customer": "name", "industry": "industry", "summary": "summary"}], + "testimonials": [{"quote": "quote", "author": "name", "company": "company", "role": "role"}], + "pricingTiers": ["Free - for individuals", "Pro $X/mo - for teams"], + "keyFeatures": ["feature1", "feature2"], + "integrations": ["integration1", "integration2"] +} + +Be fast and factual. Do not invent information. If a page doesn't exist, skip it and return empty arrays. + +FINAL RULE: Return ONLY the JSON object above. No planning text, no step reports, no reasoning traces.`; +} + +export async function POST(request: NextRequest) { + try { + const { agents } = (await request.json()) as { agents: AgentRequest[] }; + + const apiKey = process.env.TINYFISH_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "TINYFISH_API_KEY is not configured" }, + { status: 500 } + ); + } + + if (!agents || !Array.isArray(agents) || agents.length === 0) { + return NextResponse.json( + { error: "agents array is required" }, + { status: 400 } + ); + } + + const client = new TinyFish({ apiKey }); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const totalAgents = agents.length; + + function sendEvent(agentId: string, data: Record) { + try { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ ...data, agentId })}\n\n` + ) + ); + } catch { + // Controller may be closed + } + } + + // Launch ALL agents concurrently on the server side + const agentPromises = agents.map(async (agent) => { + const { id: agentId, company } = agent; + let agentStream: Awaited> | null = null; + + const timeout = setTimeout(async () => { + if (agentStream) { + try { await agentStream.close(); } catch {} + } + sendEvent(agentId, { + type: "ERROR", + error: "Timeout: Agent exceeded 5 minutes", + }); + }, AGENT_TIMEOUT); + + try { + sendEvent(agentId, { type: "CONNECTING" }); + + agentStream = await client.agent.stream({ + url: company.url, + goal: buildGoal(company), + }); + + sendEvent(agentId, { type: "BROWSING" }); + + // Direct passthrough of SDK events, tagged with agentId and company info + for await (const event of agentStream) { + sendEvent(agentId, { + ...event, + companyName: company.name, + companyUrl: company.url, + companyDescription: company.description || "", + }); + } + + clearTimeout(timeout); + } catch (error) { + clearTimeout(timeout); + console.error( + `TinyFish stream error for ${company.name}:`, + error + ); + sendEvent(agentId, { + type: "ERROR", + error: + error instanceof Error ? error.message : "Stream error", + }); + } + }); + + // Wait for ALL agents to finish, then close the stream + await Promise.allSettled(agentPromises); + + try { + sendEvent("__batch__", { type: "BATCH_COMPLETE", totalAgents }); + controller.close(); + } catch { + // Already closed + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (error) { + console.error("Batch agent error:", error); + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Batch agent error", + }, + { status: 500 } + ); + } +} diff --git a/customer-discovery-app/app/globals.css b/customer-discovery-app/app/globals.css new file mode 100644 index 000000000..dac7b879e --- /dev/null +++ b/customer-discovery-app/app/globals.css @@ -0,0 +1,241 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.98 0.005 280); + --foreground: oklch(0.2 0.02 280); + --card: oklch(1 0 0); + --card-foreground: oklch(0.2 0.02 280); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.2 0.02 280); + --primary: oklch(0.55 0.2 265); + --primary-foreground: oklch(0.98 0 0); + --secondary: oklch(0.95 0.02 180); + --secondary-foreground: oklch(0.25 0.05 180); + --muted: oklch(0.95 0.01 280); + --muted-foreground: oklch(0.5 0.02 280); + --accent: oklch(0.92 0.05 180); + --accent-foreground: oklch(0.25 0.05 180); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.9 0.01 280); + --input: oklch(0.9 0.01 280); + --ring: oklch(0.55 0.2 265); + --success: oklch(0.65 0.2 160); + --success-foreground: oklch(0.98 0 0); + --warning: oklch(0.8 0.15 85); + --warning-foreground: oklch(0.3 0.05 85); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.15 0.015 280); + --foreground: oklch(0.95 0.01 280); + --card: oklch(0.18 0.015 280); + --card-foreground: oklch(0.95 0.01 280); + --popover: oklch(0.18 0.015 280); + --popover-foreground: oklch(0.95 0.01 280); + --primary: oklch(0.65 0.2 265); + --primary-foreground: oklch(0.1 0.02 265); + --secondary: oklch(0.3 0.03 180); + --secondary-foreground: oklch(0.9 0.02 180); + --muted: oklch(0.25 0.015 280); + --muted-foreground: oklch(0.65 0.02 280); + --accent: oklch(0.3 0.04 180); + --accent-foreground: oklch(0.9 0.02 180); + --destructive: oklch(0.5 0.2 25); + --destructive-foreground: oklch(0.95 0 0); + --border: oklch(0.28 0.015 280); + --input: oklch(0.28 0.015 280); + --ring: oklch(0.65 0.2 265); + --success: oklch(0.6 0.18 160); + --success-foreground: oklch(0.1 0.02 160); + --warning: oklch(0.75 0.15 85); + --warning-foreground: oklch(0.2 0.05 85); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --font-sans: 'Geist', 'Geist Fallback'; + --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +/* Animations */ +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes pulse-ring { + 0% { box-shadow: 0 0 0 0 oklch(0.55 0.2 265 / 40%); } + 70% { box-shadow: 0 0 0 10px oklch(0.55 0.2 265 / 0%); } + 100% { box-shadow: 0 0 0 0 oklch(0.55 0.2 265 / 0%); } +} + +@keyframes radar-sweep { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-8px); } +} + +@keyframes gradient-shift { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +@keyframes scale-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@layer utilities { + .animate-fade-in-up { + animation: fade-in-up 0.5s ease-out forwards; + } + + .animate-fade-in { + animation: fade-in 0.4s ease-out forwards; + } + + .animate-shimmer { + background: linear-gradient(90deg, transparent 0%, oklch(0.55 0.2 265 / 10%) 50%, transparent 100%); + background-size: 200% 100%; + animation: shimmer 2s infinite; + } + + .animate-pulse-ring { + animation: pulse-ring 2s ease-out infinite; + } + + .animate-radar { + animation: radar-sweep 3s linear infinite; + } + + .animate-float { + animation: float 3s ease-in-out infinite; + } + + .animate-gradient { + background-size: 200% 200%; + animation: gradient-shift 3s ease infinite; + } + + .animate-scale-in { + animation: scale-in 0.3s ease-out forwards; + } + + .glass { + backdrop-filter: blur(12px); + background: oklch(1 0 0 / 60%); + border: 1px solid oklch(0.9 0.01 280 / 50%); + } +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: oklch(0.8 0.01 280); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: oklch(0.65 0.02 280); +} diff --git a/customer-discovery-app/app/layout.tsx b/customer-discovery-app/app/layout.tsx new file mode 100644 index 000000000..87a5a3e5d --- /dev/null +++ b/customer-discovery-app/app/layout.tsx @@ -0,0 +1,24 @@ +import React from "react" +import type { Metadata } from 'next' +import { Analytics } from '@vercel/analytics/next' +import './globals.css' + +export const metadata: Metadata = { + title: 'Customer Discovery - AI Market Research', + description: 'Discover customer segments and market insights using AI-powered research agents', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + + ) +} diff --git a/customer-discovery-app/app/page.tsx b/customer-discovery-app/app/page.tsx new file mode 100644 index 000000000..69ad191e6 --- /dev/null +++ b/customer-discovery-app/app/page.tsx @@ -0,0 +1,95 @@ +'use client' + +import { AnimatePresence, motion } from 'framer-motion' +import { useDiscoveryStore } from '@/lib/discovery-store' +import { useAutoSynthesis } from '@/hooks/use-auto-synthesis' +import { AppSidebar } from '@/components/app-sidebar' +import { SeedInputForm } from '@/components/seed-input-form' +import { AgentGrid } from '@/components/agent-grid' +import { ResultsView } from '@/components/results-view' +import { SynthesisView } from '@/components/synthesis-view' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertCircle, X } from 'lucide-react' +import { Button } from '@/components/ui/button' + +export default function HomePage() { + const { phase, viewMode, error, setError } = useDiscoveryStore() + + useAutoSynthesis() + + const renderContent = () => { + if (phase === 'input') return + + if (phase === 'analyzing') { + return ( + +
+
+
+
+
+
+
+
+
+
+

Analyzing Company

+

+ Finding similar businesses and building research plan... +

+
+
+ + ) + } + + if (phase === 'researching' || phase === 'synthesizing' || phase === 'complete') { + if (viewMode === 'agents') return + if (viewMode === 'synthesis') return + if (viewMode === 'results') return + } + + return null + } + + return ( +
+ + +
+ + {error && ( + + + + {error} + + + + )} + + + + {renderContent()} + +
+
+ ) +} diff --git a/customer-discovery-app/components.json b/customer-discovery-app/components.json new file mode 100644 index 000000000..4ee62ee10 --- /dev/null +++ b/customer-discovery-app/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/customer-discovery-app/components/agent-card.tsx b/customer-discovery-app/components/agent-card.tsx new file mode 100644 index 000000000..9677a2a32 --- /dev/null +++ b/customer-discovery-app/components/agent-card.tsx @@ -0,0 +1,175 @@ +'use client' + +import { AgentStatus } from '@/lib/types' +import { cn } from '@/lib/utils' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { + Bot, + Globe, + CheckCircle2, + XCircle, + Loader2, + ExternalLink, +} from 'lucide-react' + +interface AgentCardProps { + agent: AgentStatus + onViewLive?: () => void + onClick?: () => void + isSelected?: boolean +} + +const statusConfig = { + idle: { + label: 'Queued', + color: 'bg-muted text-muted-foreground border-muted-foreground/20', + icon: Bot, + }, + connecting: { + label: 'Connecting', + color: 'bg-gradient-to-r from-warning/15 to-warning/5 text-warning-foreground border-warning/30', + icon: Loader2, + }, + browsing: { + label: 'Browsing', + color: 'bg-gradient-to-r from-primary/15 to-primary/5 text-primary border-primary/30', + icon: Globe, + }, + analyzing: { + label: 'Analyzing', + color: 'bg-gradient-to-r from-chart-2/15 to-chart-2/5 text-primary border-chart-2/30', + icon: Loader2, + }, + complete: { + label: 'Complete', + color: 'bg-gradient-to-r from-success/15 to-success/5 text-success border-success/30', + icon: CheckCircle2, + }, + error: { + label: 'Error', + color: 'bg-gradient-to-r from-destructive/15 to-destructive/5 text-destructive border-destructive/30', + icon: XCircle, + }, +} + +export function AgentCard({ agent, onViewLive, onClick, isSelected }: AgentCardProps) { + const config = statusConfig[agent.status] + const StatusIcon = config.icon + const isActive = ['connecting', 'browsing', 'analyzing'].includes(agent.status) + const isClickable = agent.status === 'complete' + + return ( + + +
+
+ + {agent.company.name} + + + {agent.company.url} + +
+ + + {config.label} + +
+
+ +
+
+ Progress + {agent.progress}% +
+ div]:bg-success" + )} + /> +
+ + {agent.liveViewUrl && isActive && ( +
+