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 { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useCVMClient } from '@/hooks/useCVMClient';
|
import { useCVMClient } from '@/hooks/useCVMClient';
|
||||||
import { cn } from '@/lib/utils';
|
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 ────────
|
// ─── OpenRouter free model — no credits, no NIP-98, works for everyone ────────
|
||||||
const OPENROUTER_API_KEY = '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';
|
const OPENROUTER_API_KEY = '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';
|
||||||
|
|
@ -133,6 +170,12 @@ Example: {"tool":"find_gear","args":{"category":"deck"}}
|
||||||
Args: none
|
Args: none
|
||||||
Example: {"tool":"get_community_stats","args":{}}
|
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
|
## Rules
|
||||||
1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess
|
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
|
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 ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
|
|
@ -327,6 +419,12 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
case 'get_community_stats':
|
case 'get_community_stats':
|
||||||
toolResult = await cvm.getCommunityStats();
|
toolResult = await cvm.getCommunityStats();
|
||||||
break;
|
break;
|
||||||
|
case 'get_weather': {
|
||||||
|
const weatherCity = (toolArgs as { city?: string }).city ?? '';
|
||||||
|
const weatherJson = await fetchWeatherForCity(weatherCity);
|
||||||
|
toolResult = { content: [{ type: 'text', text: weatherJson }] };
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
toolResult = { error: `Unknown tool: ${toolName}` };
|
toolResult = { error: `Unknown tool: ${toolName}` };
|
||||||
}
|
}
|
||||||
|
|
@ -569,7 +667,7 @@ Instructions:
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="whitespace-pre-wrap">{msg.text}</div>
|
<div className="whitespace-pre-wrap"><ChatText text={msg.text} /></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -616,15 +714,25 @@ Instructions:
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[9px] text-gray-600">
|
<p className="text-[9px] text-gray-600 flex items-center gap-1 flex-wrap">
|
||||||
Powered by AI · live Nostr data 🛹
|
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>
|
</p>
|
||||||
{chatMessages.length > 0 && (
|
{chatMessages.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setChatMessages([]); setShowChat(false); setCvmConnected(null); }}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue