style: add eslint and prettier
This commit is contained in:
120
src/ai.ts
120
src/ai.ts
@ -1,11 +1,9 @@
|
||||
import { Hono, type Context } from "hono";
|
||||
import { type Context } from "hono";
|
||||
import { streamText, tool } from "ai";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { stream } from "hono/streaming";
|
||||
import z from "zod";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
export type OverpassResult = {
|
||||
elements: OverpassElement[];
|
||||
};
|
||||
@ -25,11 +23,7 @@ export type OverpassElement = {
|
||||
|
||||
const OVERPASS_SERVER = "https://overpass-api.de/api/interpreter";
|
||||
|
||||
export async function fetchPOI(
|
||||
lat: number,
|
||||
lon: number,
|
||||
radius: number,
|
||||
) {
|
||||
export async function fetchPOI(lat: number, lon: number, radius: number) {
|
||||
return await fetch(OVERPASS_SERVER, {
|
||||
method: "POST",
|
||||
body: `[out:json];
|
||||
@ -45,54 +39,70 @@ export async function fetchPOI(
|
||||
node(around:${radius}, ${lat}, ${lon})["amenity"="parking"];
|
||||
way(around:${radius}, ${lat}, ${lon})["amenity"="parking"];
|
||||
);
|
||||
out center tags;`
|
||||
}).then(res => res.json() as Promise<OverpassResult>);
|
||||
out center tags;`,
|
||||
}).then((res) => res.json() as Promise<OverpassResult>);
|
||||
}
|
||||
|
||||
function getDistance(aLat: number, aLon: number, lat: number, lon: number): number {
|
||||
function getDistance(
|
||||
aLat: number,
|
||||
aLon: number,
|
||||
lat: number,
|
||||
lon: number,
|
||||
): number {
|
||||
const R = 6371e3; // Earth radius in meters
|
||||
const φ1 = lat * Math.PI / 180;
|
||||
const φ2 = aLat * Math.PI / 180;
|
||||
const Δφ = (aLat - lat) * Math.PI / 180;
|
||||
const Δλ = (aLon - lon) * Math.PI / 180;
|
||||
const φ1 = (lat * Math.PI) / 180;
|
||||
const φ2 = (aLat * Math.PI) / 180;
|
||||
const Δφ = ((aLat - lat) * Math.PI) / 180;
|
||||
const Δλ = ((aLon - lon) * Math.PI) / 180;
|
||||
|
||||
const a = Math.sin(Δφ/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin(Δλ/2)**2;
|
||||
const a =
|
||||
Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
|
||||
function sortByDistance(elements: OverpassElement[], lat: number, lng: number): OverpassElement[] {
|
||||
function sortByDistance(
|
||||
elements: OverpassElement[],
|
||||
lat: number,
|
||||
lng: number,
|
||||
): OverpassElement[] {
|
||||
return elements.sort((a: OverpassElement, b: OverpassElement) => {
|
||||
const aLoc = a.center || a;
|
||||
const bLoc = b.center || b;
|
||||
return getDistance(aLoc.lat!, aLoc.lon!, lat, lng) - getDistance(bLoc.lat!, bLoc.lon!, lat, lng);
|
||||
return (
|
||||
getDistance(aLoc.lat!, aLoc.lon!, lat, lng) -
|
||||
getDistance(bLoc.lat!, bLoc.lon!, lat, lng)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function post(c: Context) {
|
||||
|
||||
const body = await c.req.json();
|
||||
const text = body.text ? body.text.trim() : "";
|
||||
const coords = body.coords;
|
||||
let tags: Record<string, string> | undefined = undefined;
|
||||
if(coords && coords.lat && coords.lon) {
|
||||
if (coords && coords.lat && coords.lon) {
|
||||
// fetch tags from OpenStreetMap using Overpass API
|
||||
console.log("Fetching POI for coordinates:", coords.lat, coords.lon);
|
||||
const res = await fetchPOI(coords.lat, coords.lon, 100);
|
||||
const poi = sortByDistance(res.elements, coords.lat, coords.lon);
|
||||
if(poi.length > 0) {
|
||||
if (poi.length > 0) {
|
||||
tags = poi[0]?.tags ?? {}; // Use the first element's tags
|
||||
coords.lat = poi[0]?.lat ?? coords.lat; // Use the first element's lat if available
|
||||
coords.lon = poi[0]?.lon ?? coords.lon; // Use the first element's lon if available
|
||||
}
|
||||
}
|
||||
console.log("Received request with text:", text);
|
||||
const prompt = JSON.stringify({
|
||||
coords,
|
||||
tags,
|
||||
text
|
||||
}, null, 2)
|
||||
const prompt = JSON.stringify(
|
||||
{
|
||||
coords,
|
||||
tags,
|
||||
text,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
console.log("Generated prompt:", prompt);
|
||||
const result = streamText({
|
||||
onError: (error) => {
|
||||
@ -131,23 +141,29 @@ The local date and time is ${new Date().toLocaleString("de-DE", { timeZone: "Eur
|
||||
maxSteps: 5,
|
||||
tools: {
|
||||
overpass: tool({
|
||||
description: "Query OpenStreetMap data using Overpass API with the given Overpass QL query.",
|
||||
description:
|
||||
"Query OpenStreetMap data using Overpass API with the given Overpass QL query.",
|
||||
parameters: z.object({
|
||||
query: z.string().describe("The Overpass QL query to execute."),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
console.log("Executing Overpass API query:", query);
|
||||
const response = await fetch("https://overpass-api.de/api/interpreter", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: query,
|
||||
});
|
||||
const response = await fetch(
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: query,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Overpass API request failed: ${response.status} ${response.statusText}`);
|
||||
throw new Error(
|
||||
`Overpass API request failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const data = await response.text();
|
||||
return data;
|
||||
}
|
||||
},
|
||||
}),
|
||||
fetchWebsite: tool({
|
||||
description: "Fetch the raw HTML content of a website.",
|
||||
@ -161,34 +177,36 @@ The local date and time is ${new Date().toLocaleString("de-DE", { timeZone: "Eur
|
||||
"User-Agent": "Mozilla/5.0 (compatible; GeminiBot/1.0)",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch site: ${res.status} ${res.statusText}`);
|
||||
throw new Error(
|
||||
`Failed to fetch site: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
function stripHTML(html: string): string {
|
||||
// Remove script/style/head tags and their content
|
||||
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
|
||||
html = html.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, '');
|
||||
html = html.replace(/<head[\s\S]*?>[\s\S]*?<\/head>/gi, '');
|
||||
|
||||
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "");
|
||||
html = html.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "");
|
||||
html = html.replace(/<head[\s\S]*?>[\s\S]*?<\/head>/gi, "");
|
||||
|
||||
// Strip all remaining HTML tags
|
||||
const text = html.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
|
||||
return text.replace(/\s+/g, ' ').trim().slice(0, 4000);
|
||||
const text = html.replace(/<\/?[^>]+(>|$)/g, "");
|
||||
|
||||
return text.replace(/\s+/g, " ").trim().slice(0, 4000);
|
||||
}
|
||||
|
||||
return stripHTML(text).slice(0, 5000); // avoid hitting token limit
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Mark the response as a v1 data stream:
|
||||
c.header('X-Vercel-AI-Data-Stream', 'v1');
|
||||
c.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
c.header("X-Vercel-AI-Data-Stream", "v1");
|
||||
c.header("Content-Type", "text/plain; charset=utf-8");
|
||||
|
||||
return stream(c, stream => stream.pipe(result.toDataStream()));
|
||||
}
|
||||
return stream(c, (stream) => stream.pipe(result.toDataStream()));
|
||||
}
|
||||
|
||||
22
src/auth.ts
22
src/auth.ts
@ -3,19 +3,27 @@ import jwkToPem, { type JWK } from "jwk-to-pem";
|
||||
|
||||
const JWKS = process.env.OIDC_JWKS_URL || "";
|
||||
|
||||
type JWKSResponse = {
|
||||
keys: Array<{ kid: string; kty: string; use: string; alg: Algorithm; n: string; e: string }>;
|
||||
interface JWKSResponse {
|
||||
keys: {
|
||||
kid: string;
|
||||
kty: string;
|
||||
use: string;
|
||||
alg: Algorithm;
|
||||
n: string;
|
||||
e: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<boolean> {
|
||||
const decoded = decode(token, { complete: true });
|
||||
|
||||
const jwks = await fetch(JWKS)
|
||||
.then(res => res.json() as Promise<JWKSResponse>);
|
||||
|
||||
const jwks = await fetch(JWKS).then(
|
||||
(res) => res.json() as Promise<JWKSResponse>,
|
||||
);
|
||||
if (!decoded || !decoded.header || !decoded.header.kid) {
|
||||
return false;
|
||||
}
|
||||
const key = jwks.keys.find(k => k.kid === decoded.header.kid);
|
||||
const key = jwks.keys.find((k) => k.kid === decoded.header.kid);
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
@ -24,7 +32,7 @@ export async function verifyToken(token: string): Promise<boolean> {
|
||||
const res = verify(token, pem, { algorithms: [key.alg] });
|
||||
console.log(res);
|
||||
return typeof res === "object" && "sub" in res;
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL
|
||||
})
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
199
src/main.ts
199
src/main.ts
@ -42,19 +42,19 @@ app.use(
|
||||
app.get("/api/config", (c) => {
|
||||
const capabilities: string[] = [];
|
||||
|
||||
if(process.env.OIDC_ENABLED) {
|
||||
if (process.env.OIDC_ENABLED) {
|
||||
capabilities.push("auth");
|
||||
}
|
||||
|
||||
if(process.env.REVIEWS_ENABLED) {
|
||||
if (process.env.REVIEWS_ENABLED) {
|
||||
capabilities.push("reviews");
|
||||
}
|
||||
|
||||
if(process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
|
||||
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
|
||||
capabilities.push("ai");
|
||||
}
|
||||
|
||||
if(process.env.TANKERKOENIG_API_KEY) {
|
||||
if (process.env.TANKERKOENIG_API_KEY) {
|
||||
capabilities.push("fuel");
|
||||
}
|
||||
|
||||
@ -62,17 +62,19 @@ app.get("/api/config", (c) => {
|
||||
name: "TrafficCue Server",
|
||||
version: "0",
|
||||
capabilities,
|
||||
oidc: process.env.OIDC_ENABLED ? {
|
||||
AUTH_URL: process.env.OIDC_AUTH_URL,
|
||||
CLIENT_ID: process.env.OIDC_CLIENT_ID,
|
||||
TOKEN_URL: process.env.OIDC_TOKEN_URL
|
||||
} : undefined
|
||||
})
|
||||
})
|
||||
oidc: process.env.OIDC_ENABLED
|
||||
? {
|
||||
AUTH_URL: process.env.OIDC_AUTH_URL,
|
||||
CLIENT_ID: process.env.OIDC_CLIENT_ID,
|
||||
TOKEN_URL: process.env.OIDC_TOKEN_URL,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
if(process.env.REVIEWS_ENABLED) {
|
||||
if (process.env.REVIEWS_ENABLED) {
|
||||
app.get("/api/reviews", async (c) => {
|
||||
let {lat, lon} = c.req.query();
|
||||
let { lat, lon } = c.req.query();
|
||||
if (!lat || !lon) {
|
||||
return c.json({ error: "Latitude and longitude are required" }, 400);
|
||||
}
|
||||
@ -84,22 +86,29 @@ if(process.env.REVIEWS_ENABLED) {
|
||||
"SELECT * FROM reviews WHERE latitude = $1 AND longitude = $2",
|
||||
[lat, lon],
|
||||
);
|
||||
return c.json(await Promise.all(res.rows.map(async (row) => {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
rating: row.rating,
|
||||
comment: row.comment,
|
||||
created_at: row.created_at,
|
||||
username: "Me" // TODO: Sync OIDC users with the database
|
||||
};
|
||||
})));
|
||||
return c.json(
|
||||
await Promise.all(
|
||||
res.rows.map(async (row) => {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
rating: row.rating,
|
||||
comment: row.comment,
|
||||
created_at: row.created_at,
|
||||
username: "Me", // TODO: Sync OIDC users with the database
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/review", async (c) => {
|
||||
const { rating, comment, lat, lon } = await c.req.json();
|
||||
if (!rating || !lat || !lon) {
|
||||
return c.json({ error: "Rating, latitude, and longitude are required" }, 400);
|
||||
return c.json(
|
||||
{ error: "Rating, latitude, and longitude are required" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const authHeader = c.req.header("Authorization");
|
||||
@ -126,10 +135,10 @@ if(process.env.REVIEWS_ENABLED) {
|
||||
);
|
||||
|
||||
return c.json(res.rows[0]);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if(process.env.TANKERKOENIG_API_KEY) {
|
||||
if (process.env.TANKERKOENIG_API_KEY) {
|
||||
app.get("/api/fuel/list", async (c) => {
|
||||
// pass GET query parameters to the tankerkoenig API
|
||||
const params = new URLSearchParams(c.req.query());
|
||||
@ -168,68 +177,94 @@ if(process.env.TANKERKOENIG_API_KEY) {
|
||||
});
|
||||
}
|
||||
|
||||
if(process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
|
||||
app.use("/api/ai", rateLimiter({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 50, // 10 requests per minute
|
||||
standardHeaders: "draft-6",
|
||||
keyGenerator: (c) => "global"
|
||||
}))
|
||||
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY) {
|
||||
app.use(
|
||||
"/api/ai",
|
||||
rateLimiter({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 50, // 10 requests per minute
|
||||
standardHeaders: "draft-6",
|
||||
keyGenerator: (_c) => "global",
|
||||
}),
|
||||
);
|
||||
app.post("/api/ai", post);
|
||||
}
|
||||
|
||||
let wsSubscribers: Record<string, WSContext<ServerWebSocket>[]> = {};
|
||||
const wsSubscribers: Record<string, WSContext<ServerWebSocket>[]> = {};
|
||||
|
||||
app.get("/api/ws", upgradeWebSocket((c) => {
|
||||
let advertising = "";
|
||||
return {
|
||||
onOpen(e, ws) {
|
||||
console.log("WebSocket connection opened");
|
||||
ws.send(JSON.stringify({ type: "welcome", message: "Welcome to TrafficCue WebSocket!" }));
|
||||
},
|
||||
onMessage(e, ws) {
|
||||
const data = JSON.parse(e.data.toString());
|
||||
console.log("WebSocket message received:", data);
|
||||
app.get(
|
||||
"/api/ws",
|
||||
upgradeWebSocket((_c) => {
|
||||
let advertising = "";
|
||||
return {
|
||||
onOpen(e, ws) {
|
||||
console.log("WebSocket connection opened");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "welcome",
|
||||
message: "Welcome to TrafficCue WebSocket!",
|
||||
}),
|
||||
);
|
||||
},
|
||||
onMessage(e, ws) {
|
||||
const data = JSON.parse(e.data.toString());
|
||||
console.log("WebSocket message received:", data);
|
||||
|
||||
if (data.type === "advertise") {
|
||||
const code = data.code || randomCode();
|
||||
wsSubscribers[code] = wsSubscribers[code] || [];
|
||||
advertising = code;
|
||||
ws.send(JSON.stringify({ type: "advertising", code }));
|
||||
} else if (data.type === "subscribe") {
|
||||
const code = data.code;
|
||||
if (!code || !wsSubscribers[code]) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid or unknown code" }));
|
||||
return;
|
||||
}
|
||||
wsSubscribers[code].push(ws);
|
||||
ws.send(JSON.stringify({ type: "subscribed", code }));
|
||||
} else if (data.type === "location") {
|
||||
const subscribers = wsSubscribers[advertising] || [];
|
||||
subscribers.forEach(subscriber => {
|
||||
if (subscriber !== ws) {
|
||||
subscriber.send(JSON.stringify({ type: "location", location: data.location, route: data.route }));
|
||||
if (data.type === "advertise") {
|
||||
const code = data.code || randomCode();
|
||||
wsSubscribers[code] = wsSubscribers[code] || [];
|
||||
advertising = code;
|
||||
ws.send(JSON.stringify({ type: "advertising", code }));
|
||||
} else if (data.type === "subscribe") {
|
||||
const code = data.code;
|
||||
if (!code || !wsSubscribers[code]) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid or unknown code",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Unknown message type" }));
|
||||
}
|
||||
},
|
||||
onClose(e, ws) {
|
||||
// If they are subscribing, remove them from the subscribers list
|
||||
for (const code in wsSubscribers) {
|
||||
if (wsSubscribers[code]) {
|
||||
wsSubscribers[code] = wsSubscribers[code].filter(subscriber => subscriber !== ws);
|
||||
if (wsSubscribers[code].length === 0) {
|
||||
delete wsSubscribers[code];
|
||||
wsSubscribers[code].push(ws);
|
||||
ws.send(JSON.stringify({ type: "subscribed", code }));
|
||||
} else if (data.type === "location") {
|
||||
const subscribers = wsSubscribers[advertising] || [];
|
||||
subscribers.forEach((subscriber) => {
|
||||
if (subscriber !== ws) {
|
||||
subscriber.send(
|
||||
JSON.stringify({
|
||||
type: "location",
|
||||
location: data.location,
|
||||
route: data.route,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "Unknown message type" }),
|
||||
);
|
||||
}
|
||||
},
|
||||
onClose(e, ws) {
|
||||
// If they are subscribing, remove them from the subscribers list
|
||||
for (const code in wsSubscribers) {
|
||||
if (wsSubscribers[code]) {
|
||||
wsSubscribers[code] = wsSubscribers[code].filter(
|
||||
(subscriber) => subscriber !== ws,
|
||||
);
|
||||
if (wsSubscribers[code].length === 0) {
|
||||
delete wsSubscribers[code];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
function randomCode(length: number = 6): string {
|
||||
function randomCode(length = 6): string {
|
||||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
@ -240,9 +275,9 @@ function randomCode(length: number = 6): string {
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.text("TrafficCue Server");
|
||||
})
|
||||
});
|
||||
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
websocket
|
||||
}
|
||||
websocket,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user