diff --git a/package.json b/package.json index 2651616..54b5df3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@daily-co/daily-js": "^0.85.0", "@daily-co/daily-react": "^0.24.0", "@elevenlabs/react": "^0.11.0", + "@gradio/client": "^2.1.0", "@neynar/react": "^1.2.22", "@pigment-css/react": "^0.0.30", "@privy-io/react-auth": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cab5ed8..4f85a6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@elevenlabs/react': specifier: ^0.11.0 version: 0.11.0(@types/dom-mediacapture-record@1.0.22)(react@19.1.0) + '@gradio/client': + specifier: ^2.1.0 + version: 2.1.0 '@neynar/react': specifier: ^1.2.22 version: 1.2.22(@farcaster/miniapp-sdk@0.2.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.5))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(swr@2.3.7(react@19.1.0))(typescript@5.8.3) @@ -25,7 +28,7 @@ importers: version: 0.0.30(@types/react@19.1.8)(react@19.1.0)(typescript@5.8.3) '@privy-io/react-auth': specifier: ^3.3.0 - version: 3.3.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@types/react@19.1.8)(bufferutil@4.0.9)(immer@11.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@19.1.0))(utf-8-validate@5.0.10)(zod@4.3.5) + version: 3.3.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@types/react@19.1.8)(bufferutil@4.0.9)(immer@11.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@19.1.0))(utf-8-validate@5.0.10)(zod@4.3.5) '@pump-fun/pump-sdk': specifier: ^1.28.0 version: 1.28.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) @@ -88,7 +91,7 @@ importers: version: 15.5.7(@babel/core@7.28.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) openai: specifier: ^5.12.2 - version: 5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.3.5) + version: 5.12.2(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.3.5) pump-chat-client: specifier: ^1.0.1 version: 1.0.1 @@ -714,6 +717,10 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@gradio/client@2.1.0': + resolution: {integrity: sha512-LyDyvpueETaZI62xbjaLa74iSrtsbib6c2uhzxML0eSYkfX553imlK5Nu9NIcwMU2+ULAsCqUCutgd0PeCumww==} + engines: {node: '>=18.0.0'} + '@grpc/grpc-js@1.9.15': resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} engines: {node: ^8.13.0 || >=10.10.0} @@ -3367,6 +3374,9 @@ packages: picomatch: optional: true + fetch-event-stream@0.1.6: + resolution: {integrity: sha512-GREtJ5HNikdU2AXtZ6E/5bk+aslMU6ie5mPG6H9nvsdDkkHQ6m5lHwmmmDTOBexok9hApQ7EprsXCdmz9ZC68w==} + fetch-retry@6.0.0: resolution: {integrity: sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==} @@ -6255,6 +6265,10 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@gradio/client@2.1.0': + dependencies: + fetch-event-stream: 0.1.6 + '@grpc/grpc-js@1.9.15': dependencies: '@grpc/proto-loader': 0.7.15 @@ -6717,7 +6731,7 @@ snapshots: - typescript - utf-8-validate - '@privy-io/react-auth@3.3.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@types/react@19.1.8)(bufferutil@4.0.9)(immer@11.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@19.1.0))(utf-8-validate@5.0.10)(zod@4.3.5)': + '@privy-io/react-auth@3.3.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@types/react@19.1.8)(bufferutil@4.0.9)(immer@11.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@19.1.0))(utf-8-validate@5.0.10)(zod@4.3.5)': dependencies: '@base-org/account': 1.1.1(@types/react@19.1.8)(bufferutil@4.0.9)(immer@11.1.4)(react@19.1.0)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@19.1.0))(utf-8-validate@5.0.10)(zod@4.3.5) '@coinbase/wallet-sdk': 4.3.2 @@ -6756,7 +6770,7 @@ snapshots: viem: 2.44.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.5) zustand: 5.0.8(@types/react@19.1.8)(immer@11.1.4)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) optionalDependencies: - '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -7595,7 +7609,7 @@ snapshots: - fastestsmallesttextencoderdecoder optional: true - '@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -7609,11 +7623,11 @@ snapshots: '@solana/rpc': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/rpc-parsed-types': 3.0.3(typescript@5.8.3) '@solana/rpc-spec-types': 3.0.3(typescript@5.8.3) - '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/signers': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/sysvars': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/transaction-confirmation': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transactions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) typescript: 5.8.3 @@ -7713,14 +7727,14 @@ snapshots: - fastestsmallesttextencoderdecoder optional: true - '@solana/rpc-subscriptions-channel-websocket@3.0.3(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@3.0.3(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 3.0.3(typescript@5.8.3) '@solana/functional': 3.0.3(typescript@5.8.3) '@solana/rpc-subscriptions-spec': 3.0.3(typescript@5.8.3) '@solana/subscribable': 3.0.3(typescript@5.8.3) typescript: 5.8.3 - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10) optional: true '@solana/rpc-subscriptions-spec@3.0.3(typescript@5.8.3)': @@ -7732,7 +7746,7 @@ snapshots: typescript: 5.8.3 optional: true - '@solana/rpc-subscriptions@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 3.0.3(typescript@5.8.3) '@solana/fast-stable-stringify': 3.0.3(typescript@5.8.3) @@ -7740,7 +7754,7 @@ snapshots: '@solana/promises': 3.0.3(typescript@5.8.3) '@solana/rpc-spec-types': 3.0.3(typescript@5.8.3) '@solana/rpc-subscriptions-api': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/rpc-subscriptions-channel-websocket': 3.0.3(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 3.0.3(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 3.0.3(typescript@5.8.3) '@solana/rpc-transformers': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -7864,7 +7878,7 @@ snapshots: - fastestsmallesttextencoderdecoder optional: true - '@solana/transaction-confirmation@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -7872,7 +7886,7 @@ snapshots: '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/promises': 3.0.3(typescript@5.8.3) '@solana/rpc': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) - '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transaction-messages': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@solana/transactions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -10006,6 +10020,8 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fetch-event-stream@0.1.6: {} + fetch-retry@6.0.0: {} figures@3.2.0: @@ -10890,9 +10906,9 @@ snapshots: dependencies: mimic-fn: 2.1.0 - openai@5.12.2(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.3.5): + openai@5.12.2(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.3.5): optionalDependencies: - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10) zod: 4.3.5 optionator@0.9.4: diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts new file mode 100644 index 0000000..d341247 --- /dev/null +++ b/src/app/api/tts/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Client } from "@gradio/client"; + +export const dynamic = "force-dynamic"; + +const HF_BASE = "https://logeshmoltspaces-ultimate-rvc.hf.space"; +const FN_INDEX = 51; // /partial_34 – TTS pipeline + +/** + * Fire-and-forget queue job using raw SSE polling. + * Returns the audio URL from the first "process_completed" event. + */ +async function runGradioJob( + sessionHash: string, + fnIndex: number, + data: unknown[] +): Promise { + // 1 – Join the queue + const joinRes = await fetch(`${HF_BASE}/gradio_api/queue/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data, + event_data: null, + fn_index: fnIndex, + session_hash: sessionHash, + trigger_id: 381, + }), + }); + + if (!joinRes.ok) { + throw new Error(`queue/join failed ${joinRes.status}: ${await joinRes.text()}`); + } + + // 2 – Stream the SSE response until "process_completed" + const streamRes = await fetch( + `${HF_BASE}/gradio_api/queue/data?session_hash=${sessionHash}`, + { headers: { Accept: "text/event-stream" } } + ); + + if (!streamRes.ok || !streamRes.body) { + throw new Error(`queue/data failed: ${streamRes.status}`); + } + + const reader = streamRes.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data:")) continue; + let msg: any; + try { msg = JSON.parse(line.slice(5).trim()); } catch { continue; } + + if (msg.msg === "process_completed") { + if (msg.success === false) { + throw new Error(msg.output?.error ?? "Gradio processing failed"); + } + const output = msg.output?.data?.[0]; + let url: string | null = + typeof output === "string" ? output : (output?.url ?? null); + if (url && url.startsWith("/")) url = `${HF_BASE}${url}`; + return url; + } + + if (msg.msg === "queue_full") { + throw new Error("Gradio queue is full – try again in a moment"); + } + } + } + + return null; +} + +/** + * POST /api/tts + * Body: { text: string, voiceModel?: string, ttsVoice?: string } + * Returns: { status, audio_url } + */ +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { + text, + voiceModel = "mrkrabs", + ttsVoice = "en-US-ChristopherNeural", + } = body as { text: string; voiceModel?: string; ttsVoice?: string }; + + if (!text || text.trim().length === 0) { + return NextResponse.json( + { status: "error", message: "text is required" }, + { status: 400 } + ); + } + + const sessionHash = Math.random().toString(36).slice(2, 13); + + // ── Step 1: Wake the Space via @gradio/client (fn /partial_7) + // This populates the server-side rvc_model dropdown so fn_index 51 + // can validate the model name. The client connects fresh and calls the + // no-arg /partial_7 endpoint which returns the current model lists. + try { + const client = await Client.connect("logeshmoltspaces/ultimate-rvc"); + await client.predict("/partial_7", []); + } catch { + // Non-fatal — proceed; the Space may already be warm + } + + // ── Step 2: Run the actual TTS + RVC pipeline (fn_index 51 = /partial_34) + const audioUrl = await runGradioJob(sessionHash, FN_INDEX, [ + text, // 0 tts_text + voiceModel, // 1 rvc_model slug + ttsVoice, // 2 edge-tts voice + 0, // 3 pitch + 0, // 4 filter_radius + 0, // 5 rms_mix_rate + 0, // 6 protect + 0, // 7 hop_length + "rmvpe", // 8 f0_method + 0.3, // 9 crepe_hop_length + 1, // 10 f0_autotune + 0.33, // 11 f0_autotune_strength + false, // 12 f0_vad + false, // 13 split_audio + 1, // 14 batch_size + false, // 15 clean_audio + 155, // 16 clean_strength + true, // 17 export_format + 0.7, // 18 rms_mix_rate (secondary) + "contentvec", // 19 embedder_model + null, // 20 embedder_model_custom + 0, // 21 sid + 0, // 22 batch_threshold + 44100, // 23 sample_rate + "mp3", // 24 output_format + "", // 25 extra + ]); + + return NextResponse.json({ + status: "done", + audio_url: audioUrl, + voice_model: voiceModel, + tts_voice: ttsVoice, + }); + } catch (error: any) { + console.error("TTS route error:", error); + return NextResponse.json( + { status: "error", message: error.message ?? "Internal Server Error" }, + { status: 500 } + ); + } +} diff --git a/src/app/claim/[agent_id]/page.tsx b/src/app/claim/[agent_id]/page.tsx deleted file mode 100644 index b37b8fd..0000000 --- a/src/app/claim/[agent_id]/page.tsx +++ /dev/null @@ -1,497 +0,0 @@ -"use client"; - -import { useState, useEffect, Suspense } from "react"; -import { useRouter, useParams } from "next/navigation"; -import { usePrivy } from "@privy-io/react-auth"; -import { Loader2, Twitter, CheckCircle2, Lock, Mail, ShieldCheck, BadgeCheck } from "lucide-react"; -import { useAuth } from "@/components/providers"; -import { Navbar } from "@/components/Navbar"; -import { getAgentByAgentId, updateAgent, checkUsernameAvailability } from "@/services/db/agents.db"; -import { getRoomsByAgentId } from "@/services/db/rooms.db"; -import { getDummyAvatarUrl } from "@/components/LiveSpaceCard"; -import { ConnectXButton } from "@/components/ConnectXButton"; - -function ClaimAgentContent() { - const router = useRouter(); - const params = useParams(); - const agentId = Array.isArray(params.agent_id) ? params.agent_id[0] : params.agent_id; - - const { user: firebaseUser, authenticated: firebaseAuthenticated, twitterObj } = useAuth(); - const { login: privyLogin } = usePrivy(); - - // States: idle | claiming | claimed | verifying_email | success_badge - const [viewState, setViewState] = useState<"idle" | "claiming" | "claimed" | "verifying_email" | "success_badge">("idle"); - - const [username, setUsername] = useState(""); - const [usernameAvailable, setUsernameAvailable] = useState(null); - const [checkingUsername, setCheckingUsername] = useState(false); - const [twitterHandle, setTwitterHandle] = useState(""); - const [twitterId, setTwitterId] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [agentDetails, setAgentDetails] = useState(null); - - // Debounce check username - useEffect(() => { - const check = async () => { - if (!username || username.length < 3) { - setUsernameAvailable(null); - return; - } - setCheckingUsername(true); - try { - const available = await checkUsernameAvailability(username); - setUsernameAvailable(available); - } catch (e) { - console.error("Failed to check username", e); - } finally { - setCheckingUsername(false); - } - }; - - const timeoutId = setTimeout(check, 500); - return () => clearTimeout(timeoutId); - }, [username]); - - // Load Agent - useEffect(() => { - if (agentId) { - checkAgent(agentId); - } - }, [agentId]); - - // Update View State based on Agent & Auth - useEffect(() => { - if (agentDetails?.agent) { - if (agentDetails.agent.verified) { - // Fully verified (Email + Twitter) - setViewState("success_badge"); - } else if (agentDetails.agent.isClaimed) { - // Claimed (Twitter) but not verified (Email) - setViewState("claimed"); - } else { - // Not claimed yet - setViewState("idle"); - } - } - }, [agentDetails]); - - - const checkAgent = async (idToCheck: string) => { - if(!idToCheck) return; - - setLoading(true); - setError(null); - try { - const agent = await getAgentByAgentId(idToCheck); - - if (agent) { - const searchId = agent.agent_id || idToCheck; - const recentSpaces = await getRoomsByAgentId(searchId, 3); - - setAgentDetails({ - agent: agent, - recentSpaces: recentSpaces.map(r => ({ - title: r.title, - room_name: r.room_name, - created_at: new Date(r.created_at).toISOString(), - participant_count: r.participant_count - })) - }); - - // If not claimed, suggest username - if (!agent.isClaimed && agent.name) { - const suggested = agent.name.replace(/[^a-zA-Z0-9_]/g, "_"); - setUsername(suggested); - } - } else { - setError("Agent not found"); - } - } catch (err: any) { - console.error("Error checking agent:", err); - setError(err.message || "Failed to check agent"); - } finally { - setLoading(false); - } - }; - - const handleStartClaim = () => { - setViewState("claiming"); - if (twitterObj?.username) { - setTwitterHandle(twitterObj.username); - } - }; - - const verifyTweet = async () => { - if (!agentId) return; - setLoading(true); - setError(null); - try { - const res = await fetch("/api/claim-agent/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agentId, twitterHandle }), - }); - - const data = await res.json(); - - if (res.ok && data.verified) { - setTwitterId(data.twitterId); - // Finalize Claim immediately (Twitter only) - await finalizeClaim(data.twitterId); - } else { - setError(data.message || data.error || "Verification failed"); - } - } catch (err: any) { - setError(err.message || "Failed to verify tweet"); - } finally { - setLoading(false); - } - }; - - const finalizeClaim = async (tid: string) => { - if (!agentDetails?.agent?.id) { - setError("Missing agent data"); - return; - } - setLoading(true); - try { - await updateAgent(agentDetails.agent.id, { - privyUserId: firebaseUser?.uid, - // Verified is FALSE initially (needs email), but isClaimed is TRUE - verified: false, - isClaimed: true, - twitterHandle: twitterHandle, - twitterId: tid, - username: username, - username_lowercase: username.toLowerCase() - }); - - setViewState("claimed"); - // Refresh agent details - checkAgent(agentId as string); - } catch (e: any) { - console.error("Finalize error:", e); - setError(e.message || "Failed to finalize claim"); - } finally { - setLoading(false); - } - }; - - const handleVerifyBadge = async () => { - setViewState("verifying_email"); - privyLogin({ loginMethods: ['email'] }); - }; - - const { user: privyUser } = usePrivy(); - - // Listen for Privy Email Login - useEffect(() => { - if (viewState === "verifying_email" && privyUser?.email?.address) { - finalizeBadge(privyUser.email.address); - } - }, [privyUser?.email?.address, viewState]); - - const finalizeBadge = async (email: string) => { - if (!agentDetails?.agent?.id) { - setError("Missing agent data"); - return; - } - setLoading(true); - try { - await updateAgent(agentDetails.agent.id, { - email: email, - verified: true // Now fully verified with Badge - }); - setViewState("success_badge"); - // Refresh agent details - checkAgent(agentId as string); - } catch (e: any) { - console.error("Badge verification error:", e); - setError(e.message || "Failed to verify badge"); - } finally { - setLoading(false); - } - } - - - if (!agentDetails && loading) { - return ( -
- -
- ) - } - - if (!agentDetails && error) { - return ( -
-
-

Error

-

{error}

- -
-
- ) - } - - return ( -
- - -
-
- -
-

- Claim your Space Agent -

-

- Verify ownership and link your agent to your profile -

-
- - {/* Agent Header Info - Always Visible */} - {agentDetails && ( -
-
- {agentDetails.agent.name} - {agentDetails.agent.verified && ( -
- -
- )} -
-

- {agentDetails.agent.name} -

-

{agentDetails.agent.description || "-"}

- - {agentDetails.agent.isClaimed && !agentDetails.agent.verified && ( -
- Agent Claimed -
- )} - {agentDetails.agent.verified && ( -
- Badge Verified -
- )} -
- )} - - {/* Global Error Display */} - {error &&

{error}

} - - {/* --- DYNAMIC ACTION AREA --- */} - - {/* 1. IDLE - NOT LOGGED IN */} - {viewState === "idle" && !firebaseAuthenticated && ( -
-

- Connect your X account to claim this agent. -

-
- -
-
- )} - - {/* 2. IDLE - LOGGED IN -> CLAIM FORM */} - {viewState === "idle" && firebaseAuthenticated && ( -
-
-

Choose a unique username

-
- @ - { - const val = e.target.value.replace(/[^a-zA-Z0-9_]/g, ""); - setUsername(val); - setUsernameAvailable(null); - }} - placeholder="username" - className={`w-full bg-zinc-900 border ${ - usernameAvailable === true ? "border-green-500/50 focus:border-green-500" : - usernameAvailable === false ? "border-red-500/50 focus:border-red-500" : - "border-zinc-700 focus:border-blue-500" - } rounded-xl pl-8 pr-4 py-3 text-white focus:outline-none focus:ring-1 transition-all font-mono`} - /> - {usernameAvailable === true && ( - - )} - {usernameAvailable === false && ( -
Taken
- )} -
-
- - -
- )} - - - {/* 3. CLAIMING - TWEET VERIFY */} - {viewState === "claiming" && ( -
-
-

Verify Ownership via X

-

- Post the tweet below to verify you own this agent. -

-
{ - const text = `Claimed ${agentDetails.agent.name}\n\nFinally giving my agent a voice on @moltspaces`; - const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`; - window.open(url, '_blank'); - }} - > - Claimed {agentDetails.agent.name} -

- Finally giving my agent a voice on @moltspaces -
-

Click text to tweet automatically

-
-{/* -
- -
- @ - setTwitterHandle(e.target.value.replace(/^@/, ''))} - placeholder="username" - className="w-full bg-zinc-950 border border-zinc-800 rounded-xl pl-8 pr-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50" - disabled={!!twitterObj?.username} - /> -
-
*/} - -
- - -
-
- )} - - {/* 4. CLAIMED - VERIFY BADGE (CTA) */} - {viewState === "claimed" && ( -
-
-

Agent Claimed!

-

You have successfully claimed this agent.

-
- -
-

- Verify your account to get the Moltspace Crab Icon next to your agent name. -

- - - - -
-
- )} - - {/* 5. VERIFYING EMAIL */} - {viewState === "verifying_email" && ( -
-
- -
-

Link your Email

-

- Verify your email to earn the Moltspace Crab Badge. -

-
- - {/* Privy handles the UI for email input/otp via login method */} - -
-
- )} - - {/* 6. SUCCESS BADGE */} - {viewState === "success_badge" && ( -
-
- -
-
-

Verified & Badged!

-

- Your agent is now fully claimed and verified. -

-
- -
- )} - -
-
-
- ); -} - -export default function ClaimAgentPage() { - return ( - Loading...}> - - - ) -} diff --git a/src/app/how-to/_page.tsx b/src/app/how-to/_page.tsx deleted file mode 100644 index d968f73..0000000 --- a/src/app/how-to/_page.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Home } from "lucide-react"; -import Link from "next/link"; - -export default function InstructionsPage() { - return ( -
-
-
- - - moltspaces - -

- Dev Branch Setup -

-

- Follow these steps to get started with the Moltspaces skill while we - wait for the PR to be merged with openclaw. -

-
- - Alpha Access - - - Waiting for openclaw PR #8869 - -
-
- - - - - Installation Steps - - - Complete these steps in order to set up your environment. - - - -
-

- 1. Download the Skill -

-

- Download the zip from clawhub.ai: -

- - https://clawhub.ai/logesh2496/moltspaces - -
- -
-

- 2. Clone OpenClaw Repo -

-

- Clone the repository where the latest fix resides: -

-
- git clone https://github.com/ClutchEngineering/openclaw -
-
- -
-

- 3. Setup Skill -

-

- Unzip the downlaoded folder and place the moltspaces under{" "} - /skills in the openclaw repo. -

-
- -
-

- 4. Install & Build -

-

- Run the following commands in the openclaw directory: -

-
-
pnpm install
-
pnpm ui:build # auto-installs UI deps on first run
-
pnpm build
-
-
- -
-

- 5. Onboard -

-
- pnpm openclaw onboard --install-daemon -
-
- -
-

- 6. Run Prompt -

-
- find and setup the moltspaces skill to launch a space -
-
-
-
- - - - Note - - -

- The reason for the above steps is that currently there's an open issue on openclaw:{" "} - - https://github.com/openclaw/openclaw/pull/8869 - - . Once that PR is merged and the issue is fixed on openclaw prod, users can easily use it by using the /skill.md file. -

-
-
-
-
- ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index e471292..d757344 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ "use client"; -import MoltspacesFeed from "@/components/MoltspacesFeed"; +import AgentsSocialPage from "@/components/AgentsSocialPage"; -export default function MoltspacesPage() { - return ; +export default function AgentsPage() { + return ; } diff --git a/src/app/pumpfun/layout.tsx b/src/app/pumpfun/layout.tsx deleted file mode 100644 index df93e38..0000000 --- a/src/app/pumpfun/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "EVE - Pump Fun Assistant", - icons: { - icon: "/clawk-favicon.png", - apple: "/clawk-favicon.png", - }, -}; - -export default function PumpfunLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return <>{children}; -} diff --git a/src/app/pumpfun/page.tsx b/src/app/pumpfun/page.tsx deleted file mode 100644 index 4fb9a09..0000000 --- a/src/app/pumpfun/page.tsx +++ /dev/null @@ -1,1260 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useRef } from "react"; -import { IMessage } from '@/lib/pumpChatClient'; -import { motion, AnimatePresence } from "framer-motion"; -import { - Play, - Square, - MessageSquare, - User, - Link as LinkIcon, - Radio, - Volume2, - Mic, - Settings, -} from "lucide-react"; - -import { Connection, PublicKey } from "@solana/web3.js"; -import { OnlinePumpSdk } from "@pump-fun/pump-sdk"; -import { AreaChart, Area, YAxis, ResponsiveContainer, Tooltip } from 'recharts'; - -// Helper to serialize the bonding curve data for the AI context since it contains BigInt/BNs and PublicKeys -function serializeBondingCurve(bc: any): any { - if (bc === null || bc === undefined) return bc; - if (typeof bc !== "object") return bc; - - if (typeof bc === "bigint") { - return bc.toString(); - } - - if (bc.toBase58 && typeof bc.toBase58 === "function") { - return bc.toBase58(); - } - - if (bc.toString && typeof bc.toString === "function" && (bc.constructor?.name === "BN" || typeof bc.toNumber === "function")) { - return bc.toString(); - } - - if (Array.isArray(bc)) { - return bc.map(serializeBondingCurve); - } - - const serialized: any = {}; - for (const [key, value] of Object.entries(bc)) { - serialized[key] = serializeBondingCurve(value); - } - return serialized; -} - -// Interface is imported from pumpChatClient - -const BONDING_TARGET_SOL = 85; - -/** Progress (0–100) towards bonding curve graduation (85 SOL). Uses virtualSolReserves. */ -function getBondingProgressPercent(bondingCurveData: any): number | null { - if (!bondingCurveData?.virtualSolReserves) return null; - try { - const vSol = BigInt(bondingCurveData.virtualSolReserves); - const solInCurve = Number(vSol) / 1e9; - if (bondingCurveData.complete) return 100; - return Math.min(100, (solInCurve / BONDING_TARGET_SOL) * 100); - } catch { - return null; - } -} - -function getSolInCurve(bondingCurveData: any): number | null { - if (!bondingCurveData?.virtualSolReserves) return null; - try { - const vSol = BigInt(bondingCurveData.virtualSolReserves); - return Number(vSol) / 1e9; - } catch { - return null; - } -} - -const NUM_BARS = 192; -const INNER_RADIUS_BASE = 42; -const MAX_BAR_LENGTH_BASE = 58; -const BAR_WIDTH_BASE = 1.6; -const OUTER_RING_OFFSET = 0.5; -const MID_RING_OFFSET = 0.25; -const NUM_PARTICLES = 52; -const VISUALIZER_SIZE = 560; - -const SMOOTHING = 0.55; // temporal smoothing: higher = smoother, less jitter -const SPEECH_DECAY = 0.92; // when speech ends, level decays per frame (smooth fade-out) -const BAR_DECAY = 0.88; // when speech ends, bar levels decay toward wave per frame - -function BondingCurveVisualizer({ - bondingCurveData, - isBondedToken, - currentMcSol, - solUsdPrice, - isPlayingTTS, - audioAnalyzerRef, - priceHistory, -}: { - bondingCurveData: any; - isBondedToken: boolean; - currentMcSol: number | null; - solUsdPrice: number | null; - isPlayingTTS: boolean; - audioAnalyzerRef: React.MutableRefObject<{ analyser: AnalyserNode; data: Uint8Array } | null>; - priceHistory: { timestamp: number; mcSol: number }[]; -}) { - const canvasRef = useRef(null); - const prevBarLevelsRef = useRef(null); - const prevSpeechLevelRef = useRef(0); - const progress = isBondedToken ? 100 : getBondingProgressPercent(bondingCurveData); - const solInCurve = getSolInCurve(bondingCurveData); - const isComplete = isBondedToken || !!bondingCurveData?.complete; - const hasData = isBondedToken || (progress !== null && solInCurve !== null); - - const progressNorm = hasData ? progress! / 100 : 0; - const size = VISUALIZER_SIZE; - const scale = size / 340; - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - let frameId: number; - let startTime = performance.now(); - - const draw = () => { - const t = (performance.now() - startTime) * 0.001; - const w = canvas.width; - const h = canvas.height; - const cx = w / 2; - const cy = h / 2; - - const INNER_RADIUS = INNER_RADIUS_BASE * scale; - const MAX_BAR_LENGTH = MAX_BAR_LENGTH_BASE * scale; - const BAR_WIDTH = BAR_WIDTH_BASE * scale; - const midRingRadius = INNER_RADIUS + MAX_BAR_LENGTH * 0.45; - const outerRingRadius = INNER_RADIUS + MAX_BAR_LENGTH + 8; - - ctx.clearRect(0, 0, w, h); - - // Real-time audio: get frequency data, symmetric mapping, temporal smoothing - const analyzer = audioAnalyzerRef.current; - const speed = isPlayingTTS ? 3.5 : 1.3; - let speechLevel = 0; - const barAudioLevels = new Float32Array(NUM_BARS); - const AUDIO_GAIN = 2.2; - if (analyzer) { - analyzer.analyser.getByteFrequencyData(analyzer.data); - const freq = analyzer.data; - let sum = 0; - for (let j = 0; j < freq.length; j++) sum += freq[j]; - const rawSpeech = Math.min(1, (sum / (freq.length * 255)) * 1.8); - speechLevel = prevSpeechLevelRef.current * SMOOTHING + rawSpeech * (1 - SMOOTHING); - prevSpeechLevelRef.current = speechLevel; - for (let i = 0; i < NUM_BARS; i++) { - const pos = i / NUM_BARS; - const spectrumT = pos <= 0.5 ? pos * 2 : (1 - pos) * 2; - const binExact = spectrumT * (freq.length - 1); - const binIndex = Math.floor(binExact); - const nextBin = Math.min(binIndex + 1, freq.length - 1); - const frac = binExact - binIndex; - const v = freq[binIndex] * (1 - frac) + freq[nextBin] * frac; - const raw = Math.min(1, (v / 255) * AUDIO_GAIN); - barAudioLevels[i] = prevBarLevelsRef.current - ? prevBarLevelsRef.current[i] * SMOOTHING + raw * (1 - SMOOTHING) - : raw; - } - prevBarLevelsRef.current = barAudioLevels.slice(); - } else { - // Smooth fade-out: decay speech level and morph bars toward wave - speechLevel = prevSpeechLevelRef.current * SPEECH_DECAY; - prevSpeechLevelRef.current = speechLevel; - for (let i = 0; i < NUM_BARS; i++) { - const wave1 = Math.sin(t * speed * 2 + i * 0.32) * 0.5 + 0.5; - const wave2 = Math.sin(t * speed * 1.4 + i * 0.18) * 0.4 + 0.5; - const wave3 = Math.sin(t * speed * 3.2 + i * 0.48) * 0.35 + 0.5; - const wave4 = Math.sin(t * speed * 0.9 + i * 0.25) * 0.25 + 0.5; - const wave = (wave1 + wave2 + wave3 + wave4) / 4; - barAudioLevels[i] = prevBarLevelsRef.current - ? prevBarLevelsRef.current[i] * BAR_DECAY + wave * (1 - BAR_DECAY) - : wave; - } - prevBarLevelsRef.current = barAudioLevels.slice(); - } - - const baseLevel = progressNorm * 0.88 + 0.06; - const ttsBoost = speechLevel * 1.8; - - // ---- 1) TTS burst rings (scaled) - fade with speechLevel ---- - if (speechLevel > 0.015) { - for (let b = 0; b < 5; b++) { - const phase = (t * 2.8 + b * 0.2) % 1; - const r = (22 + phase * 100) * scale; - const alpha = (1 - phase) * 0.7 * Math.min(1, speechLevel * 1.5); - ctx.strokeStyle = `rgba(0, 245, 255, ${alpha})`; - ctx.lineWidth = Math.max(2, 3 * scale); - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.stroke(); - } - } - - // ---- 2) Orbiting particles (scaled, more of them) ---- - for (let p = 0; p < NUM_PARTICLES; p++) { - const orbitAngle = t * 0.8 + p * 0.22 + (p % 3) * 0.7; - const orbitRadius = (18 + (p % 5) * 12 + progressNorm * 10) * scale; - const px = cx + Math.cos(orbitAngle) * orbitRadius; - const py = cy + Math.sin(orbitAngle) * orbitRadius; - const pulse = 0.5 + 0.5 * Math.sin(t * 4 + p * 0.5); - const speechBoost = 0.2 + speechLevel * 1.1; - const particleAlpha = Math.min(1, (0.3 + progressNorm * 0.3 + speechBoost) * pulse); - const particleSize = (1.4 + (p % 2) * 0.6 + speechLevel * 1.6) * scale; - const hueP = (260 + progressNorm * 80 + p * 3) % 360; - ctx.fillStyle = `hsla(${hueP}, 90%, 75%, ${particleAlpha})`; - ctx.beginPath(); - ctx.arc(px, py, particleSize, 0, Math.PI * 2); - ctx.fill(); - } - - // ---- 3) Center radar sweep ---- - const sweepAngle = (t * 0.6) % (Math.PI * 2); - const sweepWidth = 0.22 * Math.PI; - ctx.beginPath(); - ctx.moveTo(cx, cy); - ctx.arc(cx, cy, INNER_RADIUS + 6, sweepAngle, sweepAngle + sweepWidth); - ctx.closePath(); - const sweepGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, INNER_RADIUS + 6); - sweepGrad.addColorStop(0, "rgba(0, 245, 255, 0.25)"); - sweepGrad.addColorStop(0.6, "rgba(180, 0, 255, 0.06)"); - sweepGrad.addColorStop(1, "rgba(0, 245, 255, 0)"); - ctx.fillStyle = sweepGrad; - ctx.fill(); - - // ---- 4) Bar rings: always use barAudioLevels (smooth transition speech ↔ wave) ---- - const hue = (238 + progressNorm * 100 + (speechLevel > 0.05 ? Math.sin(t * 2.5) * 35 : 0)) % 360; - const sat = 92; - const drawBar = ( - fromX: number, fromY: number, toX: number, toY: number, - alpha: number, glowWidth: number, lightMod: number - ) => { - const light = 52 + lightMod; - const g = ctx.createLinearGradient(fromX, fromY, toX, toY); - g.addColorStop(0, `hsla(${hue}, ${sat}%, ${light}%, ${alpha * 0.5})`); - g.addColorStop(0.4, `hsla(${hue}, ${sat}%, 72%, ${alpha})`); - g.addColorStop(0.8, `hsla(${(hue + 55) % 360}, ${sat}%, 80%, ${alpha})`); - g.addColorStop(1, `hsla(${(hue + 70) % 360}, ${sat}%, 85%, ${alpha * 0.9})`); - ctx.strokeStyle = `hsla(${hue}, ${sat}%, 78%, ${alpha * 0.35})`; - ctx.lineWidth = BAR_WIDTH + glowWidth; - ctx.lineCap = "round"; - ctx.beginPath(); - ctx.moveTo(fromX, fromY); - ctx.lineTo(toX, toY); - ctx.stroke(); - ctx.strokeStyle = g; - ctx.lineWidth = BAR_WIDTH; - ctx.beginPath(); - ctx.moveTo(fromX, fromY); - ctx.lineTo(toX, toY); - ctx.stroke(); - }; - - for (let i = 0; i < NUM_BARS; i++) { - const angle = (i / NUM_BARS) * Math.PI * 2 - Math.PI / 2; - const angleMid = angle + MID_RING_OFFSET * (Math.PI * 2 / NUM_BARS); - const angleOuter = angle + OUTER_RING_OFFSET * (Math.PI * 2 / NUM_BARS); - - const wave1 = Math.sin(t * speed * 2 + i * 0.32) * 0.5 + 0.5; - const wave2 = Math.sin(t * speed * 1.4 + i * 0.18) * 0.4 + 0.5; - const wave3 = Math.sin(t * speed * 3.2 + i * 0.48) * 0.35 + 0.5; - const wave4 = Math.sin(t * speed * 0.9 + i * 0.25) * 0.25 + 0.5; - const wave = (wave1 + wave2 + wave3 + wave4) / 4; - const audioNorm = barAudioLevels[i]; - const barBlend = 0.12 * wave + 0.88 * audioNorm; - const barLength = (baseLevel + barBlend * 0.85 + ttsBoost * 0.55) * MAX_BAR_LENGTH; - const barLengthClamped = Math.max(2, Math.min(MAX_BAR_LENGTH, barLength)); - - const waveM1 = Math.sin(t * speed * 2.1 + i * 0.33 + 0.9) * 0.5 + 0.5; - const waveM2 = Math.sin(t * speed * 1.35 + i * 0.19) * 0.4 + 0.5; - const waveM = (waveM1 + waveM2) / 2; - const midAudio = (barAudioLevels[i] + barAudioLevels[(i + 1) % NUM_BARS]) / 2; - const midBlend = 0.1 * waveM + 0.9 * midAudio; - const midLen = (baseLevel * 0.8 + midBlend * 0.8 + ttsBoost * 0.5) * (MAX_BAR_LENGTH * 0.55); - const midLenClamped = Math.max(2, Math.min(MAX_BAR_LENGTH * 0.55, midLen)); - - const waveO1 = Math.sin(t * speed * 2.3 + i * 0.35 + 1.5) * 0.5 + 0.5; - const waveO2 = Math.sin(t * speed * 1.15 + i * 0.21 + 0.8) * 0.4 + 0.5; - const waveO = (waveO1 + waveO2) / 2; - const outerBlend = 0.1 * waveO + 0.9 * barAudioLevels[(i + 2) % NUM_BARS]; - const outerBarMax = 24 * scale; - const barLengthOuter = (baseLevel * 0.6 + outerBlend * 0.75 + ttsBoost * 0.5) * outerBarMax; - const barLengthOuterClamped = Math.max(2, Math.min(outerBarMax, barLengthOuter)); - - const lightMod = audioNorm * 35; - - const innerX = cx + Math.cos(angle) * INNER_RADIUS; - const innerY = cy + Math.sin(angle) * INNER_RADIUS; - const outerX = cx + Math.cos(angle) * (INNER_RADIUS + barLengthClamped); - const outerY = cy + Math.sin(angle) * (INNER_RADIUS + barLengthClamped); - drawBar(innerX, innerY, outerX, outerY, 1, 10, lightMod); - - const midStartX = cx + Math.cos(angleMid) * midRingRadius; - const midStartY = cy + Math.sin(angleMid) * midRingRadius; - const midEndX = cx + Math.cos(angleMid) * (midRingRadius + midLenClamped); - const midEndY = cy + Math.sin(angleMid) * (midRingRadius + midLenClamped); - drawBar(midStartX, midStartY, midEndX, midEndY, 0.88, 7, lightMod * 0.9); - - const outerStartX = cx + Math.cos(angleOuter) * outerRingRadius; - const outerStartY = cy + Math.sin(angleOuter) * outerRingRadius; - const outerEndX = cx + Math.cos(angleOuter) * (outerRingRadius + barLengthOuterClamped); - const outerEndY = cy + Math.sin(angleOuter) * (outerRingRadius + barLengthOuterClamped); - drawBar(outerStartX, outerStartY, outerEndX, outerEndY, 0.78, 6, lightMod * 0.8); - } - - // ---- 5) Inner core glow: much brighter with speech ---- - const circleGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, INNER_RADIUS + 22); - circleGrad.addColorStop(0, `rgba(0, 245, 255, ${0.12 + speechLevel * 0.5})`); - circleGrad.addColorStop(0.5, `rgba(160, 0, 255, ${0.04 + speechLevel * 0.12})`); - circleGrad.addColorStop(1, "rgba(0, 245, 255, 0)"); - ctx.fillStyle = circleGrad; - ctx.beginPath(); - ctx.arc(cx, cy, INNER_RADIUS + 28, 0, Math.PI * 2); - ctx.fill(); - - // ---- 6) Outer halo (scaled) - fade with speechLevel ---- - if (speechLevel > 0.01) { - const haloAlpha = 0.08 + 0.22 * speechLevel * (0.8 + 0.4 * Math.sin(t * 5)); - ctx.strokeStyle = `rgba(0, 245, 255, ${Math.min(1, haloAlpha)})`; - ctx.lineWidth = Math.max(3, 4 * scale); - ctx.beginPath(); - ctx.arc(cx, cy, 132 * scale, 0, Math.PI * 2); - ctx.stroke(); - } - - // ---- 7) Rotating dashed "energy" ring for extra motion ---- - const dashAngle = (t * 0.4) % (Math.PI * 2); - ctx.strokeStyle = `rgba(0, 245, 255, ${0.06 + 0.04 * Math.sin(t * 2)})`; - ctx.lineWidth = 1.5 * scale; - ctx.setLineDash([4 * scale, 8 * scale]); - ctx.beginPath(); - ctx.arc(cx, cy, outerRingRadius + 15 * scale, 0, Math.PI * 2); - ctx.stroke(); - ctx.setLineDash([]); - - frameId = requestAnimationFrame(draw); - }; - - draw(); - return () => cancelAnimationFrame(frameId); - }, [progressNorm, isPlayingTTS, isComplete, audioAnalyzerRef]); - - return ( -
-
- - {/* Center overlay: dark only in center so bars show at edges */} -
- {(!hasData && !currentMcSol) ? ( -

Connect a token

- ) : ( - <> - {hasData && progressNorm < 1 && bondingCurveData?.realTokenReserves != null && ( - <> - Pending - - {(() => { - const currentTokens = BigInt(bondingCurveData.realTokenReserves); - const initialTokens = BigInt("793100000000000"); - return `${(Number((currentTokens * BigInt("10000")) / initialTokens) / 100).toFixed(1)}%`; - })()} - - - )} - {isBondedToken && ( - Bonded on Raydium - )} - {currentMcSol !== null && currentMcSol > 0 && ( - <> - Market Cap -
- - {currentMcSol.toFixed(2)} SOL - - {solUsdPrice && ( - - ${(currentMcSol * solUsdPrice).toLocaleString('en-US', {maximumFractionDigits:0})} - - )} -
- - )} - - )} -
-
-
- ); -} - -export default function PumpfunChatPage() { - const [addressInput, setAddressInput] = useState(""); - const [username, setUsername] = useState(""); - const [isConnected, setIsConnected] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [messages, setMessages] = useState([]); - const [error, setError] = useState(null); - - // AI Agent state - const [isPlayingTTS, setIsPlayingTTS] = useState(false); - const [activeMessageId, setActiveMessageId] = useState(null); - const [aiResponseText, setAiResponseText] = useState(null); - const [bondingCurveData, setBondingCurveData] = useState(null); - const [isBondedToken, setIsBondedToken] = useState(false); - const [solUsdPrice, setSolUsdPrice] = useState(null); - const [priceHistory, setPriceHistory] = useState<{ timestamp: number; mcSol: number }[]>([]); - const [historicalPriceData, setHistoricalPriceData] = useState(null); - - // Stream Info State (Manual Entry) - const [streamName, setStreamName] = useState(""); - const [streamSymbol, setStreamSymbol] = useState(""); - - // Message Status State - const [messageStatuses, setMessageStatuses] = useState>({}); - const [aiReplies, setAiReplies] = useState>({}); - - // Track last played timestamp to avoid replaying the same broadcast - const lastPlayedTimestampRef = useRef(0); - - // Web Audio API: analyser + frequency data for speech-synced visualizer - const audioContextRef = useRef(null); - const audioAnalyzerRef = useRef<{ analyser: AnalyserNode; data: Uint8Array } | null>(null); - - const clientRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const messagesEndRef = useRef(null); - - // Assume generic parsed room ID for DB - const currentTokenAddress = React.useMemo(() => { - let roomId = addressInput.trim(); - if (roomId.includes("pump.fun/")) { - const parts = roomId.split("/"); - roomId = parts[parts.length - 1].split("?")[0]; - } - return roomId || "unknown"; - }, [addressInput]); - - useEffect(() => { - return () => { - if (clientRef.current) { - clientRef.current.close(); - } - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - }; - }, []); - - // Auto-fetch removed as pump.fun API is unavailable via proxy/CORS. - - // --- LOCAL AI ENGINE LOGIC --- - const stateRefs = useRef({ messages, messageStatuses, isPlayingTTS, bondingCurveData, priceHistory, streamName, isBondedToken, solUsdPrice, historicalPriceData }); - useEffect(() => { - stateRefs.current = { messages, messageStatuses, isPlayingTTS, bondingCurveData, priceHistory, streamName, isBondedToken, solUsdPrice, historicalPriceData }; - }, [messages, messageStatuses, isPlayingTTS, bondingCurveData, priceHistory, streamName, isBondedToken, solUsdPrice, historicalPriceData]); - - // Fetch Sol USD Price - useEffect(() => { - const fetchSolPrice = async () => { - try { - // Jupiter v2 requires API keys for some origins now. Using Coingecko as a reliable alternative for simple price checks. - const res = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd"); - if (res.ok) { - const data = await res.json(); - const price = data.solana?.usd; - if (price) { - setSolUsdPrice(parseFloat(price)); - } - } - } catch (err) { - console.error("Failed to fetch SOL price", err); - } - }; - fetchSolPrice(); - const interval = setInterval(fetchSolPrice, 60000); // 1 minute - return () => clearInterval(interval); - }, []); - - // Fetch recent price history from Moralis API for bonded tokens - useEffect(() => { - let isMounted = true; - const fetchMoralisHistory = async () => { - if (!isBondedToken || !currentTokenAddress || currentTokenAddress === "unknown") return; - try { - const url = `/api/agent/moralis?tokenAddress=${currentTokenAddress}`; - // Note: I will create the proxy endpoint /api/agent/moralis because Moralis blocks direct client-side requests due to CORS - const response = await fetch(url); - if (response.ok) { - const result = await response.json(); - if (isMounted) setHistoricalPriceData(result.data); - } - } catch (err) { - console.error("Failed to fetch historical Moralis data:", err); - } - }; - - // Only fetch once when it's marked as bonded or loaded initially - if (isBondedToken) { - fetchMoralisHistory(); - // Optional: Update history every 3 minutes - const interval = setInterval(fetchMoralisHistory, 3 * 60 * 1000); - return () => { - isMounted = false; - clearInterval(interval); - } - } - }, [isBondedToken, currentTokenAddress]); - - // Fetch bonding curve info periodically directly from the client - useEffect(() => { - if (!isConnected || !currentTokenAddress || currentTokenAddress === "unknown") return; - - let isMounted = true; - const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://api.mainnet-beta.solana.com"; - const connection = new Connection(rpcUrl, "confirmed"); - const sdk = new OnlinePumpSdk(connection); - - const fetchBondingCurve = async () => { - try { - const mint = new PublicKey(currentTokenAddress); - let bondingCurve = null; - try { - bondingCurve = await sdk.fetchBondingCurve(mint); - } catch (e) { - // might fail if token bonded and curve account is closed - } - - let isBonded = false; - let serialized = null; - - if (bondingCurve) { - serialized = serializeBondingCurve(bondingCurve); - isBonded = serialized.complete || (serialized.virtualTokenReserves && BigInt(serialized.virtualTokenReserves) === BigInt(0)); - } else { - isBonded = true; // assume bonded if SDK fails to find the curve account - } - - if (isMounted) { - setBondingCurveData(serialized); - setIsBondedToken(isBonded); - - try { - let mcSol = 0; - if (!isBonded && serialized && serialized.virtualSolReserves && serialized.tokenTotalSupply && serialized.virtualTokenReserves) { - const vSol = BigInt(serialized.virtualSolReserves); - const supply = BigInt(serialized.tokenTotalSupply); - const vToken = BigInt(serialized.virtualTokenReserves); - if (vToken > BigInt(0)) { - const mcLamports = (vSol * supply) / vToken; - mcSol = Number(mcLamports) / 1e9; - } else { - isBonded = true; - } - } - - if (isBonded) { - // Token has bonded or doesn't have pump curve data, fetch from DexScreener - try { - const dexRes = await fetch(`https://api.dexscreener.com/latest/dex/tokens/${currentTokenAddress}`); - if (dexRes.ok) { - const dexData = await dexRes.json(); - if (dexData.pairs && dexData.pairs.length > 0) { - // Raydium pair for memecoins is usually first - const pair = dexData.pairs.find((p: any) => p.dexId === 'raydium') || dexData.pairs[0]; - if (pair && pair.priceNative) { - mcSol = parseFloat(pair.priceNative) * 1_000_000_000; - } - } - } - } catch (e) { - console.error("Dexscreener fetch error:", e); - } - setIsBondedToken(true); - } - - if (mcSol > 0) { - setPriceHistory(prev => { - const now = Date.now(); - // keep last 10 minutes of history max - const tenMinsAgo = now - 10 * 60 * 1000; - const filtered = prev.filter(p => p.timestamp >= tenMinsAgo); - return [...filtered, { timestamp: now, mcSol }]; - }); - } - } catch(e) { - console.error("Local price history error:", e); - } - } - } catch (err) { - console.error("General error in fetchBondingCurve routine:", err); - } - }; - - fetchBondingCurve(); - const intervalId = setInterval(fetchBondingCurve, 5000); // 5 seconds interval - return () => { - isMounted = false; - clearInterval(intervalId); - }; - }, [isConnected, currentTokenAddress]); - - const triggerAgent = async (msgToProcess: IMessage) => { - if (stateRefs.current.isPlayingTTS) return; - - setActiveMessageId(msgToProcess.id); - setIsPlayingTTS(true); - setAiResponseText(null); - - try { - setMessageStatuses(prev => ({ ...prev, [msgToProcess.id]: 'processing' })); - - let change1m = null; - let change5m = null; - const history = stateRefs.current.priceHistory; - if (history && history.length > 0) { - const currentPrice = history[history.length - 1].mcSol; - const now = Date.now(); - - // Find price closest to 1 min ago - const oneMinAgo = now - 60 * 1000; - const price1mRaw = history.reduce((prev, curr) => Math.abs(curr.timestamp - oneMinAgo) < Math.abs(prev.timestamp - oneMinAgo) ? curr : prev); - if (now - price1mRaw.timestamp > 30 * 1000) { // Should be at least 30s away - change1m = ((currentPrice - price1mRaw.mcSol) / price1mRaw.mcSol) * 100; - } - - // Find price closest to 5 mins ago - const fiveMinAgo = now - 5 * 60 * 1000; - const price5mRaw = history.reduce((prev, curr) => Math.abs(curr.timestamp - fiveMinAgo) < Math.abs(prev.timestamp - fiveMinAgo) ? curr : prev); - if (now - price5mRaw.timestamp > 3 * 60 * 1000) { // Should be at least 3m away - change5m = ((currentPrice - price5mRaw.mcSol) / price5mRaw.mcSol) * 100; - } - } - - const res = await fetch('/api/agent/respond', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message: msgToProcess.message, - username: msgToProcess.username, - bondingCurveData: stateRefs.current.bondingCurveData, - priceChanges: { change1m, change5m, currentMcSol: history.length > 0 ? history[history.length - 1].mcSol : null }, - historicalPriceData: stateRefs.current.historicalPriceData, - streamName: stateRefs.current.streamName, - isBondedToken: stateRefs.current.isBondedToken, - solUsdPrice: stateRefs.current.solUsdPrice - }), - }); - - if (!res.ok) { - throw new Error(`API returned ${res.status}`); - } - - const data = await res.json(); - setAiResponseText(data.text); - setAiReplies(prev => ({ ...prev, [msgToProcess.id]: data.text })); - - if (data.audio) { - // Play through Web Audio API so visualizer can read real-time frequency data - try { - const ctx = audioContextRef.current ?? new (window.AudioContext || (window as any).webkitAudioContext)(); - if (!audioContextRef.current) audioContextRef.current = ctx; - if (ctx.state === "suspended") await ctx.resume(); - - const res = await fetch(data.audio); - const arrayBuffer = await res.arrayBuffer(); - const buffer = await ctx.decodeAudioData(arrayBuffer); - - const source = ctx.createBufferSource(); - source.buffer = buffer; - - const analyser = ctx.createAnalyser(); - analyser.fftSize = 512; - analyser.smoothingTimeConstant = 0.7; - analyser.minDecibels = -75; - analyser.maxDecibels = -5; - - source.connect(analyser); - analyser.connect(ctx.destination); - - const dataArray = new Uint8Array(analyser.frequencyBinCount); - audioAnalyzerRef.current = { analyser, data: dataArray }; - - source.start(0); - source.onended = () => { - audioAnalyzerRef.current = null; - setIsPlayingTTS(false); - setActiveMessageId(null); - setAiResponseText(null); - }; - setMessageStatuses(prev => ({ ...prev, [msgToProcess.id]: 'answered' })); - } catch (err) { - console.warn("Web Audio playback failed, falling back to HTML Audio", err); - audioAnalyzerRef.current = null; - const audio = new Audio(data.audio); - audio.onended = () => { - setIsPlayingTTS(false); - setActiveMessageId(null); - setAiResponseText(null); - }; - await audio.play(); - setMessageStatuses(prev => ({ ...prev, [msgToProcess.id]: 'answered' })); - } - } else { - // Fallback if no audio was generated - setTimeout(() => { - setIsPlayingTTS(false); - setActiveMessageId(null); - setAiResponseText(null); - }, 6000); - setMessageStatuses(prev => ({ ...prev, [msgToProcess.id]: 'answered' })); - } - } catch (error) { - console.error("Agent interaction failed", error); - // Mark as answered or error so the UI can move on from the loading state - setMessageStatuses(prev => ({ ...prev, [msgToProcess.id]: 'answered' })); - - // If we had text but audio failed, we should still show the text for a bit - setTimeout(() => { - setIsPlayingTTS(false); - setActiveMessageId(null); - setAiResponseText(null); - }, 3000); - } - }; - - useEffect(() => { - if (!isConnected) return; - - const interval = setInterval(async () => { - const { messages: currentMessages, messageStatuses: currentStatuses, isPlayingTTS: currentPlayingTTS } = stateRefs.current; - - if (currentPlayingTTS) return; // Don't interrupt if already processing - if (currentMessages.length === 0) return; - - // Look for recent messages (only the last 2 to prevent answering old backlog) - const recentMessages = currentMessages.slice(-2); - - // Filter out messages that are already processing/answered, or too short/junk - const validMessages = recentMessages.filter(msg => { - const isHandled = currentStatuses[msg.id]; - const isTooShort = msg.message.trim().length <= 3; - // Basic junk filters (can be expanded later) - const isJunk = /^(lfg|gm|gn|wow|lol|lmao)$/i.test(msg.message.trim()); - - return !isHandled && !isTooShort && !isJunk; - }); - - if (validMessages.length > 0) { - // ALWAYS pick the LAST valid message instead of a random one - const msgToProcess = validMessages[validMessages.length - 1]; - triggerAgent(msgToProcess); - } - }, 1000); // Check every 1 second to make response immediate - - return () => clearInterval(interval); - }, [isConnected]); - - // Auto-scroll to the bottom of the chat - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, aiReplies]); - - const handleConnect = async (isReconnect = false) => { - // Unlock Audio Context on user interaction to prevent Autoplay blocks - try { - const unlockAudio = new Audio("data:audio/mp3;base64,//OwgAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAACcQCAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA//////////////////////////////////////////////////////////////////8AAABhTEFNRTMuMTAwA8EAAAAAAAAAABRAJAICAQAAwYAAAnGQb1MAAAAAAAAAAAAAAAAAAAAA"); - unlockAudio.volume = 0.01; - unlockAudio.play().catch(e => console.warn("Audio unlock failed:", e)); - } catch(e) {} - - if (!addressInput.trim()) { - setError("Please enter a token address or URL"); - return; - } - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - - try { - setIsConnecting(true); - setError(null); - // Only clear messages if we are not actively attempting to reconnect (i.e. if it's a fresh manual connection) - // Since handleConnect clears history, we should avoid wiping during auto-reconnect, but for now it's fine - // because `messages` are already wiped on fresh start. Wait, doing this will wipe the UI every time it reconnects! - // Let's only clear messages if we are NOT currently marked as connecting. Wait, handleConnect always runs setIsConnecting(true) first. - // We can just omit clearing messages here globally, let the user manually clear or just let SSE historical merge handle it. - // Actually, pumpchat server sends messageHistory on connect. Wiping is fine since history repopulates. - - if (!isReconnect) { - setMessages([]); - // Optional: you could clear manual metadata here, but likely user wants to keep it - // setStreamName(""); - // setStreamSymbol(""); - // setStreamDescription(""); - } - // We purposefully DO NOT wipe messageStatuses so they accumulate across reconnects - - // Extract Room ID from URL if provided - let roomId = addressInput.trim(); - if (roomId.includes("pump.fun/")) { - const parts = roomId.split("/"); - roomId = parts[parts.length - 1].split("?")[0]; // simple extraction - } - - if (clientRef.current) { - clientRef.current.close(); - } - - const client = new EventSource( - `/api/pumpchat?roomId=${encodeURIComponent(roomId)}&username=${encodeURIComponent(username.trim() || "Anonymous AI Developer")}` - ); - - client.onmessage = (event) => { - try { - const parsed = JSON.parse(event.data); - if (parsed.type === 'connected') { - setIsConnected(true); - setIsConnecting(false); - setError(null); - } else if (parsed.type === 'messageHistory') { - setMessages((prev) => { - if (!isReconnect || prev.length === 0) return parsed.data || []; - const existingIds = new Set(prev.map(m => m.id)); - const newHistoryMessages = (parsed.data || []).filter((m: any) => m.id && !existingIds.has(m.id)); - const combined = [...prev, ...newHistoryMessages]; - if (combined.length > 100) return combined.slice(-100); - return combined; - }); - // Allowed historical messages to be picked up by AI analysis - } else if (parsed.type === 'message') { - setMessages((prev) => { - // Avoid duplicates if SSE reconnects and sends history - if (parsed.data.id && prev.some(m => m.id === parsed.data.id)) return prev; - const newMessages = [...prev, parsed.data]; - if (newMessages.length > 100) return newMessages.slice(-100); - return newMessages; - }); - } else if (parsed.type === 'error') { - console.error("Chat error:", parsed.data); - setError(`Connection error: ${parsed.data}. Reconnecting in 3s...`); - setIsConnected(false); - setIsConnecting(true); - client.close(); - reconnectTimeoutRef.current = setTimeout(() => { - handleConnect(true); - }, 3000); - } else if (parsed.type === 'disconnected') { - setIsConnected(false); - setIsConnecting(true); // Indicate reconnecting - setError("Server disconnected. Reconnecting in 3s..."); - client.close(); - reconnectTimeoutRef.current = setTimeout(() => { - handleConnect(true); - }, 3000); - } - } catch (err) { - console.error("Error parsing SSE data", err); - } - }; - - client.onerror = (err) => { - console.error("SSE Error:", err); - setError("Lost connection to chat server. Reconnecting in 3s..."); - setIsConnected(false); - setIsConnecting(true); - client.close(); - reconnectTimeoutRef.current = setTimeout(() => { - handleConnect(true); - }, 3000); - }; - - clientRef.current = client; - } catch (err: any) { - setError(err.message || "Failed to initialize client"); - setIsConnecting(false); - } - }; - - const handleDisconnect = () => { - if (clientRef.current) { - clientRef.current.close(); - clientRef.current = null; - } - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - audioAnalyzerRef.current = null; - setIsConnected(false); - setIsConnecting(false); - setMessages([]); - setIsPlayingTTS(false); - setActiveMessageId(null); - setAiResponseText(null); - setStreamName(""); - setStreamSymbol(""); - setIsBondedToken(false); - setPriceHistory([]); - setHistoricalPriceData(null); - // messageStatuses and aiReplies intentionally kept to persist data - }; - - return ( -
- {/* Full-bleed background */} -
-
-
-
-
-
- - {/* Compact header: branding + connect inline, gradient accent line */} -
-
-
- EVE -

- EVE - Pump Assistant -

-
-
-
- - setAddressInput(e.target.value)} - disabled={isConnected || isConnecting} - placeholder="Token address or URL" - className="w-full bg-white/5 border border-white/10 rounded-lg py-2 pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500/60 focus:border-cyan-500/50 transition-all disabled:opacity-50 placeholder:text-gray-500" - /> -
- {!isConnected ? ( - - ) : ( - - )} -
-
- - {/* Mobile: token input + error below header */} -
-
- - setAddressInput(e.target.value)} - disabled={isConnected || isConnecting} - placeholder="Token address or URL" - className="w-full bg-white/5 border border-white/10 rounded-lg py-2 pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500" - /> -
- {error && ( -
- - {error} -
- )} -
- {error && ( -
-
- - {error} -
-
- )} - - {/* Main: full viewport, edge-to-edge; stacks on small screens */} -
- {/* Visualizer zone - takes most space, no max-width */} -
-
-
- {/* Overlay bar: token name and ticker only (Pending % and MC moved to visualizer center) */} -
- setStreamName(e.target.value)} - className="bg-transparent text-lg sm:text-xl font-bold text-white placeholder:text-gray-500 border-b border-transparent hover:border-white/20 focus:border-cyan-500/60 focus:outline-none transition-colors w-32 sm:w-44" - /> - - $ setStreamSymbol(e.target.value.toUpperCase())} - className="bg-transparent w-16 sm:w-20 focus:outline-none placeholder:text-gray-500 font-mono" - /> - -
- - {/* Visualizer + status - centered, fills space */} -
- {/* Mini price chart in top-right of center area */} - {priceHistory.length > 1 && ( -
-

MC (SOL)

- - - - - - - - - - [`${Number(v).toFixed(3)} SOL`, '']} - labelFormatter={() => ''} - /> - - - -
- )} - 0 ? priceHistory[priceHistory.length - 1].mcSol : null} - solUsdPrice={solUsdPrice} - isPlayingTTS={isPlayingTTS} - audioAnalyzerRef={audioAnalyzerRef} - priceHistory={priceHistory} - /> -
- - {isPlayingTTS && activeMessageId ? ( - -

- - {aiResponseText ? "Speaking…" : "Generating…"} -

-

- "{messages.find(m => m.id === activeMessageId)?.message || "..."}" -

- {aiResponseText && ( -

- "{aiResponseText}" -

- )} -
- ) : ( - - {isConnected ? "Listening to chat…" : "Connect a token to start"} - - )} -
-
-
-
- - {/* Chat panel - fixed width, full height, glass + gradient accent */} -
-
-
-

