chore: initial sync
This commit is contained in:
+11
@@ -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
@@ -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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.34.0
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2136
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: '© 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 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
Vendored
+42
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user