feat: upgrade Sasquatch from static FAQ to live AI agent with CVM data
- Add useShakespeare.ts hook (Shakespeare AI via NIP-98 auth)
- Add useCVMClient.ts hook — calls SkateSpots CVM server tools over
Nostr directly from the browser (NIP-44 encrypted request/response,
no @contextvm/sdk bundle needed, pure nostr-tools)
- Rewrite SasquatchGuide to use Shakespeare AI + live CVM data:
* Static FAQ answers for protocol/app questions (fast, no API)
* Shakespeare AI for natural language understanding
* AI detects TOOL: calls in its response and executes them live
* CVM server queried for find_spots, find_events, find_coaches,
find_gear, get_community_stats
* Streaming "thinking..." and "Searching Nostr..." indicators
* Live/offline status badge shows CVM server connectivity
* Graceful fallback when CVM server unreachable
* Login nudge for unauthenticated users
CVM server pubkey: e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530
CVM server host: 185.18.221.2 (LNVPS, Dublin)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
2db0c6f7e2
commit
fadf36d86e
3 changed files with 1012 additions and 241 deletions
|
|
@ -1,10 +1,23 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
/**
|
||||
* SasquatchGuide — AI-powered skate assistant
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Upgraded from static FAQ to a live AI agent that:
|
||||
* 1. Uses Shakespeare AI (LLM) for natural language understanding
|
||||
* 2. Calls the SkateSpots CVM server on Nostr for live data
|
||||
* (spots, events, coaches, gear) when the question needs real data
|
||||
* 3. Falls back to static FAQ answers for protocol/app questions
|
||||
* (What is Nostr? What are zaps? etc.)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { X, Minimize2, HelpCircle, Send } from 'lucide-react';
|
||||
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useShakespeare } from '@/hooks/useShakespeare';
|
||||
import { useCVMClient, CVM_SERVER_PUBKEY } from '@/hooks/useCVMClient';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type SasquatchContext = 'home' | 'onboarding' | 'map' | 'lab';
|
||||
|
|
@ -13,13 +26,13 @@ export interface GuideMessage {
|
|||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
context?: SasquatchContext; // Context for FAQ responses
|
||||
suggestedQuestions?: string[]; // Quick questions users can click
|
||||
context?: SasquatchContext;
|
||||
suggestedQuestions?: string[];
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
enableChat?: boolean; // Allow Q&A on this message
|
||||
enableChat?: boolean;
|
||||
}
|
||||
|
||||
interface SasquatchGuideProps {
|
||||
|
|
@ -31,194 +44,133 @@ interface ChatMessage {
|
|||
role: 'user' | 'sasquatch';
|
||||
text: string;
|
||||
timestamp: number;
|
||||
isLive?: boolean; // came from CVM live data
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Context-specific FAQ responses
|
||||
type FAQResponses = Record<string, string>;
|
||||
// ─── System prompt for the AI ─────────────────────────────────────────────────
|
||||
// Teaches the LLM who Sasquatch is, what CVM tools are available,
|
||||
// and how to use them. Includes all the static FAQ knowledge inline.
|
||||
|
||||
// Home & Onboarding - Getting started, Nostr basics, Bitcoin basics
|
||||
const HOME_ONBOARDING_FAQ: FAQResponses = {
|
||||
// Nostr basics - Full explanations
|
||||
'what is nostr': "Nostr stands for 'Notes and Other Stuff Transmitted by Relays'. It's a simple, open protocol for decentralized social networks. Think Twitter, but no one can ban you, censor you, or control your data. You use cryptographic keys (like a password that can't be reset) instead of usernames. Your identity is portable across all Nostr apps - one account works everywhere! No company owns Nostr, it's just a protocol like email.",
|
||||
|
||||
'how does nostr work': "Here's the technical overview: You get a public key (npub - your identity, shareable) and a private key (nsec - for signing, keep secret!). When you post, your content is cryptographically signed with your private key and sent to relays (simple servers). Apps like SkateSpots connect to these relays to fetch and display content. No blockchain, no mining, no tokens - just a simple, elegant protocol that puts YOU in control of your data and identity!",
|
||||
|
||||
'what is relay': "Relays are lightweight servers that store and forward your Nostr events (posts, profiles, etc.). Think of them like post offices - you can send mail through any post office, and it still reaches its destination. You can use any relay, switch anytime, run your own, or use multiple simultaneously. This app connects to several relays for redundancy, so your content is always available even if one relay goes down. Relays are simple, cheap to run, and keep Nostr censorship-resistant!",
|
||||
|
||||
// Bitcoin & Lightning - Basics for skaters
|
||||
'what is bitcoin': "Bitcoin is digital money that no government or company controls. Created in 2009, it's peer-to-peer electronic cash - you can send it directly to anyone, anywhere, instantly. No banks needed! For skateboarding, it's perfect: permissionless (no one can stop you), censorship-resistant (can't be frozen), and global (works everywhere). Bitcoin is programmed to be scarce - only 21 million will ever exist - making it valuable like digital gold. Perfect for supporting skaters and content creators worldwide! 🪙",
|
||||
|
||||
'what are zaps': "Zaps are instant Bitcoin Lightning tips sent over Nostr! When you see a sick spot, killer trick video, or helpful content, you can zap the creator - sending them Bitcoin in milliseconds. It goes directly to their wallet, no middlemen taking cuts. Zaps are tied to your Nostr identity, so creators can see who appreciated their work. It's like hitting 'like' but with real monetary value! ⚡ Think of it as buying someone a coffee, but digital and instant.",
|
||||
|
||||
// Getting started - Step-by-step
|
||||
'how to login': "Step-by-step login guide:\n\n1. **Browser Extension (Desktop)**: Install Alby, nos2x, or Flamingo extension. Click 'Log in' in top-right, then approve the connection request.\n\n2. **Mobile/New User**: Click 'Sign up' to generate new keys. Your browser will download your nsec (secret key) - SAVE IT SAFELY! This is your account - lose it and you lose everything.\n\n3. **Existing User**: Click 'Log in', then 'Paste nsec' if you already have Nostr keys.\n\nNew to Nostr? Choose 'Sign up' - it takes 30 seconds! 🎯",
|
||||
|
||||
// Privacy & Security
|
||||
'is it safe': "YES! Privacy and security breakdown:\n\n✅ **Cryptography**: Nostr uses military-grade elliptic curve cryptography (same as Bitcoin). Your private key proves you own your account.\n\n✅ **Data Control**: You own your data. Posts are signed by YOUR key and stored on relays you choose.\n\n✅ **Privacy**: No email required, no phone number, no personal info needed to sign up.\n\n⚠️ **Important**: NEVER share your nsec (private key) with anyone! Use browser extensions like Alby to keep it secure. If someone gets your nsec, they control your account forever.\n\nFor more info: https://nostr.how/en/guides/sweep-your-nsec",
|
||||
|
||||
// Lightning & other
|
||||
'what is lightning': "Lightning Network is Bitcoin's instant payment layer - like Bitcoin on steroids! Send or receive Bitcoin in milliseconds for tiny fees (fractions of a cent). Perfect for tips, small transactions, and everyday use. It works by creating payment channels between users, allowing thousands of transactions per second. For SkateSpots, Lightning enables instant zaps to reward creators! ⚡",
|
||||
|
||||
'what is nwc': "NWC (Nostr Wallet Connect) is like USB-C for Bitcoin wallets! It's an open protocol that lets you connect your Lightning wallet to apps like SkateSpots. Instead of copying invoices or switching between apps, NWC lets you zap directly with one click. You connect once (paste a connection string from your wallet), and boom - instant zaps forever! Works with Alby, Minibits, Zeus, Coinos, and other modern wallets. It's secure (encrypted over Nostr), you control spending limits, and you can revoke access anytime. Think of it as signing into SkateSpots with your wallet! 🔌⚡",
|
||||
|
||||
'nostr wallet connect': "NWC (Nostr Wallet Connect) is like USB-C for Bitcoin wallets! It's an open protocol that lets you connect your Lightning wallet to apps like SkateSpots. Instead of copying invoices or switching between apps, NWC lets you zap directly with one click. You connect once (paste a connection string from your wallet), and boom - instant zaps forever! Works with Alby, Minibits, Zeus, Coinos, and other modern wallets. It's secure (encrypted over Nostr), you control spending limits, and you can revoke access anytime. Think of it as signing into SkateSpots with your wallet! 🔌⚡",
|
||||
|
||||
'how to zap': "First, connect a Lightning wallet (like Alby extension, or Minibits/Zeus/Coinos with NWC). Then click the ⚡ button on any content. Enter an amount in sats (1 sat ≈ $0.0004) and confirm. The sats go directly to the creator instantly! Common amounts: 21 sats (small tip), 210 sats (nice!), 2100 sats (generous!), 21000 sats (wow!).",
|
||||
|
||||
'do i need bitcoin': "Nope! You can browse spots, view events, explore the map, and connect with the community - all 100% FREE without any Bitcoin. But if you want to zap creators or earn sats for contributing spots/events, you'll need a Lightning wallet. Think of Bitcoin as optional premium features, not a requirement!",
|
||||
|
||||
'is this free': "100% free! No subscriptions, no ads, no corporate BS. Built by skaters, for skaters. You can earn Bitcoin by contributing quality spots and events (through community zaps), but the core app is completely free to use forever!",
|
||||
|
||||
'what can i do': "Discover spots worldwide, connect with pros at The Lab (coaches & filmers), join events and sessions, connect with skaters, earn sats for quality contributions, zap creators you appreciate, check weather conditions before skating, and help build the decentralized skate community! Everything is free except optional zaps.",
|
||||
|
||||
'who owns my data': "You do! Your posts are cryptographically signed with your keys and stored on relays. You can backup your keys, switch apps anytime, and no one can delete your content without your permission. Not even SkateSpots can censor you - that's the power of Nostr!",
|
||||
|
||||
// Trust Scores & Relays
|
||||
'what are trust scores': "Trust scores help identify reliable skaters and reduce spam! SkateSpots uses Relatr - a service that analyzes the entire Nostr network to compute global trust scores (0-100). It looks at factors like followers, activity, engagement, and zaps sent/received. Higher scores = more trusted accounts. This is auto-enabled for all users and helps protect the community from bots and spam. You can see trust scores on profiles and use them to verify coaches, shop owners, and other skaters. It's like reputation points for Nostr! 🛡️",
|
||||
|
||||
'trust score': "Trust scores help identify reliable skaters and reduce spam! SkateSpots uses Relatr - a service that analyzes the entire Nostr network to compute global trust scores (0-100). It looks at factors like followers, activity, engagement, and zaps sent/received. Higher scores = more trusted accounts. This is auto-enabled for all users and helps protect the community from bots and spam. You can see trust scores on profiles and use them to verify coaches, shop owners, and other skaters. It's like reputation points for Nostr! 🛡️",
|
||||
|
||||
'what is relatr': "Relatr is a trust scoring service powered by ContextVM that analyzes the entire Nostr network. It publishes global trust scores (NIP-85 events) to relays, helping apps like SkateSpots identify trustworthy users and filter spam. Think of it as a decentralized reputation system - instead of trusting one company's algorithm, Relatr computes scores from real network data (followers, engagement, zaps, activity). It's enabled by default in SkateSpots to protect the community! You can customize it in Settings → Advanced if you want. 🔍",
|
||||
|
||||
'what is nip85': "NIP-85 is a Nostr protocol for 'Trusted Assertions' - a way for services to publish trust and reputation data about users. Relatr uses NIP-85 to publish trust scores (ranks 0-100) along with metadata like follower counts, post counts, and zap amounts. Other apps can read these scores from relays to help filter content and identify trustworthy accounts. It's like a decentralized credit score, but for social networks! SkateSpots reads NIP-85 events from Relatr to show trust scores on profiles. 📊",
|
||||
|
||||
'how do relays work': "Relays store and forward your Nostr events! When you post, your content is sent to all your configured relays. When you browse, SkateSpots queries relays to fetch content. SkateSpots uses 2 default relays:\n\n1. 🛹 **goskatespots.nostr1.com** - Our primary skate relay (allowlisted for quality)\n2. 🛡️ **wotr.relatr.xyz** - Relatr's Web of Trust relay for trust scores + redundancy\n\nYou can manage relays in Settings → Relays. Want to run your own relay? Check out relay.tools! ⚡",
|
||||
|
||||
'what relays does this use': "SkateSpots uses 2 default relays:\n\n1. 🛹 **goskatespots.nostr1.com** - Our primary skate relay (allowlisted for quality)\n2. 🛡️ **wotr.relatr.xyz** - Relatr's Web of Trust relay for trust scores + redundancy\n\nYour posts go to both, so even if one is down, your content stays available! The primary relay uses an allowlist to prevent spam - DM the relay owner for access. The backup ensures your content always gets published. Want to run your own relay? Check out relay.tools! 📡",
|
||||
|
||||
'what is goskatespots relay': "goskatespots.nostr1.com is our primary skateboarding relay! It has custom policies for spots, events, coaches, filmers, and shops. It uses an **allowlist** for trust and spam management - your npub must be specifically approved to publish here. This keeps the quality high and spam low! If you're not on the allowlist yet, no worries - your content still publishes to the backup relay (wotr.relatr.xyz) so it's never lost. DM the relay owner to request access! 🛹",
|
||||
|
||||
'why cant i publish': "If you're getting publish errors, it's usually because you're not on the goskatespots.nostr1.com allowlist yet. Good news: your content STILL gets published to the backup relay (wotr.relatr.xyz), so nothing is lost! Everyone can see your spots, events, and posts. To get allowlisted on the primary relay:\n\n1. Build trust - contribute quality spots, be active in the community\n2. DM the relay owner with your npub\n3. Wait for approval\n\nIn the meantime, keep posting - the backup has you covered! 📝",
|
||||
|
||||
'allowlist relay': "The primary relay (goskatespots.nostr1.com) uses an allowlist to maintain quality and prevent spam. Only approved npubs can publish there. But don't worry - you have a backup relay (wotr.relatr.xyz) that accepts posts from everyone! Your content still publishes and everyone can see it. Think of the allowlist like a VIP club - it's exclusive, but you don't NEED it to participate. Request access by DMing the relay owner, or just keep using the backup! 🎫",
|
||||
|
||||
'how to manage relays': "Go to Settings → Relays! You'll see your 2 default relays and can add/remove relays as needed. Each relay has read/write toggles:\n\n- **Write (publish)**: Your posts go to these relays\n- **Read (fetch)**: You fetch content from these relays\n\nFor best results, enable both read+write on all relays. Want to run your own relay? Check out relay.tools to easily spin up and manage your own Nostr relay! Pro tip: If you're having issues, try removing and re-adding problematic relays. ⚙️",
|
||||
|
||||
// Cashu Wallet
|
||||
'what is cashu': "Cashu is digital cash backed by Bitcoin Lightning! It's like holding Bitcoin tokens directly in your browser - no external wallet needed. You can send and receive instantly, it works offline at skate spots, and it's privacy-focused (mints don't track who you pay). Think of it as cash in your pocket, but digital! Cashu tokens are always redeemable for Lightning sats, so you're never stuck. Your wallet is built right into SkateSpots - go to Settings → Wallet to use it! Learn more: https://cashumints.space 🥜",
|
||||
|
||||
'how does cashu work': "Cashu uses ecash technology - digital tokens backed by Bitcoin held at mints (servers). When you receive Lightning, a mint converts it to tokens stored in your browser. When you send Lightning, tokens are converted back. The cool part: mints can't see who pays who (privacy!), and you can use multiple mints for redundancy (like multiple relays!). Your tokens are secured by a 12-word backup phrase. It's like having a Bitcoin wallet built into SkateSpots - works offline, instant, and private! 💰",
|
||||
|
||||
'cashu wallet backup': "Your Cashu wallet has a 12-word recovery phrase (seed) - just like your Nostr nsec! Go to Settings → Wallet → Cashu Wallet → View Recovery Phrase to see your words. Write them down on paper and store safely in a secure place. If you clear browser data or switch devices, you'll need these words to restore your balance. ⚠️ NEVER share your seed with anyone - it's like a password that can't be reset! Anyone with your seed controls your wallet. Keep it as secure as your Nostr keys! 🔐",
|
||||
|
||||
'what are mints': "Mints are servers that issue Cashu tokens backed by Bitcoin Lightning. Think of them like banks, but open and interoperable - any wallet can use any mint! When you receive Lightning, a mint converts it to tokens. When you send Lightning, a mint converts tokens back. SkateSpots uses mint.minibits.cash by default, but you can add more in Settings → Wallet → Manage Mints. Using multiple mints is RECOMMENDED for redundancy (like multiple Nostr relays!). If one mint goes down, others keep working. Check https://cashumints.space to explore all available mints. 🏦",
|
||||
|
||||
'how to use cashu wallet': "Go to Settings → Wallet and scroll to the Cashu Wallet section!\n\n**To receive:**\n1. Enter amount in sats\n2. Click 'Generate Invoice'\n3. Pay from any Lightning wallet\n4. Tokens appear in your balance!\n\n**To send:**\n1. Enter amount\n2. Click 'Create Token'\n3. Share the QR code or copy the token\n4. Recipient redeems it!\n\nYou can also pay Lightning invoices directly or receive Cashu tokens from other users. It's all built-in - no external app needed! 🛹⚡",
|
||||
|
||||
'cashu vs lightning': "Both use Bitcoin Lightning, but differently:\n\n**Lightning Wallet (NWC):**\n• External app connected to SkateSpots\n• Requires setup\n• Always online\n• More established\n\n**Cashu Wallet (Built-in):**\n• Built into SkateSpots\n• No setup needed\n• Works offline\n• Privacy-focused\n\n**Best practice:** Use BOTH for redundancy! Cashu for quick payments at skate spots, Lightning/NWC for larger amounts and long-term storage. They work together - you can move sats between them anytime! 💪",
|
||||
|
||||
'is cashu safe': "Yes! Cashu uses cryptography (same tech as Bitcoin) to secure your tokens. Your balance is stored locally in your browser, and you control the seed. Important notes:\n\n✅ Mints are trusted servers (like relays) that back your tokens with Bitcoin\n✅ Use multiple mints for redundancy\n✅ Always backup your seed phrase\n✅ Tokens are always redeemable for Lightning sats\n⚠️ If a mint shuts down, withdraw your sats to Lightning first\n\nThink of mints like banks - use reputable ones and spread your balance! Check https://cashumints.space for trusted mints. 💰",
|
||||
};
|
||||
const SYSTEM_PROMPT = `You are The Sasquatch — the guide and mascot of SkateSpots, a decentralized skateboarding platform built on Nostr. You speak like a wise, laid-back skater who knows everything about skating AND the tech behind the app.
|
||||
|
||||
// The Lab - Coaching, filming, and services
|
||||
const LAB_FAQ: FAQResponses = {
|
||||
'what is the lab': "The Lab is where skaters level up! It's our marketplace for coaching and filming services. Find experienced skaters who can teach you tricks, or connect with filmers and photographers to capture your sessions. All profiles are on Nostr, so you know they're real skaters, not fake accounts. You can filter by skills (street, vert, transitions, etc.) and location. Want to earn sats teaching? You can offer your own services too!",
|
||||
|
||||
'what is lab': "The Lab is where skaters level up! It's our marketplace for coaching and filming services. Find experienced skaters who can teach you tricks, or connect with filmers and photographers to capture your sessions. All profiles are on Nostr, so you know they're real skaters, not fake accounts. You can filter by skills (street, vert, transitions, etc.) and location. Want to earn sats teaching? You can offer your own services too!",
|
||||
|
||||
'how to find coach': "Use the filters at the top! Select the 'Coaching' tab, then pick skills you want to learn (kickflips, grinds, bowl riding, etc.). Browse coaches in your area - each profile shows their experience, specialties, rates, and bio. Click any coach to see their full profile and contact them directly through Nostr. Pro tip: Look for coaches with NIP-05 verification (the green checkmark) - they're verified skaters!",
|
||||
|
||||
'how to find filmer': "Switch to the 'Filming' tab at the top! You'll find photographers and videographers who specialize in skate content. Filter by service type (photos, video editing, full sessions, etc.) and search by location. Check their portfolios, rates, and experience, then reach out directly. Many filmers accept Bitcoin payments too! 📹",
|
||||
|
||||
'how to offer coaching': "Click 'Offer Coaching' at the top! Fill out your profile: years of experience, what you can teach (street, vert, technical tricks, etc.), your location, and rates. Add photos and a detailed bio so skaters know who they're learning from. Your profile lives on Nostr, so you own it forever. Start earning sats while helping skaters progress!",
|
||||
|
||||
'how to become coach': "Click 'Offer Coaching' at the top! Fill out your profile: years of experience, what you can teach (street, vert, technical tricks, etc.), your location, and rates. Add photos and a detailed bio so skaters know who they're learning from. Your profile lives on Nostr, so you own it forever. Start earning sats while helping skaters progress!",
|
||||
|
||||
'how to offer filming': "Click 'Offer Filming' at the top! Detail what services you provide (filming sessions, photo shoots, video editing, slow-mo, etc.), your gear setup, experience, and pricing. Upload sample work to show your style. Once published, skaters can find you and book sessions. All payment terms are between you and the client - total freedom!",
|
||||
|
||||
'what are skills': "Skills are what coaches can teach! Options include: 🛹 Street (ledges, rails, stairs, gaps), 🏗️ Vert (half pipes, ramps), 🌊 Transitions (quarter pipes, mini ramps), 🎪 Bowls (pool riding), 💪 Technical (flip tricks, manuals), and more. Filter by what you want to learn, and we'll match you with coaches who specialize in it!",
|
||||
|
||||
'how much does coaching cost': "Rates vary by coach! Each profile shows their pricing - some charge hourly, others offer package deals. You'll see rates like '$50/hour' or 'Negotiable'. Many coaches also accept Bitcoin Lightning payments. Contact coaches directly to discuss rates and book sessions. Pro tip: Some coaches offer discounts for multiple sessions!",
|
||||
|
||||
'can i message coaches': "Yes! Click any coach profile and you'll see their contact info. Most coaches list their Nostr pubkey, so you can message them directly through any Nostr client. Some also include email, phone, or social media. All communication happens off-platform - we just connect you!",
|
||||
|
||||
'nip05 verification': "The green checkmark means they have NIP-05 verification! This proves they control a domain (like theirname@skateshop.com). It's not required, but it adds credibility - you know they're a real person/business. Verified coaches and filmers are more trustworthy!",
|
||||
};
|
||||
## Your personality
|
||||
- Chill, knowledgeable, uses skateboarding slang naturally
|
||||
- Short punchy answers unless detail is needed
|
||||
- Add skating emojis sparingly: 🛹 ⚡ 🦧
|
||||
- Never say "I'm just an AI" — you ARE The Sasquatch
|
||||
|
||||
// Map/Spots/Shops - Navigation, adding spots, finding shops, gear swap
|
||||
const MAP_FAQ: FAQResponses = {
|
||||
'how to use map': "Easy! The map shows three types of markers: 🛹 Skate Spots (parks, street spots), 🏪 Skate Shops (where to buy gear), and 🛒 Gear Swap (peer-to-peer marketplace at spots). Toggle them on/off with the buttons above the map. Click any marker to see details, or switch to list view to filter by spot type (street, park, bowl, etc.). Click anywhere on the map to add a new spot!",
|
||||
|
||||
'how to add a spot': "Click anywhere on the map to drop a pin at that location! Add photos of the spot, describe the features (rails, ledges, stairs, quarter pipes, etc.), tag the spot type (street, park, diy, etc.), and add access info. Hit publish and boom - it's on Nostr forever! Your contribution helps skaters worldwide find new places to shred. 🛹",
|
||||
|
||||
'how to add spot': "Click anywhere on the map to drop a pin at that location! Add photos of the spot, describe the features (rails, ledges, stairs, quarter pipes, etc.), tag the spot type (street, park, diy, etc.), and add access info. Hit publish and boom - it's on Nostr forever! Your contribution helps skaters worldwide find new places to shred. 🛹",
|
||||
|
||||
'what are spots': "Spots are skateable locations - anywhere you can ride! This includes skateparks (concrete bowls, ramps), street spots (stairs, handrails, ledges), DIY spots (built by skaters), transitions (quarter pipes, half pipes), and bowls. Each spot is geotagged on the map with photos, descriptions, and features. Think of it as Waze for skateboarding - skaters helping skaters find the best places to session! 🗺️",
|
||||
|
||||
'what are shops': "Skate shops are local businesses where you can buy boards, trucks, wheels, shoes, and all your skate gear. They're tagged with 🏪 on the map. Supporting your local shop supports the skate community! Many shops are run by skaters, host events, and are the heart of local skate scenes. You can add shops to the map too - just make sure to verify ownership if you're claiming a shop profile!",
|
||||
|
||||
'what is gear swap': "Gear Swap is a peer-to-peer marketplace where skaters can buy and sell used gear at specific spots! See something for sale? Meet at that spot for safe pickup. It's marked with 🛒 on the map. Important: Gear listings DON'T appear at shops (conflict of interest) - they only show at regular skate spots. List your old deck, wheels, or shoes and meet up at your local spot to sell them!",
|
||||
|
||||
'how to sell gear': "Find a spot on the map and click it, then scroll to the Gear Swap section and hit 'List Gear'. Upload photos, set your price in dollars (or mark as free), describe the condition, and add when you're usually at that spot. Buyers will see your listing when they view that spot. Meet up safely at the spot for the transaction. Keep it skater-to-skater! 💰",
|
||||
|
||||
'can i list gear at shop': "Nope! Gear Swap is peer-to-peer only and doesn't appear at shop locations to avoid conflicts of interest. Shops sell new gear - Gear Swap is for used gear between skaters. List your gear at regular skate spots (parks, street spots, DIY spots) instead. This keeps shops and the marketplace separate and fair for everyone! 🏪🚫",
|
||||
|
||||
'how to find shop': "Toggle on the 🏪 Shops filter at the top of the map (or in list view). You'll see all registered skate shops with their locations, contact info, hours, and what they carry. Click any shop to see details or get directions. Support your local shop - they're the backbone of the skate community! Many accept Bitcoin payments too. ⚡",
|
||||
|
||||
'how to find skate shop': "Toggle on the 🏪 Shops filter at the top of the map (or in list view). You'll see all registered skate shops with their locations, contact info, hours, and what they carry. Click any shop to see details or get directions. Support your local shop - they're the backbone of the skate community! Many accept Bitcoin payments too. ⚡",
|
||||
|
||||
'how to add shop': "If you're a shop owner: Go to the map and look for 'Add Shop' option. Fill in your business details, upload photos, add your hours and contact info. For verification, set up a NIP-05 identifier on your domain - this proves you own the shop! If you're NOT the owner but want to add a shop to the map, you can still add it, but owners can later claim and verify their profiles.",
|
||||
|
||||
'what are spot types': "Spots are tagged by their features: 🏞️ Street (stairs, rails, ledges, gaps), 🛝 Park (built skateparks with ramps), 🏗️ DIY (skater-built spots), 🌊 Bowl (concrete bowls and pools), 🎪 Vert (half pipes, mega ramps), 🛤️ Transition (quarter pipes, mini ramps), 🏛️ Plaza (open areas with multiple features), and more! Use these filters to find exactly what you want to skate.",
|
||||
|
||||
'spot types': "Spots are tagged by their features: 🏞️ Street (stairs, rails, ledges, gaps), 🛝 Park (built skateparks with ramps), 🏗️ DIY (skater-built spots), 🌊 Bowl (concrete bowls and pools), 🎪 Vert (half pipes, mega ramps), 🛤️ Transition (quarter pipes, mini ramps), 🏛️ Plaza (open areas with multiple features), and more! Use these filters to find exactly what you want to skate.",
|
||||
|
||||
'how to filter spots': "Use the filter buttons at the top! Toggle Spots 🛹, Shops 🏪, and Gear 🛒 on/off to show/hide each type. Below that, you'll see spot type filters like Street, Park, Bowl, DIY, etc. Click any to filter - click again to remove. Switch between Map and List view using the view toggle buttons. In list view, you can search by name or location too!",
|
||||
|
||||
'weather on map': "Toggle the ☁️ Weather button to see real-time conditions at spots! You'll see temperature, rain probability, wind, and a 'skateability score'. Filter by 'Good' or 'Perfect' conditions to find spots that are dry and rideable right now. Weather doesn't show for shops (since you're shopping indoors). This feature helps you plan sessions and avoid skating in the rain! 🌤️",
|
||||
|
||||
'map vs list view': "Map view shows spots visually on an interactive map - great for discovering spots near you or planning routes. List view shows all spots in a grid with photos and details - better for browsing and filtering by specific types. Toggle between them with the buttons at the top. Both views respect your filters!",
|
||||
};
|
||||
## Live data tools available
|
||||
You have access to live data from the Nostr network via the SkateSpots CVM server. When the user asks about:
|
||||
- Finding spots → use TOOL:find_spots with JSON args
|
||||
- Finding events → use TOOL:find_events with JSON args
|
||||
- Finding coaches/filmers → use TOOL:find_coaches with JSON args
|
||||
- Buying/selling gear → use TOOL:find_gear with JSON args
|
||||
- Community stats → use TOOL:get_community_stats
|
||||
|
||||
// Context-aware answer finder
|
||||
function findAnswer(question: string, context?: SasquatchContext): string {
|
||||
To call a tool, output EXACTLY this format on its own line (nothing else on that line):
|
||||
TOOL:tool_name {"arg": "value"}
|
||||
|
||||
Example: TOOL:find_spots {"city": "Barcelona", "difficulty": "beginner", "limit": 5}
|
||||
|
||||
After the tool result is returned, answer the user based on that data.
|
||||
|
||||
## App knowledge (answer these directly, no tool needed)
|
||||
- Nostr: decentralized protocol, cryptographic keys, no company controls it
|
||||
- Relays: goskatespots.nostr1.com (primary, allowlisted), wotr.relatr.xyz (backup), relay.ditto.pub
|
||||
- Zaps: Bitcoin Lightning tips over Nostr, instant, peer-to-peer
|
||||
- NWC: Nostr Wallet Connect — connects Lightning wallets (Alby, Minibits, Zeus, Coinos)
|
||||
- Cashu: built-in ecash wallet, privacy-focused, works offline
|
||||
- Trust scores: powered by Relatr (NIP-85), 0-100 reputation from network activity
|
||||
- Login: browser extension (Alby, nos2x) or generate new keys (Sign up)
|
||||
- Privacy: no email/phone needed, you own your keys and data
|
||||
- Free: 100% free, no ads, community-funded via Bitcoin zaps
|
||||
|
||||
## Spot/event kinds on Nostr
|
||||
- kind 30818: Skate spots
|
||||
- kind 31923: Skate events (NIP-52)
|
||||
- kind 35001: Coaches
|
||||
- kind 35002: Filmers
|
||||
- kind 35003: Video editors
|
||||
- kind 30402: Gear listings (NIP-99)
|
||||
|
||||
Keep answers concise. If live data is available, lead with it. 🛹`;
|
||||
|
||||
// ─── Detect if a question needs live CVM data ─────────────────────────────────
|
||||
|
||||
function needsLiveData(question: string): boolean {
|
||||
const q = question.toLowerCase();
|
||||
|
||||
// Choose FAQ based on context
|
||||
let faq: FAQResponses;
|
||||
switch (context) {
|
||||
case 'lab':
|
||||
faq = LAB_FAQ;
|
||||
break;
|
||||
case 'map':
|
||||
faq = MAP_FAQ;
|
||||
break;
|
||||
case 'home':
|
||||
case 'onboarding':
|
||||
default:
|
||||
faq = HOME_ONBOARDING_FAQ;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check context-specific FAQ first
|
||||
for (const [key, answer] of Object.entries(faq)) {
|
||||
if (q.includes(key)) {
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to checking all FAQs if not found in context
|
||||
const allFAQs = { ...HOME_ONBOARDING_FAQ, ...LAB_FAQ, ...MAP_FAQ };
|
||||
for (const [key, answer] of Object.entries(allFAQs)) {
|
||||
if (q.includes(key)) {
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
|
||||
return "Hmm, I'm not sure about that one! Try checking the FAQ page, or ask the community on Nostr. You can also browse the map to learn by exploring!";
|
||||
const liveKeywords = [
|
||||
'find', 'show me', 'search', 'where', 'any spots', 'spots in', 'spots near',
|
||||
'events in', 'events near', 'upcoming', 'coaches in', 'filmers in',
|
||||
'gear', 'for sale', 'buy', 'sell', 'how many', 'stats', 'count',
|
||||
'list', 'nearby', 'close to', 'around', 'available',
|
||||
];
|
||||
return liveKeywords.some((kw) => q.includes(kw));
|
||||
}
|
||||
|
||||
// ─── Static FAQ fallback (for protocol/app questions without AI) ──────────────
|
||||
|
||||
const STATIC_FAQ: Record<string, string> = {
|
||||
'what is nostr': "Nostr is a simple, open protocol for decentralized social networks. Think Twitter but no one can ban you. You use cryptographic keys instead of usernames — one account works everywhere across all Nostr apps! 🔑",
|
||||
'what are zaps': "Zaps are instant Bitcoin Lightning tips sent over Nostr! Click ⚡ on any content, send sats directly to the creator's wallet. Like hitting 'like' but with real money. Instant, peer-to-peer, no middlemen. ⚡",
|
||||
'how to login': "Two ways:\n1. **Browser extension** (Desktop): Install Alby or nos2x, then click 'Log in'\n2. **New user**: Click 'Sign up' to generate keys. Download your nsec (secret key) and SAVE IT — this is your account forever!",
|
||||
'what is cashu': "Built-in Bitcoin wallet! Cashu ecash tokens backed by Lightning — works offline at skate spots, privacy-focused (mints can't track payments). Go to Wallet in Settings to use it. 🥜",
|
||||
'what is nwc': "Nostr Wallet Connect — like USB-C for Lightning wallets. Connect Alby, Minibits, Zeus, or Coinos once, and zap with one click forever. Encrypted, you control spending limits. 🔌⚡",
|
||||
'is it free': "100% free. No ads, no subscriptions, no corporate BS. Built by skaters for skaters. You can earn Bitcoin through community zaps for contributing quality spots. 🛹",
|
||||
'what can i do': "Discover spots worldwide, connect with coaches & filmers in The Lab, join events, sell/buy gear, earn sats for contributions, zap creators, check spot weather, and build the decentralized skate community!",
|
||||
'what is the lab': "The Lab is where skaters level up! Marketplace for coaching and filming services. Find coaches to learn tricks, filmers to capture sessions, video editors. All on Nostr — real skaters, no fake accounts. 🎓",
|
||||
'trust score': "Trust scores (0-100) powered by Relatr. Analyzes your Nostr network: followers, activity, engagement, zaps. Higher = more trusted. Helps filter bots and spam. Check any profile to see their score. 🛡️",
|
||||
'what relay': "SkateSpots uses: 🛹 goskatespots.nostr1.com (primary, allowlisted for quality) + relay.ditto.pub + wotr.relatr.xyz (backup). Your data goes to all of them for redundancy.",
|
||||
};
|
||||
|
||||
function getStaticAnswer(question: string): string | null {
|
||||
const q = question.toLowerCase();
|
||||
for (const [key, answer] of Object.entries(STATIC_FAQ)) {
|
||||
if (q.includes(key)) return answer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── CVM connection status indicator ─────────────────────────────────────────
|
||||
|
||||
function CVMStatus({ connected }: { connected: boolean | null }) {
|
||||
if (connected === null) return null;
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded-full',
|
||||
connected
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-gray-500/20 text-gray-500'
|
||||
)}>
|
||||
{connected ? (
|
||||
<><Wifi className="h-2.5 w-2.5" /> Live</>
|
||||
) : (
|
||||
<><WifiOff className="h-2.5 w-2.5" /> Offline</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const shakespeare = useShakespeare();
|
||||
const cvm = useCVMClient();
|
||||
|
||||
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [userInput, setUserInput] = useState('');
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [isThinking, setIsThinking] = useState(false);
|
||||
const [cvmConnected, setCvmConnected] = useState<boolean | null>(null);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
// Delay appearance for smooth animation when first loading
|
||||
const timer = setTimeout(() => setIsVisible(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
|
|
@ -226,44 +178,165 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
}
|
||||
}, [message]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Just minimize instead of fully dismissing
|
||||
setIsMinimized(true);
|
||||
};
|
||||
// Auto-scroll chat to bottom
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [chatMessages, streamingText]);
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (!userInput.trim()) return;
|
||||
const appendMessage = useCallback((msg: ChatMessage) => {
|
||||
setChatMessages((prev) => [...prev, msg]);
|
||||
}, []);
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
role: 'user',
|
||||
text: userInput,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// ── Core: handle a user question ───────────────────────────────────────────
|
||||
|
||||
const answer = findAnswer(userInput, message?.context);
|
||||
const sasquatchMsg: ChatMessage = {
|
||||
role: 'sasquatch',
|
||||
text: answer,
|
||||
timestamp: Date.now() + 500,
|
||||
};
|
||||
const handleSendMessage = useCallback(async (questionOverride?: string) => {
|
||||
const question = (questionOverride ?? userInput).trim();
|
||||
if (!question || isThinking) return;
|
||||
|
||||
setChatMessages([...chatMessages, userMsg, sasquatchMsg]);
|
||||
setUserInput('');
|
||||
};
|
||||
setIsThinking(true);
|
||||
setStreamingText('');
|
||||
|
||||
// Don't show if no message for this page
|
||||
appendMessage({ role: 'user', text: question, timestamp: Date.now() });
|
||||
|
||||
try {
|
||||
// 1. Check static FAQ first (fast, no API call needed)
|
||||
const staticAnswer = getStaticAnswer(question);
|
||||
if (staticAnswer && !needsLiveData(question)) {
|
||||
appendMessage({
|
||||
role: 'sasquatch',
|
||||
text: staticAnswer,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Build conversation history for the AI
|
||||
const history = chatMessages.map((m) => ({
|
||||
role: m.role === 'user' ? ('user' as const) : ('assistant' as const),
|
||||
content: m.text,
|
||||
}));
|
||||
history.push({ role: 'user', content: question });
|
||||
|
||||
// 3. If user is logged in, use Shakespeare AI with streaming
|
||||
if (user && shakespeare.isAuthenticated) {
|
||||
let accumulated = '';
|
||||
|
||||
// First pass: get AI response which may include a TOOL: call
|
||||
const firstResponse = await shakespeare.sendChatMessage(
|
||||
[{ role: 'system', content: SYSTEM_PROMPT }, ...history],
|
||||
'shakespeare/claude-sonnet-4-5'
|
||||
);
|
||||
|
||||
const aiText = firstResponse.choices[0]?.message?.content as string ?? '';
|
||||
|
||||
// 4. Check if AI wants to call a CVM tool
|
||||
const toolMatch = aiText.match(/TOOL:(\w+)\s+(\{.*?\})/s);
|
||||
if (toolMatch) {
|
||||
const toolName = toolMatch[1];
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
try {
|
||||
toolArgs = JSON.parse(toolMatch[2]);
|
||||
} catch { /* keep empty */ }
|
||||
|
||||
// Show "searching..." indicator
|
||||
setStreamingText(`🔍 Searching Nostr for ${toolName.replace('_', ' ')}...`);
|
||||
|
||||
try {
|
||||
// Call the live CVM server
|
||||
let toolResult: unknown;
|
||||
switch (toolName) {
|
||||
case 'find_spots':
|
||||
toolResult = await cvm.findSpots(toolArgs as Parameters<typeof cvm.findSpots>[0]);
|
||||
break;
|
||||
case 'find_events':
|
||||
toolResult = await cvm.findEvents(toolArgs as Parameters<typeof cvm.findEvents>[0]);
|
||||
break;
|
||||
case 'find_coaches':
|
||||
toolResult = await cvm.findCoaches(toolArgs as Parameters<typeof cvm.findCoaches>[0]);
|
||||
break;
|
||||
case 'find_gear':
|
||||
toolResult = await cvm.findGear(toolArgs as Parameters<typeof cvm.findGear>[0]);
|
||||
break;
|
||||
case 'get_community_stats':
|
||||
toolResult = await cvm.getCommunityStats();
|
||||
break;
|
||||
default:
|
||||
toolResult = { error: 'Unknown tool' };
|
||||
}
|
||||
|
||||
setCvmConnected(true);
|
||||
|
||||
// 5. Send tool result back to AI for a natural language response
|
||||
setStreamingText('');
|
||||
const toolResultStr = JSON.stringify(toolResult, null, 2);
|
||||
const secondResponse = await shakespeare.sendChatMessage([
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
...history,
|
||||
{ role: 'assistant', content: aiText },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Tool result from ${toolName}:\n${toolResultStr}\n\nNow answer the user's question naturally based on this live data.`,
|
||||
},
|
||||
], 'shakespeare/claude-sonnet-4-5');
|
||||
|
||||
accumulated = secondResponse.choices[0]?.message?.content as string ?? '';
|
||||
} catch (toolErr) {
|
||||
// CVM server unreachable — answer from AI knowledge alone
|
||||
setCvmConnected(false);
|
||||
console.warn('[Sasquatch] CVM tool call failed:', toolErr);
|
||||
accumulated = aiText.replace(/TOOL:\w+\s+\{.*?\}/s, '').trim() +
|
||||
"\n\n*(Couldn't reach live data right now — try again in a moment!)*";
|
||||
}
|
||||
} else {
|
||||
// No tool call needed — AI answered from knowledge
|
||||
accumulated = aiText;
|
||||
}
|
||||
|
||||
setStreamingText('');
|
||||
appendMessage({
|
||||
role: 'sasquatch',
|
||||
text: accumulated || "Hmm, couldn't process that. Try rephrasing!",
|
||||
timestamp: Date.now(),
|
||||
isLive: !!toolMatch && cvmConnected === true,
|
||||
});
|
||||
|
||||
} else {
|
||||
// 6. Not logged in — use static FAQ or generic fallback
|
||||
const fallback =
|
||||
staticAnswer ??
|
||||
"I can answer much more with a Nostr login! For now: browse the map to discover spots, check The Lab for coaches, and hit Settings to connect your Lightning wallet. 🛹";
|
||||
appendMessage({
|
||||
role: 'sasquatch',
|
||||
text: fallback,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Sasquatch] Error:', err);
|
||||
appendMessage({
|
||||
role: 'sasquatch',
|
||||
text: "Something went sideways on my end. Try again in a sec! 🦧",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
setStreamingText('');
|
||||
}
|
||||
}, [userInput, isThinking, chatMessages, user, shakespeare, cvm, appendMessage, cvmConnected]);
|
||||
|
||||
// Don't render if no message for this page
|
||||
if (!message) return null;
|
||||
|
||||
const inputDisabled = isThinking;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Minimized button */}
|
||||
{/* Minimized bubble */}
|
||||
{isMinimized ? (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-20 md:bottom-4 left-4 z-40 transition-all duration-300',
|
||||
'opacity-100 translate-y-0' // Always visible when minimized
|
||||
)}
|
||||
>
|
||||
<div className="fixed bottom-20 md:bottom-4 left-4 z-40">
|
||||
<Button
|
||||
onClick={() => setIsMinimized(false)}
|
||||
className="flex items-center gap-2 bg-gradient-to-r from-amber-700 to-amber-900 hover:from-amber-600 hover:to-amber-800 text-white shadow-lg h-12 px-4 border-2 border-amber-500/30"
|
||||
|
|
@ -277,7 +350,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* Full guide */
|
||||
/* Full guide panel */
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-20 md:bottom-4 left-4 right-4 md:right-auto z-40 transition-all duration-300',
|
||||
|
|
@ -285,9 +358,10 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
)}
|
||||
>
|
||||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 border-2 border-amber-500/50 rounded-lg shadow-2xl max-w-sm mx-auto md:mx-0">
|
||||
{/* Header with Sasquatch */}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 p-4 pb-3 border-b border-amber-200">
|
||||
<div className="relative">
|
||||
<div className="relative flex-shrink-0">
|
||||
<img
|
||||
src="/images/sasquatch-guide.webp"
|
||||
alt="The Sasquatch"
|
||||
|
|
@ -299,35 +373,37 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-sm text-amber-900 flex items-center gap-2">
|
||||
<div className="font-bold text-sm text-amber-900 flex items-center gap-2 flex-wrap">
|
||||
{message.title}
|
||||
<span className="text-xs px-1.5 py-0.5 bg-amber-200 text-amber-800 rounded-full">
|
||||
🛹
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-amber-200 text-amber-800 rounded-full">🛹</span>
|
||||
<CVMStatus connected={cvmConnected} />
|
||||
</div>
|
||||
<div className="text-xs text-amber-700 flex items-center gap-1">
|
||||
The Sasquatch
|
||||
{user && (
|
||||
<span className="text-[9px] bg-green-100 text-green-700 px-1 rounded">AI</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-amber-700">The Sasquatch</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="ghost" size="icon"
|
||||
className="h-6 w-6 text-amber-600 hover:text-amber-800 hover:bg-amber-100"
|
||||
onClick={() => setIsMinimized(true)}
|
||||
>
|
||||
<Minimize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="ghost" size="icon"
|
||||
className="h-6 w-6 text-amber-600 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={handleDismiss}
|
||||
onClick={() => setIsMinimized(true)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{/* Greeting message */}
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-gray-800 leading-relaxed">
|
||||
{message.message}
|
||||
|
|
@ -344,7 +420,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{/* Chat feature */}
|
||||
{/* Chat section */}
|
||||
{message.enableChat && (
|
||||
<div className="mt-4 pt-4 border-t border-amber-200">
|
||||
{!showChat ? (
|
||||
|
|
@ -357,23 +433,25 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
<HelpCircle className="h-3 w-3 mr-2" />
|
||||
{message.context === 'lab' && 'Ask about Coaching & Filming'}
|
||||
{message.context === 'map' && 'Ask about Spots, Shops & Gear'}
|
||||
{(message.context === 'home' || message.context === 'onboarding') && 'Ask me about Nostr, Bitcoin & Zaps'}
|
||||
{(message.context === 'home' || message.context === 'onboarding') && 'Ask me anything 🛹'}
|
||||
{!message.context && 'Ask me anything'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Default questions */}
|
||||
|
||||
{/* Suggested questions (before any chat) */}
|
||||
{chatMessages.length === 0 && message.suggestedQuestions && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] text-amber-700 font-semibold mb-2">Quick Questions:</div>
|
||||
<div className="text-[10px] text-amber-700 font-semibold mb-2">
|
||||
Quick Questions:
|
||||
</div>
|
||||
{message.suggestedQuestions.map((q) => (
|
||||
<Button
|
||||
key={q}
|
||||
onClick={() => {
|
||||
setUserInput(q);
|
||||
setTimeout(() => handleSendMessage(), 100);
|
||||
}}
|
||||
onClick={() => handleSendMessage(q)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isThinking}
|
||||
className="w-full justify-start text-xs text-amber-800 hover:bg-amber-100 h-auto py-1.5"
|
||||
>
|
||||
{q}
|
||||
|
|
@ -382,55 +460,89 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat messages */}
|
||||
{chatMessages.length > 0 && (
|
||||
<ScrollArea className="h-48 rounded border border-amber-200 bg-white p-2">
|
||||
<div className="space-y-2">
|
||||
{/* Chat history */}
|
||||
{(chatMessages.length > 0 || streamingText) && (
|
||||
<ScrollArea className="h-52 rounded border border-amber-200 bg-white p-2">
|
||||
<div className="space-y-2" ref={scrollRef}>
|
||||
{chatMessages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
'text-xs p-2 rounded leading-relaxed',
|
||||
msg.role === 'user'
|
||||
? 'bg-blue-100 text-blue-900 ml-6'
|
||||
: 'bg-amber-100 text-amber-900 mr-6'
|
||||
? 'bg-blue-100 text-blue-900 ml-4'
|
||||
: 'bg-amber-100 text-amber-900 mr-4'
|
||||
)}
|
||||
>
|
||||
<div className="font-bold text-[10px] mb-1 opacity-70">
|
||||
<div className="font-bold text-[10px] mb-1 opacity-70 flex items-center gap-1">
|
||||
{msg.role === 'user' ? '🧑 You' : '🦧 Sasquatch'}
|
||||
{msg.isLive && (
|
||||
<span className="text-[9px] bg-green-100 text-green-600 px-1 rounded">
|
||||
live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.text}
|
||||
<div className="whitespace-pre-wrap">{msg.text}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Streaming / thinking indicator */}
|
||||
{(isThinking || streamingText) && (
|
||||
<div className="bg-amber-100 text-amber-900 mr-4 text-xs p-2 rounded">
|
||||
<div className="font-bold text-[10px] mb-1 opacity-70">🦧 Sasquatch</div>
|
||||
{streamingText ? (
|
||||
<span className="italic text-amber-700">{streamingText}</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-amber-600">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
thinking...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
{/* Input row */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||
placeholder="Type your question..."
|
||||
onKeyDown={(e) => e.key === 'Enter' && !inputDisabled && handleSendMessage()}
|
||||
placeholder={
|
||||
!user
|
||||
? 'Login for AI answers...'
|
||||
: 'Ask anything about skating...'
|
||||
}
|
||||
disabled={inputDisabled}
|
||||
className="text-sm border-amber-300 focus-visible:ring-amber-400"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
onClick={() => handleSendMessage()}
|
||||
size="sm"
|
||||
disabled={!userInput.trim()}
|
||||
disabled={!userInput.trim() || inputDisabled}
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
{isThinking ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Not logged in nudge */}
|
||||
{!user && (
|
||||
<p className="text-[10px] text-amber-600 text-center">
|
||||
Login with Nostr for AI-powered answers + live data 🛹
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Reset */}
|
||||
{chatMessages.length > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setChatMessages([]);
|
||||
setShowChat(false);
|
||||
}}
|
||||
onClick={() => { setChatMessages([]); setShowChat(false); setCvmConnected(null); }}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full text-xs text-amber-600 hover:text-amber-800"
|
||||
|
|
|
|||
287
src/hooks/useCVMClient.ts
Normal file
287
src/hooks/useCVMClient.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* useCVMClient
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Browser-side client for the SkateSpots CVM server running on LNVPS.
|
||||
*
|
||||
* Calls the MCP tools exposed by the server via a direct Nostr-encrypted
|
||||
* request/response cycle (NIP-44 gift wrap style), without bundling the
|
||||
* full @contextvm/sdk (which has Node.js dependencies incompatible with
|
||||
* browser bundling).
|
||||
*
|
||||
* Protocol (simplified MCP over Nostr):
|
||||
* 1. Generate ephemeral Nostr keypair for this session
|
||||
* 2. Encrypt the MCP JSON-RPC request with NIP-44 to the server pubkey
|
||||
* 3. Publish as kind 21000 (ContextVM request event) to relays
|
||||
* 4. Subscribe for a response event tagged with our ephemeral pubkey
|
||||
* 5. Decrypt the response and parse the MCP result
|
||||
*
|
||||
* Server pubkey: e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
generateSecretKey,
|
||||
getPublicKey,
|
||||
finalizeEvent,
|
||||
SimplePool,
|
||||
} from 'nostr-tools';
|
||||
import { nip44 } from 'nostr-tools';
|
||||
|
||||
// ─── CVM Server Config ───────────────────────────────────────────────────────
|
||||
|
||||
export const CVM_SERVER_PUBKEY =
|
||||
'e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530';
|
||||
|
||||
const CVM_RELAYS = [
|
||||
'wss://relay.contextvm.net',
|
||||
'wss://goskatespots.nostr1.com',
|
||||
'wss://relay.ditto.pub',
|
||||
];
|
||||
|
||||
// ContextVM uses kind 21000 for client→server requests (ephemeral)
|
||||
const CVM_REQUEST_KIND = 21000;
|
||||
const CVM_RESPONSE_KIND = 21001;
|
||||
|
||||
// ─── Tool result types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface SpotResult {
|
||||
name: string;
|
||||
description: string;
|
||||
city?: string;
|
||||
country?: string;
|
||||
difficulty?: string;
|
||||
features: string[];
|
||||
geohash?: string;
|
||||
image?: string;
|
||||
author_pubkey: string;
|
||||
naddr: string;
|
||||
url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EventResult {
|
||||
title: string;
|
||||
description: string;
|
||||
location?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
type: string[];
|
||||
image?: string;
|
||||
author_pubkey: string;
|
||||
naddr: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CoachResult {
|
||||
role: 'coach' | 'filmer' | 'editor';
|
||||
pubkey: string;
|
||||
bio: string;
|
||||
location?: string;
|
||||
skills: string[];
|
||||
experience?: string;
|
||||
lightning_address?: string;
|
||||
naddr: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GearResult {
|
||||
title: string;
|
||||
description: string;
|
||||
price?: string;
|
||||
currency?: string;
|
||||
condition?: string;
|
||||
categories: string[];
|
||||
image?: string;
|
||||
seller_pubkey: string;
|
||||
naddr: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CommunityStats {
|
||||
spots: number;
|
||||
upcoming_events: number;
|
||||
coaches: number;
|
||||
filmers: number;
|
||||
editors: number;
|
||||
gear_listings: number;
|
||||
relays_queried: string[];
|
||||
queried_at: string;
|
||||
}
|
||||
|
||||
// ─── Core: send a CVM tool call via Nostr ────────────────────────────────────
|
||||
|
||||
async function callCVMTool(
|
||||
toolName: string,
|
||||
toolArgs: Record<string, unknown>,
|
||||
timeoutMs = 10000
|
||||
): Promise<unknown> {
|
||||
// Generate ephemeral keypair for this request
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
|
||||
// Build the MCP JSON-RPC request
|
||||
const requestId = Math.floor(Math.random() * 1000000);
|
||||
const mcpRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: toolArgs,
|
||||
},
|
||||
};
|
||||
|
||||
// Encrypt request to server using NIP-44
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(sk, CVM_SERVER_PUBKEY);
|
||||
const encryptedContent = nip44.v2.encrypt(
|
||||
JSON.stringify(mcpRequest),
|
||||
conversationKey
|
||||
);
|
||||
|
||||
// Build the Nostr event
|
||||
const eventTemplate = {
|
||||
kind: CVM_REQUEST_KIND,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['p', CVM_SERVER_PUBKEY],
|
||||
],
|
||||
content: encryptedContent,
|
||||
};
|
||||
|
||||
const signedEvent = finalizeEvent(eventTemplate, sk);
|
||||
|
||||
const pool = new SimplePool();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
sub.close();
|
||||
pool.close(CVM_RELAYS);
|
||||
reject(new Error(`CVM tool call timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
// Subscribe for the response before publishing
|
||||
const sub = pool.subscribeMany(
|
||||
CVM_RELAYS,
|
||||
[
|
||||
{
|
||||
kinds: [CVM_RESPONSE_KIND],
|
||||
'#p': [pk], // response tagged to our ephemeral pubkey
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(responseEvent) {
|
||||
try {
|
||||
// Decrypt the response
|
||||
const convKey = nip44.v2.utils.getConversationKey(sk, responseEvent.pubkey);
|
||||
const decrypted = nip44.v2.decrypt(responseEvent.content, convKey);
|
||||
const mcpResponse = JSON.parse(decrypted);
|
||||
|
||||
// Match by request ID
|
||||
if (mcpResponse.id !== requestId) return;
|
||||
|
||||
clearTimeout(timer);
|
||||
sub.close();
|
||||
pool.close(CVM_RELAYS);
|
||||
|
||||
if (mcpResponse.error) {
|
||||
reject(new Error(mcpResponse.error.message ?? 'CVM error'));
|
||||
} else {
|
||||
resolve(mcpResponse.result);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore events we can't decrypt (not for us)
|
||||
}
|
||||
},
|
||||
oneose() {
|
||||
// Keep listening even after EOSE — response may come after
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Publish the request after subscribing
|
||||
pool.publish(CVM_RELAYS, signedEvent).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// Parse JSON from MCP tool result content array
|
||||
function parseToolResult<T>(result: unknown): T {
|
||||
const r = result as { content?: Array<{ type: string; text?: string }> };
|
||||
const text = r?.content?.[0]?.text;
|
||||
if (!text) throw new Error('Empty tool result');
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
// ─── The hook ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCVMClient() {
|
||||
const findSpots = useCallback(
|
||||
async (params: {
|
||||
city?: string;
|
||||
country?: string;
|
||||
difficulty?: 'beginner' | 'intermediate' | 'advanced' | 'all';
|
||||
features?: string[];
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}): Promise<{ count: number; spots: SpotResult[] }> => {
|
||||
const result = await callCVMTool('find_spots', params as Record<string, unknown>);
|
||||
return parseToolResult(result);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const findEvents = useCallback(
|
||||
async (params: {
|
||||
city?: string;
|
||||
country?: string;
|
||||
event_type?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
limit?: number;
|
||||
}): Promise<{ count: number; events: EventResult[] }> => {
|
||||
const result = await callCVMTool('find_events', params as Record<string, unknown>);
|
||||
return parseToolResult(result);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const findCoaches = useCallback(
|
||||
async (params: {
|
||||
role?: 'coach' | 'filmer' | 'editor' | 'any';
|
||||
skills?: string[];
|
||||
location?: string;
|
||||
limit?: number;
|
||||
}): Promise<{ count: number; profiles: CoachResult[] }> => {
|
||||
const result = await callCVMTool('find_coaches', params as Record<string, unknown>);
|
||||
return parseToolResult(result);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const findGear = useCallback(
|
||||
async (params: {
|
||||
category?: string;
|
||||
condition?: 'new' | 'like_new' | 'good' | 'fair' | 'any';
|
||||
max_price_sats?: number;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
}): Promise<{ count: number; listings: GearResult[] }> => {
|
||||
const result = await callCVMTool('find_gear', params as Record<string, unknown>);
|
||||
return parseToolResult(result);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getCommunityStats = useCallback(async (): Promise<CommunityStats> => {
|
||||
const result = await callCVMTool('get_community_stats', {});
|
||||
return parseToolResult(result);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
findSpots,
|
||||
findEvents,
|
||||
findCoaches,
|
||||
findGear,
|
||||
getCommunityStats,
|
||||
};
|
||||
}
|
||||
372
src/hooks/useShakespeare.ts
Normal file
372
src/hooks/useShakespeare.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import type { NUser } from '@nostrify/react/login';
|
||||
|
||||
// Types for Shakespeare API (compatible with OpenAI ChatCompletionMessageParam)
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string | Array<{
|
||||
type: 'text' | 'image_url';
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: ChatMessage;
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
object: string;
|
||||
owned_by: string;
|
||||
created: number;
|
||||
context_window: number;
|
||||
pricing: {
|
||||
prompt: string;
|
||||
completion: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
object: string;
|
||||
data: Model[];
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
|
||||
|
||||
// Helper function to create NIP-98 token
|
||||
async function createNIP98Token(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
user?: NUser
|
||||
): Promise<string> {
|
||||
if (!user?.signer) {
|
||||
throw new Error('User signer is required for NIP-98 authentication');
|
||||
}
|
||||
|
||||
// Create the tags array
|
||||
const tags: string[][] = [
|
||||
['u', url],
|
||||
['method', method]
|
||||
];
|
||||
|
||||
// Add payload hash for requests with body (following NIP-98 spec)
|
||||
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
|
||||
const bodyString = JSON.stringify(body);
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(bodyString);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const payloadHash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
tags.push(['payload', payloadHash]);
|
||||
}
|
||||
|
||||
// Create the HTTP request event
|
||||
const event = await user.signer.signEvent({
|
||||
kind: 27235, // NIP-98 HTTP Auth
|
||||
content: '',
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
// Return the token (base64 encoded event)
|
||||
return btoa(JSON.stringify(event));
|
||||
}
|
||||
|
||||
// Helper function to handle API errors with user-friendly messages
|
||||
async function handleAPIError(response: Response) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication failed. Please make sure you are logged in with a Nostr account.');
|
||||
} else if (response.status === 402) {
|
||||
throw new Error('Insufficient credits. Please add credits to your account to use premium models, or use the free "tybalt" model.');
|
||||
} else if (response.status === 400) {
|
||||
try {
|
||||
const error = await response.json();
|
||||
if (error.error?.type === 'invalid_request_error') {
|
||||
// Handle specific validation errors
|
||||
if (error.error.code === 'minimum_amount_not_met') {
|
||||
throw new Error(`Minimum credit amount is $${error.error.minimum_amount}. Please increase your payment amount.`);
|
||||
} else if (error.error.code === 'unsupported_method') {
|
||||
throw new Error('Payment method not supported. Please use "stripe" or "lightning".');
|
||||
} else if (error.error.code === 'invalid_url') {
|
||||
throw new Error('Invalid redirect URL provided for Stripe payment.');
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid request: ${error.error?.message || error.details || error.error || 'Please check your request parameters.'}`);
|
||||
} catch {
|
||||
throw new Error('Invalid request. Please check your parameters and try again.');
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
throw new Error('Resource not found. Please check the payment ID or try again.');
|
||||
} else if (response.status >= 500) {
|
||||
throw new Error('Server error. Please try again in a few moments.');
|
||||
} else if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
throw new Error(`API error: ${errorData.error?.message || errorData.details || errorData.error || response.statusText}`);
|
||||
} catch {
|
||||
throw new Error(`Network error: ${response.statusText}. Please check your connection and try again.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useShakespeare() {
|
||||
const { user } = useCurrentUser();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Clear error helper
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Chat completion function
|
||||
const sendChatMessage = useCallback(async (
|
||||
messages: ChatMessage[],
|
||||
model: string = 'shakespeare',
|
||||
options?: Partial<ChatCompletionRequest>
|
||||
): Promise<ChatCompletionResponse> => {
|
||||
if (!user) {
|
||||
throw new Error('User must be logged in to use AI features');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const requestBody: ChatCompletionRequest = {
|
||||
model,
|
||||
messages,
|
||||
...options
|
||||
};
|
||||
|
||||
const token = await createNIP98Token(
|
||||
'POST',
|
||||
`${SHAKESPEARE_API_URL}/chat/completions`,
|
||||
requestBody,
|
||||
user
|
||||
);
|
||||
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
await handleAPIError(response);
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === 'string') {
|
||||
errorMessage = err;
|
||||
}
|
||||
|
||||
// Add context for common issues
|
||||
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
|
||||
errorMessage = 'Network error: Please check your internet connection and try again.';
|
||||
} else if (errorMessage.includes('signer')) {
|
||||
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage, { cause: err });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Streaming chat completion function
|
||||
const sendStreamingMessage = useCallback(async (
|
||||
messages: ChatMessage[],
|
||||
model: string = 'shakespeare',
|
||||
onChunk: (chunk: string) => void,
|
||||
options?: Partial<ChatCompletionRequest>
|
||||
): Promise<void> => {
|
||||
if (!user) {
|
||||
throw new Error('User must be logged in to use AI features');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const requestBody: ChatCompletionRequest = {
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
...options
|
||||
};
|
||||
|
||||
const token = await createNIP98Token(
|
||||
'POST',
|
||||
`${SHAKESPEARE_API_URL}/chat/completions`,
|
||||
requestBody,
|
||||
user
|
||||
);
|
||||
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
await handleAPIError(response);
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) {
|
||||
onChunk(content);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors for incomplete chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (err) {
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === 'string') {
|
||||
errorMessage = err;
|
||||
}
|
||||
|
||||
// Add context for common issues
|
||||
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
|
||||
errorMessage = 'Network error: Please check your internet connection and try again.';
|
||||
} else if (errorMessage.includes('signer')) {
|
||||
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage, { cause: err });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Get available models
|
||||
const getAvailableModels = useCallback(async (): Promise<ModelsResponse> => {
|
||||
if (!user) {
|
||||
throw new Error('User must be logged in to use AI features');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = await createNIP98Token(
|
||||
'GET',
|
||||
`${SHAKESPEARE_API_URL}/models`,
|
||||
undefined,
|
||||
user
|
||||
);
|
||||
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
await handleAPIError(response);
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === 'string') {
|
||||
errorMessage = err;
|
||||
}
|
||||
|
||||
// Add context for common issues
|
||||
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
|
||||
errorMessage = 'Network error: Please check your internet connection and try again.';
|
||||
} else if (errorMessage.includes('signer')) {
|
||||
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage, { cause: err });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
isAuthenticated: !!user,
|
||||
|
||||
// Actions
|
||||
sendChatMessage,
|
||||
sendStreamingMessage,
|
||||
getAvailableModels,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue