From a3cd6cf9f40e26917653a96b58d97108e8b7637d Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 17 Jun 2026 15:55:53 +0700 Subject: [PATCH] feat: Sasquatch clickable links, weather tool, ContextVM footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatText component renders markdown [label](url) and bare URLs as neon clickable links - ๐Ÿ‘‰ URL patterns also rendered as clickable links - get_weather tool: real-time weather + skateability score via Open-Meteo Ask "weather in Bangkok?" or "is it good to skate in Barcelona today?" - Footer updated: "Powered by ContextVM & Nostr Relays Data" with ContextVM linking to https://contextvm.org/s/e3564bb877... - Weather geocodes city name via Nominatim, returns temp/conditions/wind/skateability Co-authored-by: shakespeare.diy --- src/components/SasquatchGuide.tsx | 118 ++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 5 deletions(-) diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index f26f11d..f73c9db 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -16,6 +16,43 @@ import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCVMClient } from '@/hooks/useCVMClient'; import { cn } from '@/lib/utils'; +import { fetchWeather, calculateSkateability } from '@/lib/weather'; +import ngeohash from 'ngeohash'; + +// โ”€โ”€โ”€ Weather helper โ€” fetch current conditions for a city/spot via Open-Meteo โ”€โ”€ + +async function fetchWeatherForCity(city: string): Promise { + // Geocode the city using Nominatim + const geoResp = await fetch( + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(city)}&format=json&limit=1`, + { headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' } } + ); + const geoData = await geoResp.json(); + if (!geoData?.length) return `No location found for "${city}"`; + + const lat = parseFloat(geoData[0].lat); + const lng = parseFloat(geoData[0].lon); + const weather = await fetchWeather(lat, lng); + const geohash = ngeohash.encode(lat, lng, 5); + const skateability = calculateSkateability(weather, undefined); + + return JSON.stringify({ + city, + temperature_c: weather.temperature, + feels_like_c: weather.feelsLike, + condition: weather.weatherDescription, + icon: weather.weatherIcon, + precipitation_mm: weather.precipitation, + wind_kmh: weather.windSpeed, + humidity_pct: weather.humidity, + uv_index: weather.uvIndex, + skateability_score: skateability.score, + skateability_label: skateability.label, + skateability_reasons: skateability.reasons, + geohash, + is_day: weather.isDay, + }); +} // โ”€โ”€โ”€ OpenRouter free model โ€” no credits, no NIP-98, works for everyone โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const OPENROUTER_API_KEY = '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'; @@ -133,6 +170,12 @@ Example: {"tool":"find_gear","args":{"category":"deck"}} Args: none Example: {"tool":"get_community_stats","args":{}} +### get_weather +Get real-time weather + skateability score for any city or spot location. +Args: city(string) โ€” city name or location +Example: {"tool":"get_weather","args":{"city":"Bangkok"}} +Example: {"tool":"get_weather","args":{"city":"Barcelona"}} + ## Rules 1. Data questions (spots/events/coaches/gear/stats) โ†’ ALWAYS use a tool, never guess 2. Tool call = ONE JSON object on its own line, nothing else before or after @@ -214,6 +257,55 @@ function CVMStatus({ connected }: { connected: boolean | null }) { ); } +// โ”€โ”€โ”€ ChatText โ€” renders markdown links as real tags โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function ChatText({ text }: { text: string }) { + // Split on markdown links [label](url) and bare URLs + const parts = text.split(/(\[([^\]]+)\]\((https?:\/\/[^\)]+)\)|https?:\/\/\S+)/g); + const elements: React.ReactNode[] = []; + let i = 0; + // Use a regex to find all links in order + const linkRegex = /\[([^\]]+)\]\((https?:\/\/[^\)]+)\)|๐Ÿ‘‰\s*(https?:\/\/\S+)|(https?:\/\/\S+)/g; + let last = 0; + let match: RegExpExecArray | null; + while ((match = linkRegex.exec(text)) !== null) { + // Text before this match + if (match.index > last) { + elements.push({text.slice(last, match.index)}); + } + if (match[1] && match[2]) { + // [label](url) + elements.push( + + {match[1]} + + ); + } else if (match[3]) { + // ๐Ÿ‘‰ url + elements.push( + + {match[3]} + + ); + } else if (match[4]) { + // bare url + elements.push( + + {match[4]} + + ); + } + last = match.index + match[0].length; + } + if (last < text.length) { + elements.push({text.slice(last)}); + } + return <>{elements}; +} + // โ”€โ”€โ”€ Main component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { @@ -327,6 +419,12 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { case 'get_community_stats': toolResult = await cvm.getCommunityStats(); break; + case 'get_weather': { + const weatherCity = (toolArgs as { city?: string }).city ?? ''; + const weatherJson = await fetchWeatherForCity(weatherCity); + toolResult = { content: [{ type: 'text', text: weatherJson }] }; + break; + } default: toolResult = { error: `Unknown tool: ${toolName}` }; } @@ -569,7 +667,7 @@ Instructions: )} -
{msg.text}
+
))} @@ -616,15 +714,25 @@ Instructions: {/* Footer */}
-

- Powered by AI ยท live Nostr data ๐Ÿ›น +

+ Powered by{' '} + + ContextVM + + {' & '} + Nostr Relays Data

{chatMessages.length > 0 && ( )}