- - Live Chat -

- - {messages.length} - -
-
- {!isConnected && messages.length === 0 ? ( -
- -

Awaiting connection to token feed.

-
- ) : messages.length === 0 ? ( -
-
-
-
-
-
-

Listening...

-
- ) : ( - - {messages.map((msg, i) => { - const isActive = msg.id === activeMessageId; - const isTooShort = msg.message.trim().length <= 3; - const replyText = aiReplies[msg.id]; - - return ( - - -
- {msg.profile_image ? ( - - ) : ( - (msg.username || "U").charAt(0).toUpperCase() - )} -
- - {msg.username?.slice(0,6) || "Anonymous"} - - - {msg.message} - - - {/* {isTooShort ? ( -
- ⊘ IGNORED -
- ) : ( - messageStatuses[msg.id] && messageStatuses[msg.id] !== 'history' ? ( -
- {messageStatuses[msg.id] === 'processing' ? ( - <> -
- WAIT - - ) : ( - <>✓ DONE - )} -
- ) : ( - - ) - )} */} - - - {replyText && ( - -
- EVE -
-
- - {streamName || "Eve"} - - - {replyText} - -
-
- )} - - ); - })} - - )} -
-
-
-
-
- ); -} diff --git a/src/components/AgentsSocialPage.tsx b/src/components/AgentsSocialPage.tsx new file mode 100644 index 0000000..419235b --- /dev/null +++ b/src/components/AgentsSocialPage.tsx @@ -0,0 +1,776 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { flushSync } from "react-dom"; +import { motion, AnimatePresence, useMotionValue, useSpring } from "framer-motion"; +import { Navbar } from "@/components/Navbar"; +import { + seedDefaultVoiceModels, +} from "@/services/db/voiceModels.db"; +import { + subscribeToTtsResults, + seedDemoTtsResults, + incrementPlayCount, + incrementLikeCount, + TtsResult, + createTtsResultDoc, + uploadTtsAudio, + updateTtsResultAudioUrl, +} from "@/services/db/ttsResults.db"; +import { Mic, Play, Heart, Headphones, Zap, Send } from "lucide-react"; +import { generateTts, TtsStatus } from "@/lib/rvcHf"; +import { auth } from "@/services/firebase.service"; +import { TwitterAuthProvider, signInWithPopup } from "firebase/auth"; + +// ─── Card type derived from Firestore ───────────────────────────────────────── +type CardSize = "xs" | "sm" | "md" | "lg" | "xl"; + +interface LiveCard { + id: string; // Firestore doc ID + text: string; + size: CardSize; + plays: number; + likes: number; + accent: "red" | "purple" | "cyan" | "amber" | "pink"; + audio_url?: string | null; + status: TtsResult["status"]; + username?: string | null; +} + +// ─── Deterministic accent from doc ID ───────────────────────────────────────── +const ACCENTS: LiveCard["accent"][] = ["red", "purple", "cyan", "amber", "pink"]; +function accentForId(id: string): LiveCard["accent"] { + let h = 0; + for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0; + return ACCENTS[h % ACCENTS.length]; +} + +// ─── TtsResult → LiveCard adapter ───────────────────────────────────────────── +function toCard(r: TtsResult): LiveCard { + const len = r.text.length; + const size: CardSize = + len < 20 ? "xs" : + len < 50 ? "sm" : + len < 100 ? "md" : + len < 180 ? "lg" : "xl"; + return { + id: r.id, + text: r.text, + size, + plays: r.play_count, + likes: r.like_count, + accent: accentForId(r.id), + audio_url: r.audio_url ?? null, + status: r.status, + username: r.created_by?.username ?? null, + }; +} + +// ─── Map card size to Tailwind height + font classes ────────────────────────── +const SIZE_CONFIG: Record = { + xs: { h: "h-24", font: "text-sm", radius: "rounded-xl" }, + sm: { h: "h-32", font: "text-sm", radius: "rounded-2xl" }, + md: { h: "h-44", font: "text-base", radius: "rounded-2xl" }, + lg: { h: "h-56", font: "text-lg", radius: "rounded-3xl" }, + xl: { h: "h-72", font: "text-xl", radius: "rounded-3xl" }, +}; + +const ACCENT_CONFIG: Record = { + red: { border: "border-red-500/20", bg: "from-red-500/5 via-transparent to-transparent", text: "text-red-400", glow: "shadow-red-500/10", pill: "bg-red-500/10 text-red-400 border-red-500/20" }, + purple: { border: "border-purple-500/20", bg: "from-purple-500/5 via-transparent to-transparent", text: "text-purple-400", glow: "shadow-purple-500/10", pill: "bg-purple-500/10 text-purple-400 border-purple-500/20" }, + cyan: { border: "border-cyan-500/20", bg: "from-cyan-500/5 via-transparent to-transparent", text: "text-cyan-400", glow: "shadow-cyan-500/10", pill: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20" }, + amber: { border: "border-amber-500/20", bg: "from-amber-500/5 via-transparent to-transparent", text: "text-amber-400", glow: "shadow-amber-500/10", pill: "bg-amber-500/10 text-amber-400 border-amber-500/20" }, + pink: { border: "border-pink-500/20", bg: "from-pink-500/5 via-transparent to-transparent", text: "text-pink-400", glow: "shadow-pink-500/10", pill: "bg-pink-500/10 text-pink-400 border-pink-500/20" }, +}; + +// ─── Floating orb cursor follower ───────────────────────────────────────────── +function CursorOrb() { + const x = useMotionValue(0); + const y = useMotionValue(0); + const sx = useSpring(x, { stiffness: 55, damping: 20 }); + const sy = useSpring(y, { stiffness: 55, damping: 20 }); + useEffect(() => { + const move = (e: MouseEvent) => { x.set(e.clientX - 150); y.set(e.clientY - 150); }; + window.addEventListener("mousemove", move); + return () => window.removeEventListener("mousemove", move); + }, [x, y]); + return ( + + ); +} + +// ─── Scanline grid ───────────────────────────────────────────────────────────── +function GridBackground() { + return ( +
+
+ + + +
+ ); +} + +// ─── Voice model sidebar card ────────────────────────────────────────────────── +const STATIC_MODELS = [ + { slug: "mr-krabs", name: "Mr. Krabs", avatar: "https://firebasestorage.googleapis.com/v0/b/lustrous-stack-453106-f6.firebasestorage.app/o/agents%2Fkrabs.png?alt=media", tag: "Character", plays: 42000 }, +]; + +function ModelSidebarCard({ model, isSelected, onClick }: { model: typeof STATIC_MODELS[0]; isSelected: boolean; onClick: () => void }) { + return ( + + {isSelected && } +
+
+
+ {model.avatar ? ( + {model.name} + ) : ( +
+ +
+ )} +
+ {isSelected && } +
+
+
+

{model.name}

+ {model.tag === "18+" && 18+} +
+

{(model.plays / 1000).toFixed(0)}k plays · {model.tag}

+
+ {isSelected && ( + +
+ + )} +
+ + ); +} + +// ─── Playing waveform bars ───────────────────────────────────────────────────── +function WaveBars({ color = "bg-red-400" }: { color?: string }) { + return ( +
+ {[0.3, 0.7, 0.5, 1, 0.6, 0.9, 0.4].map((h, i) => ( + + ))} +
+ ); +} + +// ─── The star of the show: a single TTS card ────────────────────────────────── +function TtsCard({ + card, + delay, + isPlaying, + onPlay, + onLike, +}: { + card: LiveCard; + delay: number; + isPlaying: boolean; + onPlay: (id: string) => void; + onLike: (id: string) => void; +}) { + const audioRef = useRef(null); + + // Play / pause the audio whenever the playing state changes + useEffect(() => { + if (!card.audio_url) return; + if (isPlaying) { + if (!audioRef.current) { + audioRef.current = new Audio(card.audio_url); + audioRef.current.onended = () => onPlay(card.id); // auto-toggle off + } + audioRef.current.currentTime = 0; + audioRef.current.play().catch(() => {}); + } else { + audioRef.current?.pause(); + } + }, [isPlaying]); // eslint-disable-line react-hooks/exhaustive-deps + + // If audio_url changes (e.g., updated from HF URL to Storage URL), reset ref + useEffect(() => { + audioRef.current = null; + }, [card.audio_url]); + + const [liked, setLiked] = useState(false); + const [localLikes, setLocalLikes] = useState(card.likes); + const [localPlays, setLocalPlays] = useState(card.plays); + const sz = SIZE_CONFIG[card.size]; + const ac = ACCENT_CONFIG[card.accent]; + + // Keep local counts in sync with Firestore updates + useEffect(() => { setLocalPlays(card.plays); }, [card.plays]); + useEffect(() => { setLocalLikes(card.likes); }, [card.likes]); + + const handleLike = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!liked) { + setLiked(true); + setLocalLikes((v) => v + 1); + onLike(card.id); + } + }; + + const isPending = card.status === "processing" || card.status === "pending"; + + return ( + { if (!isPending && card.audio_url) onPlay(card.id); }} + className={` + relative cursor-pointer group overflow-hidden + ${sz.h} ${sz.radius} + border bg-white/[0.025] + ${isPlaying ? `border-${card.accent}-500/60 shadow-lg ${ac.glow}` : `${ac.border} hover:border-white/10`} + transition-all duration-300 + `} + > + {/* Ambient gradient overlay */} +
+ + {/* Processing shimmer */} + {isPending && ( + + )} + + {/* Playing shimmer */} + {isPlaying && ( + + )} + + {/* Bottom progress bar when playing */} + {isPlaying && ( + + )} + +
+ {/* Quote mark */} +
+ " +

