Add routecheck service and CommuteTool fast tool
routecheck/ — FastAPI service (port 8090): - Image captcha (PIL: arithmetic problem, noise, wave distortion) - POST /api/captcha/new + /api/captcha/solve → short-lived token - GET /api/route?from=lat,lon&to=lat,lon&token=... → Yandex Routing API - Internal bypass via INTERNAL_TOKEN env var (for CommuteTool) - HTTPS proxy forwarded to reach Yandex API from container CommuteTool (fast_tools.py): - Matches commute/traffic/arrival time queries - Calls routecheck /api/route with ROUTECHECK_TOKEN - Hardcoded route: Balashikha home → Moscow center - Returns traffic-adjusted travel time + delay annotation Needs: YANDEX_ROUTING_KEY + ROUTECHECK_TOKEN in .env Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,6 @@ WORKDIR /app
|
|||||||
RUN pip install --no-cache-dir deepagents langchain-openai langgraph \
|
RUN pip install --no-cache-dir deepagents langchain-openai langgraph \
|
||||||
fastapi uvicorn langchain-mcp-adapters langchain-community httpx
|
fastapi uvicorn langchain-mcp-adapters langchain-community httpx
|
||||||
|
|
||||||
COPY agent.py channels.py vram_manager.py router.py agent_factory.py fast_tools.py hello_world.py .
|
COPY agent.py channels.py vram_manager.py router.py agent_factory.py fast_tools.py hello_world.py ./
|
||||||
|
|
||||||
CMD ["uvicorn", "agent:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "agent:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
5
agent.py
5
agent.py
@@ -24,7 +24,7 @@ from langchain_core.tools import Tool
|
|||||||
from vram_manager import VRAMManager
|
from vram_manager import VRAMManager
|
||||||
from router import Router
|
from router import Router
|
||||||
from agent_factory import build_medium_agent, build_complex_agent
|
from agent_factory import build_medium_agent, build_complex_agent
|
||||||
from fast_tools import FastToolRunner, WeatherTool
|
from fast_tools import FastToolRunner, WeatherTool, CommuteTool
|
||||||
import channels
|
import channels
|
||||||
|
|
||||||
# Bifrost gateway — all LLM inference goes through here
|
# Bifrost gateway — all LLM inference goes through here
|
||||||
@@ -38,6 +38,8 @@ COMPLEX_MODEL = os.getenv("DEEPAGENTS_COMPLEX_MODEL", "qwen3:8b")
|
|||||||
SEARXNG_URL = os.getenv("SEARXNG_URL", "http://host.docker.internal:11437")
|
SEARXNG_URL = os.getenv("SEARXNG_URL", "http://host.docker.internal:11437")
|
||||||
OPENMEMORY_URL = os.getenv("OPENMEMORY_URL", "http://openmemory:8765")
|
OPENMEMORY_URL = os.getenv("OPENMEMORY_URL", "http://openmemory:8765")
|
||||||
CRAWL4AI_URL = os.getenv("CRAWL4AI_URL", "http://crawl4ai:11235")
|
CRAWL4AI_URL = os.getenv("CRAWL4AI_URL", "http://crawl4ai:11235")
|
||||||
|
ROUTECHECK_URL = os.getenv("ROUTECHECK_URL", "http://routecheck:8090")
|
||||||
|
ROUTECHECK_TOKEN = os.getenv("ROUTECHECK_TOKEN", "")
|
||||||
|
|
||||||
MAX_HISTORY_TURNS = 5
|
MAX_HISTORY_TURNS = 5
|
||||||
_conversation_buffers: dict[str, list] = {}
|
_conversation_buffers: dict[str, list] = {}
|
||||||
@@ -122,6 +124,7 @@ _memory_search_tool = None
|
|||||||
# Fast tools run before the LLM — classifier + context enricher
|
# Fast tools run before the LLM — classifier + context enricher
|
||||||
_fast_tool_runner = FastToolRunner([
|
_fast_tool_runner = FastToolRunner([
|
||||||
WeatherTool(searxng_url=SEARXNG_URL),
|
WeatherTool(searxng_url=SEARXNG_URL),
|
||||||
|
CommuteTool(routecheck_url=ROUTECHECK_URL, internal_token=ROUTECHECK_TOKEN),
|
||||||
])
|
])
|
||||||
|
|
||||||
# GPU mutex: one LLM inference at a time
|
# GPU mutex: one LLM inference at a time
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ services:
|
|||||||
- SEARXNG_URL=http://host.docker.internal:11437
|
- SEARXNG_URL=http://host.docker.internal:11437
|
||||||
- GRAMMY_URL=http://grammy:3001
|
- GRAMMY_URL=http://grammy:3001
|
||||||
- CRAWL4AI_URL=http://crawl4ai:11235
|
- CRAWL4AI_URL=http://crawl4ai:11235
|
||||||
|
- ROUTECHECK_URL=http://routecheck:8090
|
||||||
|
- ROUTECHECK_TOKEN=${ROUTECHECK_TOKEN}
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -38,6 +40,7 @@ services:
|
|||||||
- grammy
|
- grammy
|
||||||
- crawl4ai
|
- crawl4ai
|
||||||
- bifrost
|
- bifrost
|
||||||
|
- routecheck
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
openmemory:
|
openmemory:
|
||||||
@@ -79,6 +82,19 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- tools
|
- tools
|
||||||
|
|
||||||
|
routecheck:
|
||||||
|
build: ./routecheck
|
||||||
|
container_name: routecheck
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
environment:
|
||||||
|
- YANDEX_ROUTING_KEY=${YANDEX_ROUTING_KEY}
|
||||||
|
- INTERNAL_TOKEN=${ROUTECHECK_TOKEN}
|
||||||
|
- HTTPS_PROXY=http://host.docker.internal:56928
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
crawl4ai:
|
crawl4ai:
|
||||||
image: unclecode/crawl4ai:latest
|
image: unclecode/crawl4ai:latest
|
||||||
container_name: crawl4ai
|
container_name: crawl4ai
|
||||||
|
|||||||
@@ -92,6 +92,68 @@ class WeatherTool(FastTool):
|
|||||||
return "\n".join(lines) if len(lines) > 1 else ""
|
return "\n".join(lines) if len(lines) > 1 else ""
|
||||||
|
|
||||||
|
|
||||||
|
class CommuteTool(FastTool):
|
||||||
|
"""
|
||||||
|
Returns real-time driving time from home (Balashikha) to a destination
|
||||||
|
using Yandex traffic data via the local routecheck service.
|
||||||
|
|
||||||
|
Triggered by queries about commute time, arrival, or road traffic.
|
||||||
|
The routecheck service handles Yandex API auth and the HTTPS proxy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_PATTERN = re.compile(
|
||||||
|
r"\b(commute|arrival time|how long.{0,20}(drive|get|travel|reach)"
|
||||||
|
r"|сколько.{0,20}(ехать|добираться|минут)"
|
||||||
|
r"|пробки|traffic|road.{0,10}now|drive to (work|office|center|москва|moscow)"
|
||||||
|
r"|when (will i|do i) (arrive|get there|reach))\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Home: Balashikha. Default destination: Moscow city center.
|
||||||
|
_HOME = "55.7963,37.9382"
|
||||||
|
_DEST = "55.7558,37.6173"
|
||||||
|
|
||||||
|
def __init__(self, routecheck_url: str, internal_token: str):
|
||||||
|
self._url = routecheck_url.rstrip("/")
|
||||||
|
self._token = internal_token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "commute"
|
||||||
|
|
||||||
|
def matches(self, message: str) -> bool:
|
||||||
|
return bool(self._PATTERN.search(message))
|
||||||
|
|
||||||
|
async def run(self, message: str) -> str:
|
||||||
|
if not self._token:
|
||||||
|
return "[commute: ROUTECHECK_TOKEN not configured]"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.get(
|
||||||
|
f"{self._url}/api/route",
|
||||||
|
params={"from": self._HOME, "to": self._DEST, "token": self._token},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
d = r.json()
|
||||||
|
except Exception as e:
|
||||||
|
return f"[commute error: {e}]"
|
||||||
|
|
||||||
|
traffic = d["duration_traffic_min"]
|
||||||
|
normal = d["duration_min"]
|
||||||
|
dist = d["distance_km"]
|
||||||
|
delay = traffic - normal
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Current drive time from Balashikha to Moscow center:",
|
||||||
|
f" With traffic: {traffic} min",
|
||||||
|
f" Without traffic: {normal} min",
|
||||||
|
f" Distance: {dist} km",
|
||||||
|
]
|
||||||
|
if delay > 5:
|
||||||
|
lines.append(f" Traffic delay: +{delay} min")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
class FastToolRunner:
|
class FastToolRunner:
|
||||||
"""
|
"""
|
||||||
Classifier + executor for fast tools.
|
Classifier + executor for fast tools.
|
||||||
|
|||||||
6
routecheck/Dockerfile
Normal file
6
routecheck/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN pip install --no-cache-dir fastapi uvicorn pillow httpx
|
||||||
|
COPY app.py .
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||||
377
routecheck/app.py
Normal file
377
routecheck/app.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"""
|
||||||
|
RouteCheck — local routing web service with image captcha.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET / — web UI
|
||||||
|
GET /captcha/image/{id} — PNG captcha image
|
||||||
|
POST /api/captcha/new — create captcha, return {id}
|
||||||
|
POST /api/captcha/solve — {id, answer} → {token} or 400
|
||||||
|
GET /api/route — ?from=lat,lon&to=lat,lon&token=...
|
||||||
|
token = solved captcha token OR INTERNAL_TOKEN env var
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI(title="RouteCheck")
|
||||||
|
|
||||||
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
||||||
|
YANDEX_KEY = os.getenv("YANDEX_ROUTING_KEY", "")
|
||||||
|
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
|
||||||
|
HTTPS_PROXY = os.getenv("HTTPS_PROXY", "")
|
||||||
|
CAPTCHA_TTL = 300 # seconds a captcha is valid
|
||||||
|
TOKEN_TTL = 3600 # seconds a solved token is valid
|
||||||
|
|
||||||
|
# ── In-memory captcha store ────────────────────────────────────────────────────
|
||||||
|
_captchas: dict[str, dict] = {} # id → {answer, token, expires}
|
||||||
|
_tokens: dict[str, float] = {} # token → expires
|
||||||
|
|
||||||
|
|
||||||
|
def _purge():
|
||||||
|
now = time.time()
|
||||||
|
for k in list(_captchas.keys()):
|
||||||
|
if _captchas[k]["expires"] < now:
|
||||||
|
del _captchas[k]
|
||||||
|
for k in list(_tokens.keys()):
|
||||||
|
if _tokens[k] < now:
|
||||||
|
del _tokens[k]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Captcha image generation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _rand_color(dark=False):
|
||||||
|
if dark:
|
||||||
|
return tuple(random.randint(0, 100) for _ in range(3))
|
||||||
|
return tuple(random.randint(140, 255) for _ in range(3))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_captcha_image(text: str) -> bytes:
|
||||||
|
W, H = 220, 80
|
||||||
|
img = Image.new("RGB", (W, H), color=_rand_color())
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Background noise: random lines
|
||||||
|
for _ in range(8):
|
||||||
|
x1, y1 = random.randint(0, W), random.randint(0, H)
|
||||||
|
x2, y2 = random.randint(0, W), random.randint(0, H)
|
||||||
|
draw.line([(x1, y1), (x2, y2)], fill=_rand_color(dark=True), width=2)
|
||||||
|
|
||||||
|
# Background noise: random dots
|
||||||
|
for _ in range(300):
|
||||||
|
x, y = random.randint(0, W), random.randint(0, H)
|
||||||
|
draw.point((x, y), fill=_rand_color(dark=True))
|
||||||
|
|
||||||
|
# Draw each character with slight random offset and rotation
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
|
||||||
|
except Exception:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
char_w = W // (len(text) + 2)
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
x = char_w + i * char_w + random.randint(-4, 4)
|
||||||
|
y = (H - 40) // 2 + random.randint(-6, 6)
|
||||||
|
# Draw shadow
|
||||||
|
draw.text((x + 2, y + 2), ch, font=font, fill=_rand_color(dark=True))
|
||||||
|
draw.text((x, y), ch, font=font, fill=_rand_color(dark=True))
|
||||||
|
|
||||||
|
# Wavy distortion via pixel manipulation
|
||||||
|
pixels = img.load()
|
||||||
|
for x in range(W):
|
||||||
|
shift = int(4 * math.sin(x / 15.0))
|
||||||
|
col = [pixels[x, y] for y in range(H)]
|
||||||
|
for y in range(H):
|
||||||
|
pixels[x, y] = col[(y - shift) % H]
|
||||||
|
|
||||||
|
img = img.filter(ImageFilter.SMOOTH)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_problem() -> tuple[str, int]:
|
||||||
|
"""Return (display_text, answer)."""
|
||||||
|
ops = [
|
||||||
|
lambda a, b: (f"{a} + {b} = ?", a + b),
|
||||||
|
lambda a, b: (f"{a} × {b} = ?", a * b),
|
||||||
|
lambda a, b: (f"{max(a,b)} − {min(a,b)} = ?", max(a, b) - min(a, b)),
|
||||||
|
]
|
||||||
|
op = random.choice(ops)
|
||||||
|
a, b = random.randint(2, 9), random.randint(2, 9)
|
||||||
|
text, answer = op(a, b)
|
||||||
|
return text, answer
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index():
|
||||||
|
return HTML_PAGE
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/captcha/image/{captcha_id}")
|
||||||
|
async def captcha_image(captcha_id: str):
|
||||||
|
_purge()
|
||||||
|
entry = _captchas.get(captcha_id)
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(404, "Captcha not found or expired")
|
||||||
|
png = _make_captcha_image(entry["problem"])
|
||||||
|
return StreamingResponse(io.BytesIO(png), media_type="image/png",
|
||||||
|
headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
class CaptchaNewResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/captcha/new")
|
||||||
|
async def captcha_new():
|
||||||
|
_purge()
|
||||||
|
problem_text, answer = _generate_problem()
|
||||||
|
cid = str(uuid.uuid4())
|
||||||
|
_captchas[cid] = {
|
||||||
|
"problem": problem_text,
|
||||||
|
"answer": answer,
|
||||||
|
"expires": time.time() + CAPTCHA_TTL,
|
||||||
|
}
|
||||||
|
return {"id": cid}
|
||||||
|
|
||||||
|
|
||||||
|
class SolveRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
answer: int
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/captcha/solve")
|
||||||
|
async def captcha_solve(req: SolveRequest):
|
||||||
|
_purge()
|
||||||
|
entry = _captchas.get(req.id)
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(400, "Captcha expired or not found")
|
||||||
|
if entry["answer"] != req.answer:
|
||||||
|
raise HTTPException(400, "Wrong answer")
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
_tokens[token] = time.time() + TOKEN_TTL
|
||||||
|
del _captchas[req.id]
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/route")
|
||||||
|
async def route(
|
||||||
|
from_coords: str = Query(..., alias="from", description="lat,lon"),
|
||||||
|
to_coords: str = Query(..., alias="to", description="lat,lon"),
|
||||||
|
token: str = Query(...),
|
||||||
|
):
|
||||||
|
_purge()
|
||||||
|
|
||||||
|
# Auth: internal service token or valid captcha token
|
||||||
|
if token != INTERNAL_TOKEN:
|
||||||
|
if token not in _tokens:
|
||||||
|
raise HTTPException(401, "Invalid or expired token — solve captcha first")
|
||||||
|
|
||||||
|
if not YANDEX_KEY:
|
||||||
|
raise HTTPException(503, "YANDEX_ROUTING_KEY not configured")
|
||||||
|
|
||||||
|
# Parse coords
|
||||||
|
try:
|
||||||
|
from_lat, from_lon = map(float, from_coords.split(","))
|
||||||
|
to_lat, to_lon = map(float, to_coords.split(","))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "coords must be lat,lon")
|
||||||
|
|
||||||
|
# Yandex Routing API expects lon,lat order
|
||||||
|
waypoints = f"{from_lon},{from_lat}|{to_lon},{to_lat}"
|
||||||
|
|
||||||
|
transport = httpx.AsyncHTTPTransport(proxy=HTTPS_PROXY) if HTTPS_PROXY else None
|
||||||
|
async with httpx.AsyncClient(timeout=15, transport=transport) as client:
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
"https://api.routing.yandex.net/v2/route",
|
||||||
|
params={"apikey": YANDEX_KEY, "waypoints": waypoints, "mode": "driving"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Yandex API unreachable: {e}")
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise HTTPException(502, f"Yandex API error {r.status_code}: {r.text[:200]}")
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
try:
|
||||||
|
leg = data["route"]["legs"][0]
|
||||||
|
duration_s = leg["duration"]
|
||||||
|
duration_traffic_s = leg.get("duration_in_traffic", duration_s)
|
||||||
|
distance_m = leg["distance"]
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
raise HTTPException(502, f"Unexpected Yandex response: {e} — {str(data)[:200]}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"duration_min": round(duration_s / 60),
|
||||||
|
"duration_traffic_min": round(duration_traffic_s / 60),
|
||||||
|
"distance_km": round(distance_m / 1000, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HTML_PAGE = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>RouteCheck</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh;
|
||||||
|
display: flex; align-items: center; justify-content: center; }
|
||||||
|
.card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 420px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.5); }
|
||||||
|
h1 { font-size: 1.4rem; font-weight: 700; color: #38bdf8; margin-bottom: .3rem; }
|
||||||
|
.sub { color: #94a3b8; font-size: .85rem; margin-bottom: 1.5rem; }
|
||||||
|
label { display: block; font-size: .8rem; color: #94a3b8; margin-bottom: .3rem; margin-top: 1rem; }
|
||||||
|
input { width: 100%; background: #0f172a; border: 1px solid #334155; border-radius: 6px;
|
||||||
|
color: #e2e8f0; padding: .55rem .75rem; font-size: .95rem; outline: none; }
|
||||||
|
input:focus { border-color: #38bdf8; }
|
||||||
|
button { width: 100%; margin-top: 1.2rem; padding: .7rem; background: #0ea5e9;
|
||||||
|
border: none; border-radius: 6px; color: #fff; font-size: 1rem;
|
||||||
|
font-weight: 600; cursor: pointer; transition: background .2s; }
|
||||||
|
button:hover { background: #0284c7; }
|
||||||
|
button:disabled { background: #334155; cursor: default; }
|
||||||
|
.captcha-row { display: flex; gap: .75rem; align-items: center; margin-top: 1rem; }
|
||||||
|
.captcha-row img { border-radius: 6px; border: 1px solid #334155; cursor: pointer; }
|
||||||
|
.captcha-row input { flex: 1; }
|
||||||
|
.result { margin-top: 1.2rem; background: #0f172a; border-radius: 8px; padding: 1rem;
|
||||||
|
border-left: 3px solid #38bdf8; display: none; }
|
||||||
|
.result .big { font-size: 1.6rem; font-weight: 700; color: #38bdf8; }
|
||||||
|
.result .label { font-size: .8rem; color: #64748b; margin-top: .2rem; }
|
||||||
|
.result .row { display: flex; gap: 1.5rem; margin-top: .8rem; }
|
||||||
|
.result .metric { flex: 1; }
|
||||||
|
.result .metric .val { font-size: 1.1rem; font-weight: 600; }
|
||||||
|
.error { color: #f87171; margin-top: .8rem; font-size: .85rem; display: none; }
|
||||||
|
.step { display: none; }
|
||||||
|
.step.active { display: block; }
|
||||||
|
a.refresh { font-size: .75rem; color: #38bdf8; text-decoration: none; display: block;
|
||||||
|
margin-top: .4rem; }
|
||||||
|
a.refresh:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>RouteCheck</h1>
|
||||||
|
<p class="sub">Real-time driving time with Yandex traffic data</p>
|
||||||
|
|
||||||
|
<!-- Step 1: captcha -->
|
||||||
|
<div class="step active" id="step-captcha">
|
||||||
|
<label>Prove you are human</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<img id="captcha-img" src="" alt="captcha" width="160" height="60"
|
||||||
|
title="Click to refresh" onclick="loadCaptcha()">
|
||||||
|
<input id="captcha-ans" type="number" placeholder="Answer" min="0" max="999">
|
||||||
|
</div>
|
||||||
|
<a class="refresh" href="#" onclick="loadCaptcha();return false;">↻ New challenge</a>
|
||||||
|
<div class="error" id="captcha-err">Wrong answer, try again.</div>
|
||||||
|
<button id="captcha-btn" onclick="solveCaptcha()">Verify →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: route query -->
|
||||||
|
<div class="step" id="step-route">
|
||||||
|
<label>From (lat, lon)</label>
|
||||||
|
<input id="from" type="text" placeholder="55.7963, 37.9382" value="55.7963, 37.9382">
|
||||||
|
<label>To (lat, lon)</label>
|
||||||
|
<input id="to" type="text" placeholder="55.7558, 37.6173" value="55.7558, 37.6173">
|
||||||
|
<button id="route-btn" onclick="queryRoute()">Get travel time</button>
|
||||||
|
<div class="error" id="route-err"></div>
|
||||||
|
<div class="result" id="result">
|
||||||
|
<div class="big" id="res-traffic"></div>
|
||||||
|
<div class="label">with current traffic</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="metric"><div class="val" id="res-normal"></div>
|
||||||
|
<div class="label">without traffic</div></div>
|
||||||
|
<div class="metric"><div class="val" id="res-dist"></div>
|
||||||
|
<div class="label">distance</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let captchaId = null;
|
||||||
|
let routeToken = null;
|
||||||
|
|
||||||
|
async function loadCaptcha() {
|
||||||
|
const r = await fetch('/api/captcha/new', {method: 'POST'});
|
||||||
|
const d = await r.json();
|
||||||
|
captchaId = d.id;
|
||||||
|
document.getElementById('captcha-img').src = '/captcha/image/' + captchaId + '?t=' + Date.now();
|
||||||
|
document.getElementById('captcha-ans').value = '';
|
||||||
|
document.getElementById('captcha-err').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function solveCaptcha() {
|
||||||
|
const ans = parseInt(document.getElementById('captcha-ans').value);
|
||||||
|
if (isNaN(ans)) return;
|
||||||
|
const btn = document.getElementById('captcha-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
const r = await fetch('/api/captcha/solve', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({id: captchaId, answer: ans})
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
routeToken = d.token;
|
||||||
|
document.getElementById('step-captcha').classList.remove('active');
|
||||||
|
document.getElementById('step-route').classList.add('active');
|
||||||
|
} else {
|
||||||
|
document.getElementById('captcha-err').style.display = 'block';
|
||||||
|
loadCaptcha();
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryRoute() {
|
||||||
|
const from = document.getElementById('from').value.trim();
|
||||||
|
const to = document.getElementById('to').value.trim();
|
||||||
|
const btn = document.getElementById('route-btn');
|
||||||
|
const err = document.getElementById('route-err');
|
||||||
|
err.style.display = 'none';
|
||||||
|
document.getElementById('result').style.display = 'none';
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Fetching…';
|
||||||
|
const r = await fetch(`/api/route?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&token=${routeToken}`);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Get travel time';
|
||||||
|
if (!r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
err.textContent = d.detail || 'Error';
|
||||||
|
err.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = await r.json();
|
||||||
|
document.getElementById('res-traffic').textContent = d.duration_traffic_min + ' min';
|
||||||
|
document.getElementById('res-normal').textContent = d.duration_min + ' min';
|
||||||
|
document.getElementById('res-dist').textContent = d.distance_km + ' km';
|
||||||
|
document.getElementById('result').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCaptcha();
|
||||||
|
|
||||||
|
document.getElementById('captcha-ans').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') solveCaptcha();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
Reference in New Issue
Block a user