feat: Sasquatch clickable links, weather tool, ContextVM footer

- 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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-17 15:55:53 +07:00
parent f9c0f3c5ac
commit a3cd6cf9f4

View file

@ -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<string> {
// 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 <a> 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(<span key={i++}>{text.slice(last, match.index)}</span>);
}
if (match[1] && match[2]) {
// [label](url)
elements.push(
<a key={i++} href={match[2]} target="_blank" rel="noopener noreferrer"
className="text-[#ccff00] underline underline-offset-2 hover:text-[#d4ff1a] break-all">
{match[1]}
</a>
);
} else if (match[3]) {
// 👉 url
elements.push(
<a key={i++} href={match[3]} target="_blank" rel="noopener noreferrer"
className="text-[#ccff00] underline underline-offset-2 hover:text-[#d4ff1a] break-all">
{match[3]}
</a>
);
} else if (match[4]) {
// bare url
elements.push(
<a key={i++} href={match[4]} target="_blank" rel="noopener noreferrer"
className="text-[#ccff00] underline underline-offset-2 hover:text-[#d4ff1a] break-all">
{match[4]}
</a>
);
}
last = match.index + match[0].length;
}
if (last < text.length) {
elements.push(<span key={i++}>{text.slice(last)}</span>);
}
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:
</span>
)}
</div>
<div className="whitespace-pre-wrap">{msg.text}</div>
<div className="whitespace-pre-wrap"><ChatText text={msg.text} /></div>
</div>
))}
@ -616,15 +714,25 @@ Instructions:
{/* Footer */}
<div className="flex items-center justify-between">
<p className="text-[9px] text-gray-600">
Powered by AI · live Nostr data 🛹
<p className="text-[9px] text-gray-600 flex items-center gap-1 flex-wrap">
Powered by{' '}
<a
href="https://contextvm.org/s/e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530"
target="_blank"
rel="noopener noreferrer"
className="text-[#ccff00]/60 hover:text-[#ccff00] underline underline-offset-1 transition-colors"
>
ContextVM
</a>
{' & '}
Nostr Relays Data
</p>
{chatMessages.length > 0 && (
<button
onClick={() => { setChatMessages([]); setShowChat(false); setCvmConnected(null); }}
className="text-[9px] text-gray-600 hover:text-[#ccff00] transition-colors"
className="text-[9px] text-gray-600 hover:text-[#ccff00] transition-colors flex-shrink-0"
>
clear chat
clear
</button>
)}
</div>