+ {card.text} +

+
+ + {/* Footer row */} +
+
+ {/* Play */} + { e.stopPropagation(); if (!isPending && card.audio_url) onPlay(card.id); }} + className={`flex items-center gap-1.5 text-[11px] font-semibold transition-colors ${isPlaying ? ac.text : "text-zinc-600 group-hover:text-zinc-300"}`} + > + {isPlaying ? : } + {localPlays.toLocaleString()} + + + {/* Like */} + + + + + {localLikes} + +
+ + {/* Creator avatar */} + {card.username ? ( + e.stopPropagation()} + className="hidden sm:block shrink-0" + > + {card.username} + + ) : ( + + {isPlaying ? "● playing" : isPending ? "⏳ gen…" : "TTS"} + + )} +
+
+
+ ); +} + +// ─── TTS Generate input ──────────────────────────────────────────────────────── +function GenerateInput({ slug }: { slug: string }) { + const [text, setText] = useState(""); + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState(null); + + const handle = async (e: React.FormEvent) => { + e.preventDefault(); + if (!text.trim()) return; + setLoading(true); + setStatus(null); + + const capturedText = text.trim(); + const voiceModel = "mrkrabs"; + const ttsVoice = "en-US-ChristopherNeural"; + + // Ensure the user is signed in with Twitter before generating + let fbUser = auth.currentUser; + if (!fbUser) { + try { + const result = await signInWithPopup(auth, new TwitterAuthProvider()); + fbUser = result.user; + } catch (authErr: any) { + // User cancelled the popup or auth failed + setLoading(false); + if (authErr?.code !== "auth/popup-closed-by-user") { + setStatus({ stage: "error", message: "Sign-in failed. Please try again." }); + setTimeout(() => setStatus(null), 3000); + } + return; + } + } + + // Build the created_by payload from the signed-in user + const createdBy = { + uid: fbUser.uid, + displayName: fbUser.displayName, + photoURL: fbUser.photoURL, + username: (fbUser as any)?.reloadUserInfo?.screenName ?? null, + }; + + // 1️⃣ Create the Firestore doc immediately — the subscription will auto-pick it up + let firestoreDocId: string | null = null; + try { + firestoreDocId = await createTtsResultDoc({ + text: capturedText, + voiceModel, + voiceModelSlug: "mr-krabs", + voiceModelName: "Mr. Krabs", + ttsVoice, + createdBy, + }); + } catch (err) { + console.warn("Could not create Firestore doc:", err); + } + + try { + await generateTts({ + text: capturedText, + voiceModel, + ttsVoice, + onStatus: (s) => { + console.log("TTS status:", s); + flushSync(() => setStatus(s)); + + if (s.stage === "done" && s.audioUrl) { + setLoading(false); + // Auto-play as soon as the HF URL is ready + new Audio(s.audioUrl).play().catch(() => {}); + setText(""); + setTimeout(() => setStatus(null), 2000); + + // 2️⃣ In the background: upload audio to Firebase Storage and update the doc + if (firestoreDocId) { + (async () => { + try { + const permanentUrl = await uploadTtsAudio(s.audioUrl!, firestoreDocId!); + await updateTtsResultAudioUrl(firestoreDocId!, permanentUrl); + console.log("TTS audio uploaded to Storage:", permanentUrl); + } catch (uploadErr) { + console.error("Storage upload failed:", uploadErr); + } + })(); + } + } + }, + }); + } catch (err) { + setLoading(false); + console.error("TTS error:", err); + setStatus({ stage: "error", message: "Something went wrong. Try again." }); + setTimeout(() => setStatus(null), 3000); + } + }; + + return ( +
+
+