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