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:
parent
f9c0f3c5ac
commit
a3cd6cf9f4
1 changed files with 113 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue