From da25ea9b0358fe7c27ef7d3d3429f97f8a484b5b Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Wed, 10 Sep 2025 17:50:47 -0300 Subject: [PATCH] feat: add OCR endpoint using OpenRouter Sonoma Sky Alpha model --- .vscode/settings.json | 8 +- .../src/db/migrations/meta/0000_snapshot.json | 26 +- .../src/db/migrations/meta/_journal.json | 2 +- apps/server/src/index.ts | 84 ++++ apps/web/.env.example | 3 +- apps/web/src/components/DotGrid.jsx | 74 ++-- apps/web/src/components/sign-in-form.tsx | 8 +- apps/web/src/components/sign-up-form.tsx | 4 +- .../src/components/ui/base-dropdown-menu.tsx | 414 +++++++++--------- .../src/components/ui/radix-dropdown-menu.tsx | 94 ++-- apps/web/src/components/ui/tooltip.tsx | 103 +++++ apps/web/src/lib/appwrite-db.ts | 46 +- apps/web/src/lib/tldraw/ai-shapes.tsx | 369 ++++++++++++++++ apps/web/src/lib/tldraw/knowledge-graph.ts | 185 ++++++++ apps/web/src/lib/tldraw/processing.ts | 141 ++++++ apps/web/src/lib/utils.ts | 6 + apps/web/src/routes/space.tsx | 98 ++++- apps/web/src/types/ambient.d.ts | 9 + 18 files changed, 1358 insertions(+), 316 deletions(-) create mode 100644 apps/web/src/components/ui/tooltip.tsx create mode 100644 apps/web/src/lib/tldraw/ai-shapes.tsx create mode 100644 apps/web/src/lib/tldraw/knowledge-graph.ts create mode 100644 apps/web/src/lib/tldraw/processing.ts create mode 100644 apps/web/src/types/ambient.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 64256ff..105f40a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -32,5 +32,11 @@ "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" }, - "cSpell.words": ["appwrite", "Reflecto", "Tldraw"] + "cSpell.words": [ + "appwrite", + "OPENROUTER", + "Reflecto", + "sonoma", + "Tldraw" + ] } diff --git a/apps/server/src/db/migrations/meta/0000_snapshot.json b/apps/server/src/db/migrations/meta/0000_snapshot.json index c30cb4a..f542357 100644 --- a/apps/server/src/db/migrations/meta/0000_snapshot.json +++ b/apps/server/src/db/migrations/meta/0000_snapshot.json @@ -93,12 +93,8 @@ "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -168,12 +164,8 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -183,9 +175,7 @@ "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -246,9 +236,7 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -316,4 +304,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index b7ec2af..154bfba 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 272e526..9b0652b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,6 +3,7 @@ import { execSync } from "node:child_process"; import { join } from "node:path"; import { trpcServer } from "@hono/trpc-server"; import { Hono } from "hono"; +import { env } from "hono/adapter"; import { cors } from "hono/cors"; import { logger as honoLogger } from "hono/logger"; import { createContext } from "./lib/context"; @@ -44,6 +45,89 @@ app.use( }) ); +// Utilities for OpenRouter +async function openRouterChat( + apiKey: string, + body: unknown, + extra?: { referer?: string; title?: string } +) { + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }; + if (extra?.referer) headers["HTTP-Referer"] = extra.referer; + if (extra?.title) headers["X-Title"] = extra.title; + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers, + body: JSON.stringify(body), + }); + return res; +} + +function arrayBufferToBase64(buf: ArrayBuffer) { + const bytes = new Uint8Array(buf); + let binary = ""; + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return Buffer.from(binary, "binary").toString("base64"); +} + +// OCR via OpenRouter Sonoma Sky Alpha +app.post("/ai/ocr", async (c) => { + const { OPENROUTER_API_KEY, OPENROUTER_SITE_URL, OPENROUTER_SITE_NAME, MOCK } = + env<{ + OPENROUTER_API_KEY?: string; + OPENROUTER_SITE_URL?: string; + OPENROUTER_SITE_NAME?: string; + MOCK?: string; + }>(c); + if (!OPENROUTER_API_KEY) { + return c.json({ error: "Missing OPENROUTER_API_KEY" }, 500); + } + + const blob = await c.req.blob(); + const contentType = c.req.header("content-type") || "image/png"; + const base64 = arrayBufferToBase64(await blob.arrayBuffer()); + const dataUrl = `data:${contentType};base64,${base64}`; + + const body = { + model: "openrouter/sonoma-sky-alpha", + messages: [ + { + role: "system", + content: + "You are an OCR engine. Extract all text visible in the provided image. Respond with plain text only, preserving line breaks and reading order. Do not add commentary.", + }, + { + role: "user", + content: [ + { + type: "text", + text: "Extract text from this image and return only the text.", + }, + { type: "image_url", image_url: { url: dataUrl } }, + ], + }, + ], + }; + + const res = await openRouterChat(OPENROUTER_API_KEY as string, body, { + referer: OPENROUTER_SITE_URL, + title: OPENROUTER_SITE_NAME, + }); + if (!res.ok) { + const err = await res.text(); + return c.json( + { error: `OpenRouter error ${res.status}`, details: err }, + 502 + ); + } + const json = (await res.json()) as any; + const text: string = json?.choices?.[0]?.message?.content ?? ""; + return c.json({ text }); +}); + app.get("/", (c) => { return c.text("OK"); }); diff --git a/apps/web/.env.example b/apps/web/.env.example index cd3d3ca..0cd1929 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,4 +1,5 @@ VITE_APPWRITE_ENDPOINT=https://.cloud.appwrite.io/v1 VITE_APPWRITE_PROJECT_ID= VITE_APPWRITE_DB_ID= -VITE_APPWRITE_COLLECTION_ID= \ No newline at end of file +VITE_APPWRITE_COLLECTION_ID= +VITE_APPWRITE_BUCKET_ID= \ No newline at end of file diff --git a/apps/web/src/components/DotGrid.jsx b/apps/web/src/components/DotGrid.jsx index 211f506..bfc1021 100644 --- a/apps/web/src/components/DotGrid.jsx +++ b/apps/web/src/components/DotGrid.jsx @@ -1,9 +1,9 @@ -'use client'; -import { useRef, useEffect, useCallback, useMemo } from 'react'; -import { gsap } from 'gsap'; -import { InertiaPlugin } from 'gsap/InertiaPlugin'; +"use client"; +import { gsap } from "gsap"; +import { InertiaPlugin } from "gsap/InertiaPlugin"; +import { useCallback, useEffect, useMemo, useRef } from "react"; -import './DotGrid.css'; +import "./DotGrid.css"; gsap.registerPlugin(InertiaPlugin); @@ -22,17 +22,17 @@ function hexToRgb(hex) { const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); if (!m) return { r: 0, g: 0, b: 0 }; return { - r: parseInt(m[1], 16), - g: parseInt(m[2], 16), - b: parseInt(m[3], 16) + r: Number.parseInt(m[1], 16), + g: Number.parseInt(m[2], 16), + b: Number.parseInt(m[3], 16), }; } const DotGrid = ({ dotSize = 16, gap = 32, - baseColor = '#5227FF', - activeColor = '#5227FF', + baseColor = "#5227FF", + activeColor = "#5227FF", proximity = 150, speedTrigger = 100, shockRadius = 250, @@ -40,8 +40,8 @@ const DotGrid = ({ maxSpeed = 5000, resistance = 750, returnDuration = 1.5, - className = '', - style + className = "", + style, }) => { const wrapperRef = useRef(null); const canvasRef = useRef(null); @@ -54,14 +54,14 @@ const DotGrid = ({ speed: 0, lastTime: 0, lastX: 0, - lastY: 0 + lastY: 0, }); const baseRgb = useMemo(() => hexToRgb(baseColor), [baseColor]); const activeRgb = useMemo(() => hexToRgb(activeColor), [activeColor]); const circlePath = useMemo(() => { - if (typeof window === 'undefined' || !window.Path2D) return null; + if (typeof window === "undefined" || !window.Path2D) return null; const p = new window.Path2D(); p.arc(0, 0, dotSize / 2, 0, Math.PI * 2); @@ -71,7 +71,7 @@ const DotGrid = ({ const buildGrid = useCallback(() => { const wrap = wrapperRef.current; const canvas = canvasRef.current; - if (!wrap || !canvas) return; + if (!(wrap && canvas)) return; const { width, height } = wrap.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; @@ -80,7 +80,7 @@ const DotGrid = ({ canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); if (ctx) ctx.scale(dpr, dpr); const cols = Math.floor((width + gap) / (dotSize + gap)); @@ -116,7 +116,7 @@ const DotGrid = ({ const draw = () => { const canvas = canvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); @@ -156,20 +156,20 @@ const DotGrid = ({ useEffect(() => { buildGrid(); let ro = null; - if ('ResizeObserver' in window) { + if ("ResizeObserver" in window) { ro = new ResizeObserver(buildGrid); wrapperRef.current && ro.observe(wrapperRef.current); } else { - window.addEventListener('resize', buildGrid); + window.addEventListener("resize", buildGrid); } return () => { if (ro) ro.disconnect(); - else window.removeEventListener('resize', buildGrid); + else window.removeEventListener("resize", buildGrid); }; }, [buildGrid]); useEffect(() => { - const onMove = e => { + const onMove = (e) => { const now = performance.now(); const pr = pointerRef.current; const dt = pr.lastTime ? now - pr.lastTime : 16; @@ -209,16 +209,16 @@ const DotGrid = ({ xOffset: 0, yOffset: 0, duration: returnDuration, - ease: 'elastic.out(1,0.75)' + ease: "elastic.out(1,0.75)", }); dot._inertiaApplied = false; - } + }, }); } } }; - const onClick = e => { + const onClick = (e) => { const rect = canvasRef.current.getBoundingClientRect(); const cx = e.clientX - rect.left; const cy = e.clientY - rect.top; @@ -237,29 +237,37 @@ const DotGrid = ({ xOffset: 0, yOffset: 0, duration: returnDuration, - ease: 'elastic.out(1,0.75)' + ease: "elastic.out(1,0.75)", }); dot._inertiaApplied = false; - } + }, }); } } }; const throttledMove = throttle(onMove, 50); - window.addEventListener('mousemove', throttledMove, { passive: true }); - window.addEventListener('click', onClick); + window.addEventListener("mousemove", throttledMove, { passive: true }); + window.addEventListener("click", onClick); return () => { - window.removeEventListener('mousemove', throttledMove); - window.removeEventListener('click', onClick); + window.removeEventListener("mousemove", throttledMove); + window.removeEventListener("click", onClick); }; - }, [maxSpeed, speedTrigger, proximity, resistance, returnDuration, shockRadius, shockStrength]); + }, [ + maxSpeed, + speedTrigger, + proximity, + resistance, + returnDuration, + shockRadius, + shockStrength, + ]); return (
-
- +
+
); diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx index fb8d6ad..2b59c81 100644 --- a/apps/web/src/components/sign-in-form.tsx +++ b/apps/web/src/components/sign-in-form.tsx @@ -53,7 +53,7 @@ export default function SignInForm({ .string() .min( MIN_PASSWORD_LENGTH, - `Password must be at least ${MIN_PASSWORD_LENGTH} characters`, + `Password must be at least ${MIN_PASSWORD_LENGTH} characters` ), }), }, @@ -136,12 +136,12 @@ export default function SignInForm({
- -
diff --git a/apps/web/src/components/sign-up-form.tsx b/apps/web/src/components/sign-up-form.tsx index 6651d84..7965289 100644 --- a/apps/web/src/components/sign-up-form.tsx +++ b/apps/web/src/components/sign-up-form.tsx @@ -55,7 +55,7 @@ export default function SignUpForm({ .string() .min( MIN_PASSWORD_LENGTH, - `Password must be at least ${MIN_PASSWORD_LENGTH} characters`, + `Password must be at least ${MIN_PASSWORD_LENGTH} characters` ), }), }, @@ -160,7 +160,7 @@ export default function SignUpForm({
-