chore: initial sync

This commit is contained in:
fn-registry agent
2026-04-28 22:12:49 +02:00
commit f803067cb1
23 changed files with 3480 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
frontend/node_modules/
frontend/dist/
frontend/android/
frontend/.vite/
frontend/tsconfig.tsbuildinfo
backend/__pycache__/
backend/*.pyc
backend/.venv/
*.apk
.venv/
*.log
+178
View File
@@ -0,0 +1,178 @@
"""Voice Guide — Backend FastAPI.
Compone funciones del registry para guiar al usuario por voz
según su ubicación e intereses.
"""
import sys
import os
import json
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
# Registry functions path
_registry_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_registry_root / "python" / "functions"))
from infra.overpass_nearby_pois import overpass_nearby_pois
from infra.nominatim_reverse_geocode import nominatim_reverse_geocode
from infra.ollama_chat import ollama_chat
from core.match_pois_to_interests import match_pois_to_interests
from core.build_guide_prompt import build_guide_prompt
app = FastAPI(title="Voice Guide API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# --- In-memory store (per session, no DB needed) ---
_interests: list[dict] = []
_INTERESTS_FILE = Path(__file__).parent / "interests.json"
def _load_interests() -> list[dict]:
global _interests
if _INTERESTS_FILE.exists():
_interests = json.loads(_INTERESTS_FILE.read_text())
return _interests
def _save_interests() -> None:
_INTERESTS_FILE.write_text(json.dumps(_interests, ensure_ascii=False, indent=2))
_load_interests()
# --- Models ---
class Interest(BaseModel):
name: str
keywords: list[str]
weight: float = 1.0
class GuideRequest(BaseModel):
lat: float
lon: float
query: str = ""
radius_m: int = 500
categories: Optional[list[str]] = None
model: str = "llama3.1:8b"
ollama_url: str = "http://localhost:11434"
# --- Endpoints ---
@app.get("/api/interests")
def list_interests():
return _interests
@app.post("/api/interests")
def add_interest(interest: Interest):
_interests.append(interest.model_dump())
_save_interests()
return {"ok": True, "interests": _interests}
@app.delete("/api/interests/{index}")
def delete_interest(index: int):
if 0 <= index < len(_interests):
removed = _interests.pop(index)
_save_interests()
return {"ok": True, "removed": removed}
return {"ok": False, "error": "index out of range"}
@app.post("/api/guide")
def guide(req: GuideRequest):
"""Pipeline principal: ubicación + POIs + intereses + LLM → guía."""
# 1. Reverse geocode
try:
location = nominatim_reverse_geocode(req.lat, req.lon)
except RuntimeError:
location = {"display_name": "", "street": "", "house_number": "",
"neighbourhood": "", "city": "", "state": "", "country": "",
"postcode": "", "lat": req.lat, "lon": req.lon,
"osm_type": "", "osm_id": 0}
# 2. POIs cercanos
try:
pois = overpass_nearby_pois(
req.lat, req.lon,
radius_m=req.radius_m,
categories=req.categories,
)
except RuntimeError:
pois = []
# 3. Filtrar por intereses
if _interests:
matched = match_pois_to_interests(pois, _interests, max_results=10)
else:
# Sin intereses, devolver todos con score 0
matched = [
{**p, "score": 0, "matched_interests": []}
for p in pois[:10]
]
# 4. Construir prompt
messages = build_guide_prompt(
location=location,
pois=matched,
interests=_interests,
user_query=req.query,
)
# 5. LLM
try:
llm_resp = ollama_chat(
messages=messages,
model=req.model,
base_url=req.ollama_url,
)
guide_text = llm_resp["content"]
except RuntimeError as e:
guide_text = _fallback_guide(location, matched, req.query)
return {
"guide": guide_text,
"location": location,
"pois": matched,
"interests": _interests,
}
def _fallback_guide(location: dict, pois: list[dict], query: str) -> str:
"""Respuesta sin LLM cuando Ollama no está disponible."""
city = location.get("city", "tu zona")
street = location.get("street", "")
where = f"{street}, {city}" if street else city
if not pois:
return f"Estás en {where}. No encontré lugares destacados cerca con tus intereses."
names = ", ".join(p["name"] for p in pois[:3])
return f"Estás en {where}. Cerca tienes: {names}."
@app.get("/api/health")
def health():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8787)
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
+12
View File
@@ -0,0 +1,12 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.fnregistry.voiceguide',
appName: 'Voice Guide',
webDir: 'dist',
server: {
androidScheme: 'https',
},
};
export default config;
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voice Guide</title>
<meta name="theme-color" content="#12b886" />
<link rel="manifest" href="/manifest.json" />
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {})
}
</script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "voice-guide",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc -b && vite build",
"preview": "vite preview --host"
},
"dependencies": {
"@capacitor/android": "^8.3.0",
"@capacitor/cli": "^8.3.0",
"@capacitor/core": "^8.3.0",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^9.0.0",
"@mantine/hooks": "^9.0.0",
"@mantine/notifications": "^9.0.0",
"@tabler/icons-react": "^3.41.1",
"leaflet": "^1.9.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-leaflet": "^5.0.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.14",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"postcss": "^8.5.8",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
}
+2136
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};
+27
View File
@@ -0,0 +1,27 @@
{
"name": "Voice Guide",
"short_name": "VoiceGuide",
"description": "Guía por voz con POIs según tus intereses",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#12b886",
"background_color": "#1a1b1e",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": [],
"lang": "es"
}
+86
View File
@@ -0,0 +1,86 @@
// Service Worker — generado automaticamente
// Estrategia: cache-first para assets, network-first para API
const CACHE_NAME = 'voice-guide-v1';
const PRECACHE_URLS = [
'/',
'/index.html',
'/manifest.json',
];
const NETWORK_FIRST_PATTERNS = [
'/api/',
];
// ── install ──────────────────────────────────────────────────────────────────
// Pre-cachea los assets estaticos esenciales al instalar el SW.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(PRECACHE_URLS);
})
);
self.skipWaiting();
});
// ── activate ─────────────────────────────────────────────────────────────────
// Limpia caches de versiones anteriores al activar la nueva version del SW.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// ── fetch ─────────────────────────────────────────────────────────────────────
// Intercepta peticiones:
// - URLs que coinciden con NETWORK_FIRST_PATTERNS → network-first con fallback a cache
// - El resto → cache-first con fallback a network (y guarda en cache la respuesta)
self.addEventListener('fetch', (event) => {
const url = event.request.url;
const isNetworkFirst = url.includes('/api/');
if (isNetworkFirst) {
// Network-first: intenta red, si falla usa cache
event.respondWith(
fetch(event.request)
.then((response) => {
if (response && response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => caches.match(event.request))
);
} else {
// Cache-first: sirve desde cache, si no existe va a red y guarda en cache
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
return cached;
}
return fetch(event.request).then((response) => {
if (response && response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
});
})
);
}
});
+226
View File
@@ -0,0 +1,226 @@
import { useState, useEffect, useCallback } from 'react'
import {
AppShell, Group, Text, Stack, Button, Loader, Box, Burger,
TextInput, Slider,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
import { IconCompass, IconSend } from '@tabler/icons-react'
import { MapView } from './components/MapView'
import { InterestsPanel } from './components/InterestsPanel'
import { GuidePanel } from './components/GuidePanel'
import { VoiceButton } from './components/VoiceButton'
import { useGeolocation } from './hooks/useGeolocation'
import { useSpeech } from './hooks/useSpeech'
import {
fetchGuide, fetchInterests, addInterest, deleteInterest,
type Interest, type GuideResponse,
} from './lib/api'
export function App() {
const [navOpened, { toggle: toggleNav }] = useDisclosure(true)
const geo = useGeolocation()
const speech = useSpeech()
const [interests, setInterests] = useState<Interest[]>([])
const [guideResponse, setGuideResponse] = useState<GuideResponse | null>(null)
const [loading, setLoading] = useState(false)
const [textQuery, setTextQuery] = useState('')
const [radius, setRadius] = useState(500)
// Load interests on mount
useEffect(() => {
fetchInterests().then(setInterests).catch(() => {})
}, [])
// When voice transcript arrives, auto-query
useEffect(() => {
if (speech.transcript && geo.lat != null && geo.lon != null) {
handleGuide(speech.transcript)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speech.transcript])
const handleGuide = useCallback(async (query = '') => {
if (geo.lat == null || geo.lon == null) {
notifications.show({ title: 'Sin ubicación', message: 'Esperando GPS...', color: 'yellow' })
return
}
setLoading(true)
try {
const resp = await fetchGuide({
lat: geo.lat,
lon: geo.lon,
query,
radius_m: radius,
})
setGuideResponse(resp)
// Auto-speak the guide
if (resp.guide) speech.speak(resp.guide)
} catch (err) {
notifications.show({
title: 'Error',
message: err instanceof Error ? err.message : 'Error desconocido',
color: 'red',
})
} finally {
setLoading(false)
}
}, [geo.lat, geo.lon, radius, speech])
const handleAddInterest = useCallback(async (interest: Interest) => {
try {
await addInterest(interest)
const updated = await fetchInterests()
setInterests(updated)
} catch {
notifications.show({ title: 'Error', message: 'No se pudo añadir interés', color: 'red' })
}
}, [])
const handleRemoveInterest = useCallback(async (index: number) => {
try {
await deleteInterest(index)
const updated = await fetchInterests()
setInterests(updated)
} catch {
notifications.show({ title: 'Error', message: 'No se pudo eliminar interés', color: 'red' })
}
}, [])
const handleTextSubmit = useCallback(() => {
if (textQuery.trim()) {
handleGuide(textQuery.trim())
setTextQuery('')
}
}, [textQuery, handleGuide])
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 320, breakpoint: 'sm', collapsed: { mobile: !navOpened, desktop: !navOpened } }}
padding={0}
>
{/* Header */}
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group gap="sm">
<Burger opened={navOpened} onClick={toggleNav} size="sm" />
<IconCompass size={24} />
<Text fw={700} size="lg">Voice Guide</Text>
</Group>
<Group gap="sm">
<VoiceButton
listening={speech.listening}
supported={speech.supported}
onStart={speech.startListening}
onStop={speech.stopListening}
transcript={speech.transcript}
/>
{loading && <Loader size="sm" color="teal" />}
</Group>
</Group>
</AppShell.Header>
{/* Sidebar: interests + settings */}
<AppShell.Navbar p="md">
<AppShell.Section grow style={{ overflow: 'auto' }}>
<Stack gap="md">
<InterestsPanel
interests={interests}
onAdd={handleAddInterest}
onRemove={handleRemoveInterest}
/>
{/* Radius slider */}
<Stack gap={4}>
<Text size="xs" fw={500}>Radio de búsqueda: {radius}m</Text>
<Slider
value={radius}
onChange={setRadius}
min={100}
max={2000}
step={100}
marks={[
{ value: 100, label: '100m' },
{ value: 1000, label: '1km' },
{ value: 2000, label: '2km' },
]}
color="teal"
/>
</Stack>
{/* Explore button */}
<Button
fullWidth
color="teal"
onClick={() => handleGuide()}
loading={loading}
disabled={geo.lat == null}
>
Explorar mi entorno
</Button>
</Stack>
</AppShell.Section>
</AppShell.Navbar>
{/* Main content */}
<AppShell.Main>
<Box style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 60px)' }}>
{/* Map */}
<Box style={{ flex: 1, position: 'relative' }}>
{geo.lat != null && geo.lon != null ? (
<MapView
lat={geo.lat}
lon={geo.lon}
pois={guideResponse?.pois ?? []}
/>
) : (
<Stack align="center" justify="center" h="100%">
<Loader color="teal" />
<Text size="sm" c="dimmed">
{geo.error ?? 'Obteniendo ubicación...'}
</Text>
</Stack>
)}
</Box>
{/* Bottom panel: guide + text input */}
<Box p="md" style={{ maxHeight: '40vh', overflow: 'auto' }}>
<Stack gap="sm">
<GuidePanel
response={guideResponse}
loading={loading}
onSpeak={speech.speak}
speaking={speech.speaking}
onStopSpeaking={speech.stopSpeaking}
/>
{/* Text input fallback */}
<Group gap="xs">
<TextInput
placeholder="Escribe tu pregunta..."
size="sm"
style={{ flex: 1 }}
value={textQuery}
onChange={e => setTextQuery(e.currentTarget.value)}
onKeyDown={e => e.key === 'Enter' && handleTextSubmit()}
/>
<Button
size="sm"
color="teal"
variant="light"
onClick={handleTextSubmit}
disabled={!textQuery.trim() || loading}
>
<IconSend size={16} />
</Button>
</Group>
</Stack>
</Box>
</Box>
</AppShell.Main>
</AppShell>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { Paper, Text, Stack, Group, Badge, ScrollArea } from '@mantine/core'
import { IconMapPin, IconVolume } from '@tabler/icons-react'
import { ActionIcon } from '@mantine/core'
import type { GuideResponse } from '../lib/api'
interface GuidePanelProps {
response: GuideResponse | null
loading: boolean
onSpeak: (text: string) => void
speaking: boolean
onStopSpeaking: () => void
}
export function GuidePanel({ response, loading, onSpeak, speaking, onStopSpeaking }: GuidePanelProps) {
if (loading) {
return (
<Paper withBorder p="md" radius="md">
<Text size="sm" c="dimmed">Consultando tu entorno...</Text>
</Paper>
)
}
if (!response) {
return (
<Paper withBorder p="md" radius="md">
<Text size="sm" c="dimmed">
Pulsa el micrófono o el botón de explorar para recibir información sobre tu entorno.
</Text>
</Paper>
)
}
const { guide, location, pois } = response
return (
<Stack gap="sm">
{/* Location header */}
<Paper withBorder p="sm" radius="md">
<Group gap="xs" wrap="nowrap">
<IconMapPin size={16} style={{ flexShrink: 0 }} />
<Text size="xs" lineClamp={2}>
{location.street ? `${location.street}, ` : ''}
{location.neighbourhood ? `${location.neighbourhood}, ` : ''}
{location.city}
</Text>
</Group>
</Paper>
{/* Guide text */}
<Paper withBorder p="md" radius="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">Guía</Text>
<ActionIcon
variant={speaking ? 'filled' : 'subtle'}
color="teal"
size="sm"
onClick={() => speaking ? onStopSpeaking() : onSpeak(guide)}
>
<IconVolume size={14} />
</ActionIcon>
</Group>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{guide}</Text>
</Paper>
{/* POIs */}
{pois.length > 0 && (
<Paper withBorder p="sm" radius="md">
<Text fw={600} size="sm" mb="xs">Lugares cercanos</Text>
<ScrollArea.Autosize mah={200}>
<Stack gap={4}>
{pois.map(poi => (
<Group key={poi.id} gap="xs" wrap="nowrap">
<Text size="xs" fw={500} style={{ flex: 1 }} lineClamp={1}>
{poi.name}
</Text>
<Badge size="xs" variant="light">{poi.category}</Badge>
{(poi.score ?? 0) > 0 && (
<Badge size="xs" variant="dot" color="teal">
{poi.score?.toFixed(1)}
</Badge>
)}
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Paper>
)}
</Stack>
)
}
@@ -0,0 +1,93 @@
import { useState } from 'react'
import { Stack, Group, Text, TextInput, NumberInput, ActionIcon, Badge, Paper, TagsInput } from '@mantine/core'
import { IconPlus, IconTrash } from '@tabler/icons-react'
import type { Interest } from '../lib/api'
interface InterestsPanelProps {
interests: Interest[]
onAdd: (interest: Interest) => void
onRemove: (index: number) => void
}
export function InterestsPanel({ interests, onAdd, onRemove }: InterestsPanelProps) {
const [name, setName] = useState('')
const [keywords, setKeywords] = useState<string[]>([])
const [weight, setWeight] = useState<number>(1.0)
const handleAdd = () => {
if (!name.trim() || keywords.length === 0) return
onAdd({ name: name.trim(), keywords, weight })
setName('')
setKeywords([])
setWeight(1.0)
}
return (
<Stack gap="sm">
<Text fw={600} size="sm">Mis intereses</Text>
{interests.map((interest, i) => (
<Paper key={i} withBorder p="xs" radius="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="wrap">
<Text size="sm" fw={500}>{interest.name}</Text>
{interest.keywords.map(kw => (
<Badge key={kw} size="xs" variant="light">{kw}</Badge>
))}
<Badge size="xs" variant="outline" color="gray">
peso: {interest.weight}
</Badge>
</Group>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onRemove(i)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Paper>
))}
<Paper withBorder p="sm" radius="sm" bg="var(--mantine-color-dark-7)">
<Stack gap="xs">
<TextInput
placeholder="Nombre (ej: arte, gastronomía)"
size="xs"
value={name}
onChange={e => setName(e.currentTarget.value)}
/>
<TagsInput
placeholder="Keywords (Enter para añadir)"
size="xs"
value={keywords}
onChange={setKeywords}
/>
<Group gap="xs">
<NumberInput
placeholder="Peso"
size="xs"
value={weight}
onChange={v => setWeight(typeof v === 'number' ? v : 1.0)}
min={0.1}
max={2.0}
step={0.1}
decimalScale={1}
style={{ flex: 1 }}
/>
<ActionIcon
variant="filled"
color="teal"
size="lg"
onClick={handleAdd}
disabled={!name.trim() || keywords.length === 0}
>
<IconPlus size={16} />
</ActionIcon>
</Group>
</Stack>
</Paper>
</Stack>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef } from 'react'
import { Box } from '@mantine/core'
import L from 'leaflet'
import type { POI } from '../lib/api'
// Fix default marker icons in bundled environments
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
const poiIcon = new L.Icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
className: 'poi-marker',
})
interface MapViewProps {
lat: number
lon: number
pois: POI[]
}
export function MapView({ lat, lon, pois }: MapViewProps) {
const mapRef = useRef<L.Map | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const markersRef = useRef<L.LayerGroup | null>(null)
const userMarkerRef = useRef<L.CircleMarker | null>(null)
// Init map
useEffect(() => {
if (!containerRef.current || mapRef.current) return
const map = L.map(containerRef.current).setView([lat, lon], 16)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map)
markersRef.current = L.layerGroup().addTo(map)
userMarkerRef.current = L.circleMarker([lat, lon], {
radius: 10,
fillColor: '#12b886',
color: '#fff',
weight: 2,
fillOpacity: 0.9,
}).addTo(map).bindPopup('Tu ubicación')
mapRef.current = map
return () => {
map.remove()
mapRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Update user position
useEffect(() => {
if (!mapRef.current || !userMarkerRef.current) return
userMarkerRef.current.setLatLng([lat, lon])
mapRef.current.setView([lat, lon], mapRef.current.getZoom())
}, [lat, lon])
// Update POI markers
useEffect(() => {
if (!markersRef.current) return
markersRef.current.clearLayers()
pois.forEach(poi => {
const interests = poi.matched_interests?.join(', ') ?? ''
const popup = `<strong>${poi.name}</strong><br/>${poi.category}${interests ? `<br/><em>${interests}</em>` : ''}`
L.marker([poi.lat, poi.lon], { icon: poiIcon })
.bindPopup(popup)
.addTo(markersRef.current!)
})
}, [pois])
return (
<Box
ref={containerRef}
style={{ height: '100%', width: '100%', minHeight: 300, borderRadius: 8 }}
/>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { ActionIcon, Tooltip, Text, Stack } from '@mantine/core'
import { IconMicrophone, IconPlayerStop } from '@tabler/icons-react'
interface VoiceButtonProps {
listening: boolean
supported: boolean
onStart: () => void
onStop: () => void
transcript: string
}
export function VoiceButton({ listening, supported, onStart, onStop, transcript }: VoiceButtonProps) {
if (!supported) {
return (
<Tooltip label="Tu navegador no soporta reconocimiento de voz">
<ActionIcon variant="light" color="gray" size="xl" radius="xl" disabled>
<IconMicrophone size={24} />
</ActionIcon>
</Tooltip>
)
}
return (
<Stack align="center" gap={4}>
<Tooltip label={listening ? 'Escuchando... pulsa para parar' : 'Pulsa para hablar'}>
<ActionIcon
variant={listening ? 'filled' : 'light'}
color={listening ? 'red' : 'teal'}
size="xl"
radius="xl"
onClick={listening ? onStop : onStart}
>
{listening ? <IconPlayerStop size={24} /> : <IconMicrophone size={24} />}
</ActionIcon>
</Tooltip>
{transcript && (
<Text size="xs" c="dimmed" maw={200} ta="center" lineClamp={2}>
"{transcript}"
</Text>
)}
</Stack>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useState, useEffect, useCallback } from 'react'
interface GeoState {
lat: number | null
lon: number | null
accuracy: number | null
error: string | null
loading: boolean
}
export function useGeolocation(watch = true) {
const [state, setState] = useState<GeoState>({
lat: null,
lon: null,
accuracy: null,
error: null,
loading: true,
})
const refresh = useCallback(() => {
if (!navigator.geolocation) {
setState(s => ({ ...s, error: 'Geolocalización no soportada', loading: false }))
return
}
setState(s => ({ ...s, loading: true }))
navigator.geolocation.getCurrentPosition(
pos => {
setState({
lat: pos.coords.latitude,
lon: pos.coords.longitude,
accuracy: pos.coords.accuracy,
error: null,
loading: false,
})
},
err => {
setState(s => ({ ...s, error: err.message, loading: false }))
},
{ enableHighAccuracy: true, timeout: 10000 },
)
}, [])
useEffect(() => {
if (!navigator.geolocation) {
setState(s => ({ ...s, error: 'Geolocalización no soportada', loading: false }))
return
}
if (!watch) {
refresh()
return
}
const id = navigator.geolocation.watchPosition(
pos => {
setState({
lat: pos.coords.latitude,
lon: pos.coords.longitude,
accuracy: pos.coords.accuracy,
error: null,
loading: false,
})
},
err => {
setState(s => ({ ...s, error: err.message, loading: false }))
},
{ enableHighAccuracy: true, timeout: 10000 },
)
return () => navigator.geolocation.clearWatch(id)
}, [watch, refresh])
return { ...state, refresh }
}
+73
View File
@@ -0,0 +1,73 @@
import { useState, useCallback, useRef } from 'react'
interface SpeechState {
listening: boolean
transcript: string
speaking: boolean
supported: boolean
}
export function useSpeech(lang = 'es-ES') {
const [state, setState] = useState<SpeechState>({
listening: false,
transcript: '',
speaking: false,
supported: typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window),
})
const recognitionRef = useRef<SpeechRecognition | null>(null)
const startListening = useCallback(() => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognition) return
const recognition = new SpeechRecognition()
recognition.lang = lang
recognition.interimResults = false
recognition.maxAlternatives = 1
recognition.onresult = (event: SpeechRecognitionEvent) => {
const transcript = event.results[0]?.[0]?.transcript ?? ''
setState(s => ({ ...s, transcript, listening: false }))
}
recognition.onerror = () => {
setState(s => ({ ...s, listening: false }))
}
recognition.onend = () => {
setState(s => ({ ...s, listening: false }))
}
recognitionRef.current = recognition
setState(s => ({ ...s, listening: true, transcript: '' }))
recognition.start()
}, [lang])
const stopListening = useCallback(() => {
recognitionRef.current?.stop()
setState(s => ({ ...s, listening: false }))
}, [])
const speak = useCallback((text: string) => {
if (!window.speechSynthesis) return
window.speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang
utterance.rate = 1.0
utterance.onstart = () => setState(s => ({ ...s, speaking: true }))
utterance.onend = () => setState(s => ({ ...s, speaking: false }))
utterance.onerror = () => setState(s => ({ ...s, speaking: false }))
window.speechSynthesis.speak(utterance)
}, [lang])
const stopSpeaking = useCallback(() => {
window.speechSynthesis?.cancel()
setState(s => ({ ...s, speaking: false }))
}, [])
return { ...state, startListening, stopListening, speak, stopSpeaking }
}
+110
View File
@@ -0,0 +1,110 @@
import { Capacitor, registerPlugin } from '@capacitor/core'
export interface Interest {
name: string
keywords: string[]
weight: number
}
export interface POI {
id: number
lat: number
lon: number
name: string
category: string
tags: Record<string, string>
score?: number
matched_interests?: string[]
}
export interface Location {
display_name: string
street: string
house_number: string
neighbourhood: string
city: string
state: string
country: string
postcode: string
lat: number
lon: number
}
export interface GuideResponse {
guide: string
location: Location
pois: POI[]
interests: Interest[]
}
// ── Native plugin (Android) ─────────────────────────────────────
interface VoiceGuidePlugin {
getInterests(): Promise<{ interests: Interest[] }>
addInterest(opts: Interest): Promise<{ ok: boolean; interests: Interest[] }>
removeInterest(opts: { index: number }): Promise<{ ok: boolean; interests: Interest[] }>
guide(opts: {
lat: number
lon: number
query?: string
radius_m?: number
model?: string
ollama_url?: string
}): Promise<GuideResponse>
}
const isNative = Capacitor.isNativePlatform()
const NativePlugin = isNative
? registerPlugin<VoiceGuidePlugin>('VoiceGuide')
: null
// ── Public API (auto-selects native or HTTP) ────────────────────
export async function fetchGuide(params: {
lat: number
lon: number
query?: string
radius_m?: number
}): Promise<GuideResponse> {
if (NativePlugin) {
return NativePlugin.guide(params)
}
const res = await fetch('/api/guide', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
})
if (!res.ok) throw new Error(`Guide API error: ${res.status}`)
return res.json()
}
export async function fetchInterests(): Promise<Interest[]> {
if (NativePlugin) {
const r = await NativePlugin.getInterests()
return r.interests
}
const res = await fetch('/api/interests')
if (!res.ok) throw new Error(`Interests API error: ${res.status}`)
return res.json()
}
export async function addInterest(interest: Interest): Promise<void> {
if (NativePlugin) {
await NativePlugin.addInterest(interest)
return
}
const res = await fetch('/api/interests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(interest),
})
if (!res.ok) throw new Error(`Add interest error: ${res.status}`)
}
export async function deleteInterest(index: number): Promise<void> {
if (NativePlugin) {
await NativePlugin.removeInterest({ index })
return
}
const res = await fetch(`/api/interests/${index}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`Delete interest error: ${res.status}`)
}
+22
View File
@@ -0,0 +1,22 @@
import '@mantine/core/styles.css'
import '@mantine/notifications/styles.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { MantineProvider, createTheme } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import { App } from './App'
const theme = createTheme({
primaryColor: 'teal',
fontFamily: 'system-ui, -apple-system, sans-serif',
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<MantineProvider theme={theme} defaultColorScheme="dark">
<Notifications position="top-right" />
<App />
</MantineProvider>
</StrictMode>,
)
+42
View File
@@ -0,0 +1,42 @@
/// <reference types="vite/client" />
// Web Speech API types (not in all TS libs)
interface SpeechRecognitionEvent extends Event {
readonly results: SpeechRecognitionResultList
readonly resultIndex: number
}
interface SpeechRecognitionResultList {
readonly length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult | undefined
}
interface SpeechRecognitionResult {
readonly length: number
readonly isFinal: boolean
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative | undefined
}
interface SpeechRecognitionAlternative {
readonly transcript: string
readonly confidence: number
}
declare class SpeechRecognition extends EventTarget {
lang: string
interimResults: boolean
maxAlternatives: number
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: Event) => void) | null
onend: (() => void) | null
start(): void
stop(): void
abort(): void
}
interface Window {
SpeechRecognition: typeof SpeechRecognition
webkitSpeechRecognition: typeof SpeechRecognition
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": ["./src/*"],
"@fn_library/*": ["../../../frontend/functions/ui/*"]
}
},
"include": ["src"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@fn_library': resolve(__dirname, '../../../frontend/functions/ui'),
},
dedupe: ['react', 'react-dom', '@mantine/core', '@mantine/hooks', '@mantine/notifications', '@mantine/charts'],
},
css: {
postcss: resolve(__dirname, './postcss.config.cjs'),
},
server: {
port: 5188,
proxy: {
'/api': {
target: 'http://localhost:8787',
changeOrigin: true,
},
},
},
})
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Voice Guide — start backend + frontend
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
REGISTRY_ROOT="$(cd "$ROOT/../.." && pwd)"
echo "=== Voice Guide ==="
echo "Backend: http://localhost:8787"
echo "Frontend: http://localhost:5188"
echo ""
# Backend
echo "[1/2] Starting backend..."
cd "$ROOT/backend"
if [ ! -d ".venv" ]; then
uv venv .venv
uv pip install -r requirements.txt
fi
.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8787 --reload &
BACKEND_PID=$!
# Frontend
echo "[2/2] Starting frontend..."
cd "$ROOT/frontend"
if [ ! -d "node_modules" ]; then
pnpm install
fi
pnpm dev &
FRONTEND_PID=$!
echo ""
echo "Backend PID: $BACKEND_PID"
echo "Frontend PID: $FRONTEND_PID"
echo "Press Ctrl+C to stop both."
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
wait