From 626f48c1c2f86da2ae345154fcaf66e8d250f4bd Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Mon, 6 Apr 2026 12:43:31 -0700 Subject: [PATCH] add super alpha ai api routes --- app/api/ai/blocks/[blockId]/route.ts | 375 +++++++++++++++++ app/api/ai/blocks/route.ts | 242 +++++++++++ app/api/ai/doc/route.ts | 138 ++++++ app/api/ai/lib.tsx | 609 +++++++++++++++++++++++++++ app/api/ai/search/route.ts | 87 ++++ 5 files changed, 1451 insertions(+) create mode 100644 app/api/ai/blocks/[blockId]/route.ts create mode 100644 app/api/ai/blocks/route.ts create mode 100644 app/api/ai/doc/route.ts create mode 100644 app/api/ai/lib.tsx create mode 100644 app/api/ai/search/route.ts diff --git a/app/api/ai/blocks/[blockId]/route.ts b/app/api/ai/blocks/[blockId]/route.ts new file mode 100644 index 00000000..e1bb42db --- /dev/null +++ b/app/api/ai/blocks/[blockId]/route.ts @@ -0,0 +1,375 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { sql, eq, and } from "drizzle-orm"; +import { pool } from "supabase/pool"; +import { facts, entities } from "drizzle/schema"; +import { + authenticateToken, + broadcastPoke, + tokenHash, + hasWriteAccess, + editYjsText, + EditOperation, +} from "../../lib"; + +type Params = { params: Promise<{ blockId: string }> }; + +// --- DELETE --- + +export async function DELETE(req: NextRequest, { params }: Params) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + if (!hasWriteAccess(auth)) { + return Response.json({ error: "No write access" }, { status: 403 }); + } + + let { blockId } = await params; + + let client = await pool.connect(); + try { + let db = drizzle(client); + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); + + // Verify the block entity exists + let [entity] = await tx + .select({ id: entities.id, set: entities.set }) + .from(entities) + .where(eq(entities.id, blockId)); + + if (!entity) { + throw Response.json({ error: "Block not found" }, { status: 404 }); + } + + // Verify permission + let hasAccess = auth.tokenRights.some( + (r) => r.entity_set === entity.set && r.write, + ); + if (!hasAccess) { + throw Response.json({ error: "Block not found" }, { status: 404 }); + } + + // Check for image to clean up + let [imageFact] = await tx + .select({ data: facts.data }) + .from(facts) + .where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/image"))); + + if (imageFact) { + let { createClient } = await import("@supabase/supabase-js"); + let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + ); + let src = (imageFact.data as any).src; + if (src) { + let paths = src.split("/"); + await supabase.storage + .from("minilink-user-assets") + .remove([paths[paths.length - 1]]); + } + } + + // Delete the entity (cascades to facts) + await tx.delete(entities).where(eq(entities.id, blockId)); + + // Also delete referencing facts (card/block pointing to this entity) + await tx.delete(facts).where( + and( + eq(facts.attribute, "card/block"), + sql`data->>'value' = ${blockId}`, + ), + ); + }); + + await broadcastPoke(auth.rootEntity); + return Response.json({ deleted: blockId }); + } catch (e) { + if (e instanceof Response) return e; + console.error("AI API delete error:", e); + return Response.json({ error: "Internal error" }, { status: 500 }); + } finally { + client.release(); + } +} + +// --- PATCH --- + +export async function PATCH(req: NextRequest, { params }: Params) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + if (!hasWriteAccess(auth)) { + return Response.json({ error: "No write access" }, { status: 403 }); + } + + let { blockId } = await params; + + let body: { + action?: "replace" | "insert"; + content?: string; + position?: "start" | "end" | { before: string } | { after: string }; + language?: string | null; + }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + let hasContentEdit = body.action !== undefined || body.content !== undefined; + if (hasContentEdit && (!body.action || body.content === undefined)) { + return Response.json( + { error: "action and content required together" }, + { status: 400 }, + ); + } + if (!hasContentEdit && body.language === undefined) { + return Response.json( + { error: "must provide action+content or language" }, + { status: 400 }, + ); + } + + let client = await pool.connect(); + try { + let db = drizzle(client); + let result: { blockId: string; newText: string } | null = null; + + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); + + // Verify the block entity exists + let [entity] = await tx + .select({ id: entities.id, set: entities.set }) + .from(entities) + .where(eq(entities.id, blockId)); + + if (!entity) { + throw Response.json({ error: "Block not found" }, { status: 404 }); + } + + let hasAccess = auth.tokenRights.some( + (r) => r.entity_set === entity.set && r.write, + ); + if (!hasAccess) { + throw Response.json({ error: "Block not found" }, { status: 404 }); + } + + // Get block type + let [typeFact] = await tx + .select({ id: facts.id, data: facts.data }) + .from(facts) + .where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/type"))); + + if (!typeFact) { + throw Response.json({ error: "Block has no type" }, { status: 400 }); + } + + let blockType = (typeFact.data as any).value; + + if ( + blockType === "text" || + blockType === "heading" || + blockType === "blockquote" + ) { + if (body.language !== undefined) { + throw Response.json( + { error: "language only applies to code blocks" }, + { status: 400 }, + ); + } + if (!hasContentEdit) { + throw Response.json( + { error: "action and content required" }, + { status: 400 }, + ); + } + // YJS content + let [textFact] = await tx + .select({ id: facts.id, data: facts.data }) + .from(facts) + .where( + and(eq(facts.entity, blockId), eq(facts.attribute, "block/text")), + ); + + let existingBase64 = textFact ? (textFact.data as any).value : null; + let content = body.content as string; + + let operation: EditOperation; + if (body.action === "replace") { + operation = { type: "replace", content }; + } else { + operation = { + type: "insert", + position: body.position || "end", + content, + } as EditOperation; + } + + if (!existingBase64) { + // No existing text, create new + let { createYjsText } = await import("../../lib"); + let newBase64 = createYjsText(content); + if (textFact) { + await tx + .update(facts) + .set({ data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newBase64)}::jsonb)` }) + .where(eq(facts.id, textFact.id)); + } else { + let { v7 } = await import("uuid"); + await tx.insert(facts).values({ + id: v7(), + entity: blockId, + attribute: "block/text", + data: sql`${JSON.stringify({ type: "text", value: newBase64 })}::jsonb`, + }); + } + result = { blockId, newText: content }; + } else { + let editResult = editYjsText(existingBase64, operation); + + if ("error" in editResult) { + throw Response.json( + { + error: "search_not_found", + blockText: editResult.fullText, + }, + { status: 400 }, + ); + } + + await tx + .update(facts) + .set({ + data: sql`jsonb_set(data, '{value}', ${JSON.stringify(editResult.result)}::jsonb)`, + }) + .where(eq(facts.id, textFact.id)); + + result = { blockId, newText: editResult.plaintext }; + } + } else if (blockType === "code") { + // Plain string content + let [codeFact] = await tx + .select({ id: facts.id, data: facts.data }) + .from(facts) + .where( + and(eq(facts.entity, blockId), eq(facts.attribute, "block/code")), + ); + + let existingCode = codeFact ? ((codeFact.data as any).value as string) : ""; + let newCode = existingCode; + + if (hasContentEdit) { + let content = body.content as string; + if (body.action === "replace") { + newCode = content; + } else { + let pos = body.position || "end"; + if (pos === "start") { + newCode = content + existingCode; + } else if (pos === "end") { + newCode = existingCode + content; + } else if (typeof pos === "object" && "before" in pos) { + let idx = existingCode.indexOf(pos.before); + if (idx === -1) { + throw Response.json( + { error: "search_not_found", blockText: existingCode }, + { status: 400 }, + ); + } + newCode = + existingCode.slice(0, idx) + + content + + existingCode.slice(idx); + } else if (typeof pos === "object" && "after" in pos) { + let idx = existingCode.indexOf(pos.after); + if (idx === -1) { + throw Response.json( + { error: "search_not_found", blockText: existingCode }, + { status: 400 }, + ); + } + newCode = + existingCode.slice(0, idx + pos.after.length) + + content + + existingCode.slice(idx + pos.after.length); + } else { + newCode = existingCode + content; + } + } + + if (codeFact) { + await tx + .update(facts) + .set({ + data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newCode)}::jsonb)`, + }) + .where(eq(facts.id, codeFact.id)); + } else { + let { v7 } = await import("uuid"); + await tx.insert(facts).values({ + id: v7(), + entity: blockId, + attribute: "block/code", + data: sql`${JSON.stringify({ type: "string", value: newCode })}::jsonb`, + }); + } + } + + // Handle language update + if (body.language !== undefined) { + let [langFact] = await tx + .select({ id: facts.id }) + .from(facts) + .where( + and( + eq(facts.entity, blockId), + eq(facts.attribute, "block/code-language"), + ), + ); + + if (body.language === null || body.language === "") { + // Remove language + if (langFact) { + await tx.delete(facts).where(eq(facts.id, langFact.id)); + } + } else { + let langData = { type: "string", value: body.language }; + if (langFact) { + await tx + .update(facts) + .set({ data: sql`${JSON.stringify(langData)}::jsonb` }) + .where(eq(facts.id, langFact.id)); + } else { + let { v7 } = await import("uuid"); + await tx.insert(facts).values({ + id: v7(), + entity: blockId, + attribute: "block/code-language", + data: sql`${JSON.stringify(langData)}::jsonb`, + }); + } + } + } + + result = { blockId, newText: newCode }; + } else { + throw Response.json( + { error: `Cannot edit blocks of type '${blockType}'` }, + { status: 400 }, + ); + } + }); + + await broadcastPoke(auth.rootEntity); + return Response.json(result); + } catch (e) { + if (e instanceof Response) return e; + console.error("AI API patch error:", e); + return Response.json({ error: "Internal error" }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/app/api/ai/blocks/route.ts b/app/api/ai/blocks/route.ts new file mode 100644 index 00000000..df647ad2 --- /dev/null +++ b/app/api/ai/blocks/route.ts @@ -0,0 +1,242 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { sql, eq } from "drizzle-orm"; +import { pool } from "supabase/pool"; +import { permission_token_rights } from "drizzle/schema"; +import { cachedServerMutationContext } from "src/replicache/cachedServerMutationContext"; +import { generateKeyBetween } from "fractional-indexing"; +import { v7 } from "uuid"; +import { + authenticateToken, + resolvePageEntity, + getPageBlocks, + createYjsText, + broadcastPoke, + tokenHash, + hasWriteAccess, +} from "../lib"; + +type BlockInput = + | { type: "text"; content: string } + | { type: "heading"; content: string; level?: number } + | { type: "code"; content: string; language?: string } + | { type: "blockquote"; content: string } + | { type: "horizontal-rule" }; + +type PositionInput = + | "start" + | "end" + | { after: string } + | { before: string }; + +export async function POST(req: NextRequest) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + if (!hasWriteAccess(auth)) { + return Response.json({ error: "No write access" }, { status: 403 }); + } + + let body: { page?: string; position: PositionInput; blocks: BlockInput[] }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body.blocks || !Array.isArray(body.blocks) || body.blocks.length === 0) { + return Response.json({ error: "blocks array required" }, { status: 400 }); + } + if (!body.position) { + return Response.json({ error: "position required" }, { status: 400 }); + } + + let client = await pool.connect(); + try { + let db = drizzle(client); + let createdBlocks: { blockId: string; type: string }[] = []; + + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); + + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, body.page); + if (pageEntity instanceof Response) throw pageEntity; + + let token_rights = await tx + .select() + .from(permission_token_rights) + .where(eq(permission_token_rights.token, auth.tokenId)); + + let { getContext, flush } = cachedServerMutationContext( + tx, + auth.tokenId, + token_rights, + ); + let ctx = getContext("ai-api", 0); + + let existingBlocks = await getPageBlocks(tx, pageEntity as string); + let sorted = existingBlocks.sort((a, b) => + a.position > b.position ? 1 : -1, + ); + + // Compute initial position based on body.position + let currentPosition: string; + let pos = body.position; + + if (pos === "start") { + currentPosition = generateKeyBetween( + null, + sorted[0]?.position || null, + ); + } else if (pos === "end") { + currentPosition = generateKeyBetween( + sorted[sorted.length - 1]?.position || null, + null, + ); + } else if ("after" in pos) { + let targetIdx = sorted.findIndex((b) => b.value === pos.after); + if (targetIdx === -1) { + throw Response.json({ error: "Block not found for 'after'" }, { status: 404 }); + } + currentPosition = generateKeyBetween( + sorted[targetIdx].position, + sorted[targetIdx + 1]?.position || null, + ); + } else if ("before" in pos) { + let targetIdx = sorted.findIndex((b) => b.value === pos.before); + if (targetIdx === -1) { + throw Response.json({ error: "Block not found for 'before'" }, { status: 404 }); + } + currentPosition = generateKeyBetween( + sorted[targetIdx - 1]?.position || null, + sorted[targetIdx].position, + ); + } else { + throw Response.json({ error: "Invalid position" }, { status: 400 }); + } + + // Track the next position boundary for chaining + let nextBound: string | null = null; + if (pos === "start" && sorted.length > 0) { + nextBound = sorted[0].position; + } else if (typeof pos === "object" && "before" in pos) { + let targetIdx = sorted.findIndex((b) => b.value === pos.before); + nextBound = sorted[targetIdx].position; + } + + for (let i = 0; i < body.blocks.length; i++) { + let block = body.blocks[i]; + let newEntityID = v7(); + let factID = v7(); + + // For subsequent blocks, chain after the previous position + if (i > 0) { + currentPosition = generateKeyBetween(currentPosition, nextBound); + } + + await ctx.createEntity({ + entityID: newEntityID, + permission_set: auth.permissionSet!, + }); + + await ctx.assertFact({ + entity: pageEntity as string, + id: factID, + data: { + type: "ordered-reference" as const, + value: newEntityID, + position: currentPosition, + }, + attribute: "card/block" as const, + }); + + let blockType: string; + + if (block.type === "text") { + blockType = "text"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "text" }, + attribute: "block/type" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "text" as const, value: createYjsText(block.content) }, + attribute: "block/text" as const, + }); + } else if (block.type === "heading") { + blockType = "heading"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "heading" }, + attribute: "block/type" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "text" as const, value: createYjsText(block.content) }, + attribute: "block/text" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "number" as const, value: block.level || 1 }, + attribute: "block/heading-level" as const, + }); + } else if (block.type === "code") { + blockType = "code"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "code" }, + attribute: "block/type" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "string" as const, value: block.content }, + attribute: "block/code" as const, + }); + if (block.language) { + await ctx.assertFact({ + entity: newEntityID, + data: { type: "string" as const, value: block.language }, + attribute: "block/code-language" as const, + }); + } + } else if (block.type === "blockquote") { + blockType = "blockquote"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "blockquote" }, + attribute: "block/type" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "text" as const, value: createYjsText(block.content) }, + attribute: "block/text" as const, + }); + } else if (block.type === "horizontal-rule") { + blockType = "horizontal-rule"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "horizontal-rule" }, + attribute: "block/type" as const, + }); + } else { + continue; + } + + createdBlocks.push({ blockId: newEntityID, type: blockType }); + } + + await flush(); + }); + + await broadcastPoke(auth.rootEntity); + + return Response.json({ blocks: createdBlocks }); + } catch (e) { + if (e instanceof Response) return e; + console.error("AI API blocks error:", e); + return Response.json({ error: "Internal error" }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/app/api/ai/doc/route.ts b/app/api/ai/doc/route.ts new file mode 100644 index 00000000..f0c047ac --- /dev/null +++ b/app/api/ai/doc/route.ts @@ -0,0 +1,138 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { pool } from "supabase/pool"; +import { + authenticateToken, + resolvePageEntity, + getPageBlocks, + getAllFactsForEntities, + blocksToMarkdown, + extractPlaintext, +} from "../lib"; + +export async function GET(req: NextRequest) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + let pageParam = req.nextUrl.searchParams.get("page"); + + let client = await pool.connect(); + try { + let db = drizzle(client); + return await db.transaction(async (tx) => { + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam); + if (pageEntity instanceof Response) return pageEntity; + + let blocks = await getPageBlocks(tx, pageEntity); + + // Collect all entity IDs we need facts for + let entityIds = new Set(); + for (let b of blocks) { + entityIds.add(b.value); + } + let allFacts = await getAllFactsForEntities(tx, [...entityIds]); + + // For card blocks, also fetch subpage facts + let subpages: { id: string; title: string }[] = []; + for (let b of blocks) { + if (b.type === "card") { + let cardFacts = allFacts.filter( + (f) => f.entity === b.value && f.attribute === "block/card", + ); + if (cardFacts[0]) { + let cardEntityId = (cardFacts[0].data as any).value; + entityIds.add(cardEntityId); + } + } + } + + // Re-fetch with subpage entities included + allFacts = await getAllFactsForEntities(tx, [...entityIds]); + + // Also fetch subpage block entities for titles + let subpageBlockEntityIds = new Set(); + for (let b of blocks) { + if (b.type === "card") { + let cardFacts = allFacts.filter( + (f) => f.entity === b.value && f.attribute === "block/card", + ); + if (cardFacts[0]) { + let cardEntityId = (cardFacts[0].data as any).value; + let blockRefs = allFacts + .filter( + (f) => + f.entity === cardEntityId && f.attribute === "card/block", + ) + .sort( + (a, b) => + (a.data as any).position > (b.data as any).position ? 1 : -1, + ); + for (let ref of blockRefs) { + subpageBlockEntityIds.add((ref.data as any).value); + } + } + } + } + + if (subpageBlockEntityIds.size > 0) { + let subpageBlockFacts = await getAllFactsForEntities(tx, [ + ...subpageBlockEntityIds, + ]); + allFacts = [...allFacts, ...subpageBlockFacts]; + } + + // Build subpages list + for (let b of blocks) { + if (b.type === "card") { + let cardFacts = allFacts.filter( + (f) => f.entity === b.value && f.attribute === "block/card", + ); + if (cardFacts[0]) { + let cardEntityId = (cardFacts[0].data as any).value; + let blockRefs = allFacts + .filter( + (f) => + f.entity === cardEntityId && f.attribute === "card/block", + ) + .sort( + (a, b) => + (a.data as any).position > (b.data as any).position ? 1 : -1, + ); + let title = ""; + if (blockRefs[0]) { + let firstBlockId = (blockRefs[0].data as any).value; + let textFact = allFacts.find( + (f) => + f.entity === firstBlockId && f.attribute === "block/text", + ); + if (textFact) { + title = extractPlaintext((textFact.data as any).value); + } + } + subpages.push({ id: cardEntityId, title: title || "Untitled" }); + } + } + } + + let markdown = await blocksToMarkdown(blocks, allFacts); + + // Extract document title from first heading + let titleBlock = blocks.find( + (b) => b.type === "heading" || b.type === "text", + ); + let title = ""; + if (titleBlock) { + let textFact = allFacts.find( + (f) => f.entity === titleBlock.value && f.attribute === "block/text", + ); + if (textFact) { + title = extractPlaintext((textFact.data as any).value); + } + } + + return Response.json({ title, markdown, subpages }); + }); + } finally { + client.release(); + } +} diff --git a/app/api/ai/lib.tsx b/app/api/ai/lib.tsx new file mode 100644 index 00000000..b00b655a --- /dev/null +++ b/app/api/ai/lib.tsx @@ -0,0 +1,609 @@ +import { createClient } from "@supabase/supabase-js"; +import type { Database } from "supabase/database.types"; +import { permission_tokens, permission_token_rights } from "drizzle/schema"; +import { entities, facts } from "drizzle/schema"; +import * as driz from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import * as Y from "yjs"; +import * as base64 from "base64-js"; +import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; +import { Block } from "components/Blocks/Block"; +import { parseBlocksToList, List } from "src/utils/parseBlocksToList"; +import { htmlToMarkdown } from "src/htmlMarkdownParsers"; + +// --- Auth --- + +export type AuthResult = { + tokenId: string; + rootEntity: string; + tokenRights: { + token: string; + entity_set: string; + read: boolean; + write: boolean; + create_token: boolean; + change_entity_set: boolean; + }[]; + permissionSet: string | null; +}; + +export async function authenticateToken( + request: Request, +): Promise { + let auth = request.headers.get("Authorization"); + if (!auth || !auth.startsWith("Bearer ")) { + return Response.json({ error: "Missing Authorization header" }, { status: 401 }); + } + let tokenId = auth.slice("Bearer ".length).trim(); + if (!tokenId) { + return Response.json({ error: "Invalid token" }, { status: 401 }); + } + + let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + ); + + let { data: token } = await supabase + .from("permission_tokens") + .select("id, root_entity, blocked_by_admin") + .eq("id", tokenId) + .single(); + + if (!token) { + return Response.json({ error: "Invalid token" }, { status: 401 }); + } + if (token.blocked_by_admin) { + return Response.json({ error: "Token blocked" }, { status: 403 }); + } + + let { data: rights } = await supabase + .from("permission_token_rights") + .select("token, entity_set, read, write, create_token, change_entity_set") + .eq("token", tokenId); + + let tokenRights = rights || []; + let permissionSet = + tokenRights.find((r) => r.write)?.entity_set ?? null; + + return { + tokenId, + rootEntity: token.root_entity, + tokenRights, + permissionSet, + }; +} + +// --- Page resolution --- + +export async function resolvePageEntity( + tx: PgTransaction, + rootEntity: string, + pageParam?: string | null, +): Promise { + let rootPageFacts = await tx + .select({ data: facts.data }) + .from(facts) + .where( + driz.and( + driz.eq(facts.entity, rootEntity), + driz.eq(facts.attribute, "root/page"), + ), + ); + + let mainPage = (rootPageFacts[0]?.data as any)?.value as string | undefined; + if (!mainPage) { + return Response.json({ error: "No main page found" }, { status: 404 }); + } + + if (!pageParam) return mainPage; + + // Verify the requested page exists as an entity in this document + let [pageEntity] = await tx + .select({ id: entities.id }) + .from(entities) + .where(driz.eq(entities.id, pageParam)); + + if (!pageEntity) { + return Response.json({ error: "Page not found" }, { status: 404 }); + } + + return pageParam; +} + +// --- Block fetching (server-side version of getBlocksWithTypeLocal) --- + +type FactRow = { + id: string; + entity: string; + attribute: string; + data: any; +}; + +export async function getPageBlocks( + tx: PgTransaction, + pageEntity: string, +): Promise { + // Get all facts for this page's blocks in bulk + let blockRefs = await tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where( + driz.and( + driz.eq(facts.entity, pageEntity), + driz.eq(facts.attribute, "card/block"), + ), + ); + + blockRefs.sort((a, b) => { + let posA = (a.data as any).position; + let posB = (b.data as any).position; + if (posA === posB) return a.id > b.id ? 1 : -1; + return posA > posB ? 1 : -1; + }); + + if (blockRefs.length === 0) return []; + + // Collect all block entity IDs + let blockEntityIds = blockRefs.map((r) => (r.data as any).value as string); + + // Fetch all facts for these block entities in one query + let allBlockFacts = await tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where(driz.inArray(facts.entity, blockEntityIds)); + + let factsByEntity = new Map(); + for (let f of allBlockFacts) { + let arr = factsByEntity.get(f.entity); + if (!arr) { + arr = []; + factsByEntity.set(f.entity, arr); + } + arr.push(f); + } + + let result: Block[] = []; + + for (let ref of blockRefs) { + let blockEntityId = (ref.data as any).value as string; + let blockFacts = factsByEntity.get(blockEntityId) || []; + let typeFact = blockFacts.find((f) => f.attribute === "block/type"); + if (!typeFact) continue; + + let isListFact = blockFacts.find((f) => f.attribute === "block/is-list"); + if (isListFact && (isListFact.data as any).value) { + let children = await getListChildren(tx, ref, pageEntity, 1, []); + result.push(...children); + } else { + result.push({ + value: blockEntityId, + position: (ref.data as any).position, + factID: ref.id, + type: (typeFact.data as any).value, + parent: pageEntity, + }); + } + } + + computeDisplayNumbers(result); + return result; +} + +async function getListChildren( + tx: PgTransaction, + root: FactRow, + pageParent: string, + depth: number, + path: { depth: number; entity: string }[], +): Promise { + let rootValue = (root.data as any).value as string; + + let childRefs = await tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where( + driz.and( + driz.eq(facts.entity, rootValue), + driz.eq(facts.attribute, "card/block"), + ), + ); + childRefs.sort((a, b) => + (a.data as any).position > (b.data as any).position ? 1 : -1, + ); + + let rootFacts = await tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where(driz.eq(facts.entity, rootValue)); + + let typeFact = rootFacts.find((f) => f.attribute === "block/type"); + if (!typeFact) return []; + + let listStyleFact = rootFacts.find((f) => f.attribute === "block/list-style"); + let listNumberFact = rootFacts.find((f) => f.attribute === "block/list-number"); + + let newPath = [...path, { entity: rootValue, depth }]; + + let childBlocks: Block[] = []; + for (let c of childRefs) { + let children = await getListChildren(tx, c, rootValue, depth + 1, newPath); + childBlocks.push(...children); + } + + return [ + { + value: rootValue, + position: (root.data as any).position, + factID: root.id, + type: (typeFact.data as any).value, + parent: pageParent, + listData: { + depth, + parent: root.entity, + path: newPath, + listStyle: listStyleFact ? (listStyleFact.data as any).value : undefined, + listStart: listNumberFact ? (listNumberFact.data as any).value : undefined, + }, + }, + ...childBlocks, + ]; +} + +function computeDisplayNumbers(blocks: Block[]): void { + let counters = new Map(); + for (let block of blocks) { + if (!block.listData) { + counters.clear(); + continue; + } + if (block.listData.listStyle !== "ordered") continue; + let parent = block.listData.parent; + if (block.listData.listStart !== undefined) { + counters.set(parent, block.listData.listStart); + } else if (!counters.has(parent)) { + counters.set(parent, 1); + } + block.listData.displayNumber = counters.get(parent)!; + counters.set(parent, counters.get(parent)! + 1); + } +} + +// --- Server-side YJS to HTML rendering --- + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderYjsToHTML( + base64Value: string, + wrapper: "p" | "h1" | "h2" | "h3" | "blockquote", + attrs?: Record, +): string { + let attrStr = attrs + ? Object.entries(attrs) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => ` ${k}="${escapeHtml(v)}"`) + .join("") + : ""; + + if (!base64Value) return `<${wrapper}${attrStr}>`; + + let doc = new Y.Doc(); + Y.applyUpdate(doc, base64.toByteArray(base64Value)); + let [node] = doc.getXmlElement("prosemirror").toArray(); + if (!node || node.constructor !== Y.XmlElement) return `<${wrapper}${attrStr}>`; + + let children = node.toArray(); + if (children.length === 0) return `<${wrapper}${attrStr}>
`; + + let inner = children + .map((child) => { + if (child.constructor === Y.XmlText) { + let deltas = child.toDelta() as { insert: string; attributes?: any }[]; + if (deltas.length === 0) return "
"; + return deltas + .map((d) => { + let text = escapeHtml(d.insert); + if (d.attributes?.link) return `${text}`; + if (d.attributes?.strong) text = `${text}`; + if (d.attributes?.em) text = `${text}`; + if (d.attributes?.code) text = `${text}`; + return text; + }) + .join(""); + } + if (child.constructor === Y.XmlElement) { + if (child.nodeName === "hard_break") return "
"; + if (child.nodeName === "didMention" || child.nodeName === "atMention") { + let text = child.getAttribute("text") || ""; + return escapeHtml(text); + } + } + return ""; + }) + .join(""); + + return `<${wrapper}${attrStr}>${inner}`; +} + +// --- Block-to-HTML (server-side, reads from pre-fetched facts) --- + +export async function getAllFactsForEntities( + tx: PgTransaction, + entityIds: string[], +): Promise { + if (entityIds.length === 0) return []; + return tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where(driz.inArray(facts.entity, entityIds)); +} + +function factsLookup(allFacts: FactRow[], entity: string, attribute: string): FactRow[] { + return allFacts.filter((f) => f.entity === entity && f.attribute === attribute); +} + +async function renderBlockToHTML( + b: Block, + allFacts: FactRow[], +): Promise { + let [alignment] = factsLookup(allFacts, b.value, "block/text-alignment"); + let a = alignment ? (alignment.data as any).value : undefined; + + switch (b.type) { + case "text": { + let [value] = factsLookup(allFacts, b.value, "block/text"); + return renderYjsToHTML(value?.data.value, "p", a ? { "data-alignment": a } : undefined); + } + case "heading": { + let [value] = factsLookup(allFacts, b.value, "block/text"); + let [headingLevel] = factsLookup(allFacts, b.value, "block/heading-level"); + let wrapper = ("h" + ((headingLevel?.data as any)?.value || 1)) as "h1" | "h2" | "h3"; + return renderYjsToHTML(value?.data.value, wrapper, a ? { "data-alignment": a } : undefined); + } + case "blockquote": { + let [value] = factsLookup(allFacts, b.value, "block/text"); + return renderYjsToHTML(value?.data.value, "blockquote", a ? { "data-alignment": a } : undefined); + } + case "code": { + let [code] = factsLookup(allFacts, b.value, "block/code"); + let [lang] = factsLookup(allFacts, b.value, "block/code-language"); + let langValue = (lang?.data as any)?.value as string | undefined; + let codeAttr = langValue ? ` class="language-${escapeHtml(langValue)}"` : ""; + return `
${escapeHtml((code?.data as any)?.value || "")}
`; + } + case "image": { + let [src] = factsLookup(allFacts, b.value, "block/image"); + if (!src) return ""; + let alignAttr = a ? ` data-alignment="${escapeHtml(a)}"` : ""; + return ``; + } + case "horizontal-rule": + return "
"; + case "card": { + let [card] = factsLookup(allFacts, b.value, "block/card"); + if (!card) return ""; + let cardEntityId = (card.data as any).value; + let title = await getSubpageTitle(allFacts, cardEntityId); + return `${escapeHtml(title || "Untitled")}`; + } + case "link": { + let [url] = factsLookup(allFacts, b.value, "link/url"); + let [title] = factsLookup(allFacts, b.value, "link/title"); + if (!url) return ""; + return `${escapeHtml((title?.data as any)?.value || "")}`; + } + case "button": { + let [text] = factsLookup(allFacts, b.value, "button/text"); + let [url] = factsLookup(allFacts, b.value, "button/url"); + if (!text || !url) return ""; + return `${escapeHtml((text.data as any).value)}`; + } + case "math": { + let [math] = factsLookup(allFacts, b.value, "block/math"); + return `${escapeHtml((math?.data as any)?.value || "")}`; + } + default: + return ""; + } +} + +async function getSubpageTitle( + allFacts: FactRow[], + cardEntityId: string, +): Promise { + // Look for card/block children of the subpage to find first heading + let blockRefs = allFacts + .filter((f) => f.entity === cardEntityId && f.attribute === "card/block") + .sort((a, b) => ((a.data as any).position > (b.data as any).position ? 1 : -1)); + + if (blockRefs.length === 0) return ""; + + let firstBlockId = (blockRefs[0].data as any).value; + let [textFact] = allFacts.filter( + (f) => f.entity === firstBlockId && f.attribute === "block/text", + ); + + if (textFact) { + return extractPlaintext((textFact.data as any).value); + } + return ""; +} + +async function renderListToHTML(l: List, allFacts: FactRow[]): Promise { + let children = ( + await Promise.all(l.children.map((c) => renderListToHTML(c, allFacts))) + ).join("\n"); + + let checkedFacts = factsLookup(allFacts, l.block.value, "block/check-list"); + let checked = checkedFacts[0]; + + let isOrdered = l.children[0]?.block.listData?.listStyle === "ordered"; + let tag = isOrdered ? "ol" : "ul"; + + return `
  • ${await renderBlockToHTML(l.block, allFacts)} ${ + l.children.length > 0 ? `<${tag}>${children}` : "" + }
  • `; +} + +export async function blocksToHTML( + blocks: Block[], + allFacts: FactRow[], +): Promise { + let result: string[] = []; + let parsed = parseBlocksToList(blocks); + + for (let pb of parsed) { + if (pb.type === "block") { + result.push(await renderBlockToHTML(pb.block, allFacts)); + } else { + let isOrdered = pb.children[0]?.block.listData?.listStyle === "ordered"; + let tag = isOrdered ? "ol" : "ul"; + let listItems = ( + await Promise.all( + pb.children.map((c) => renderListToHTML(c, allFacts)), + ) + ).join("\n"); + result.push(`<${tag}>${listItems}`); + } + } + return result; +} + +// --- Blocks-to-markdown --- + +export async function blocksToMarkdown( + blocks: Block[], + allFacts: FactRow[], +): Promise { + let htmlParts = await blocksToHTML(blocks, allFacts); + let html = htmlParts.join("\n"); + return htmlToMarkdown(html); +} + +// --- YJS plaintext extraction --- + +export function extractPlaintext(base64Value: string): string { + if (!base64Value) return ""; + let doc = new Y.Doc(); + Y.applyUpdate(doc, base64.toByteArray(base64Value)); + let nodes = doc.getXmlElement("prosemirror").toArray(); + if (nodes.length === 0) return ""; + return YJSFragmentToString(nodes[0]); +} + +// --- YJS text creation --- + +export function createYjsText(plaintext: string): string { + let doc = new Y.Doc(); + let fragment = doc.getXmlFragment("prosemirror"); + let paragraph = new Y.XmlElement("paragraph"); + let textNode = new Y.XmlText(); + textNode.insert(0, plaintext); + paragraph.insert(0, [textNode]); + fragment.insert(0, [paragraph]); + return base64.fromByteArray(Y.encodeStateAsUpdate(doc)); +} + +// --- YJS text editing --- + +export type EditOperation = + | { type: "replace"; content: string } + | { type: "insert"; position: "start" | "end"; content: string } + | { type: "insert"; position: { before: string } | { after: string }; content: string }; + +export function editYjsText( + existingBase64: string, + operation: EditOperation, +): { result: string; plaintext: string } | { error: "search_not_found"; fullText: string } { + let doc = new Y.Doc(); + Y.applyUpdate(doc, base64.toByteArray(existingBase64)); + + let element = doc.getXmlElement("prosemirror"); + let paragraph = element.toArray()[0]; + if (!paragraph || paragraph.constructor !== Y.XmlElement) { + return { error: "search_not_found", fullText: "" }; + } + + // Find the XmlText child + let textNodes = paragraph.toArray(); + let xmlText: Y.XmlText | null = null; + for (let n of textNodes) { + if (n.constructor === Y.XmlText) { + xmlText = n; + break; + } + } + + if (!xmlText) { + // No text node exists yet, create one for replace/insert + xmlText = new Y.XmlText(); + paragraph.insert(0, [xmlText]); + } + + let currentText = (xmlText.toDelta() as { insert: string }[]) + .map((d) => d.insert) + .join(""); + + if (operation.type === "replace") { + xmlText.delete(0, currentText.length); + xmlText.insert(0, operation.content); + } else if (operation.type === "insert") { + let pos = operation.position; + if (pos === "start") { + xmlText.insert(0, operation.content); + } else if (pos === "end") { + xmlText.insert(currentText.length, operation.content); + } else if ("before" in pos) { + let idx = currentText.indexOf(pos.before); + if (idx === -1) return { error: "search_not_found", fullText: currentText }; + xmlText.insert(idx, operation.content); + } else if ("after" in pos) { + let idx = currentText.indexOf(pos.after); + if (idx === -1) return { error: "search_not_found", fullText: currentText }; + xmlText.insert(idx + pos.after.length, operation.content); + } + } + + let newText = (xmlText.toDelta() as { insert: string }[]) + .map((d) => d.insert) + .join(""); + + return { + result: base64.fromByteArray(Y.encodeStateAsUpdate(doc)), + plaintext: newText, + }; +} + +// --- Realtime poke --- + +export async function broadcastPoke(rootEntity: string) { + let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + ); + let channel = supabase.channel(`rootEntity:${rootEntity}`); + await channel.send({ + type: "broadcast", + event: "poke", + payload: { message: "poke" }, + }); + await supabase.removeChannel(channel); +} + +// --- Helpers --- + +export function tokenHash(tokenId: string): number { + return tokenId.split("").reduce((acc, char) => { + return ((acc << 5) - acc + char.charCodeAt(0)) | 0; + }, 0); +} + +export function hasWriteAccess(auth: AuthResult): boolean { + return auth.tokenRights.some((r) => r.write); +} diff --git a/app/api/ai/search/route.ts b/app/api/ai/search/route.ts new file mode 100644 index 00000000..ed3c10a5 --- /dev/null +++ b/app/api/ai/search/route.ts @@ -0,0 +1,87 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { pool } from "supabase/pool"; +import { + authenticateToken, + resolvePageEntity, + getPageBlocks, + getAllFactsForEntities, + extractPlaintext, +} from "../lib"; + +export async function GET(req: NextRequest) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + let query = req.nextUrl.searchParams.get("q"); + if (!query) { + return Response.json({ error: "Missing q parameter" }, { status: 400 }); + } + + let pageParam = req.nextUrl.searchParams.get("page"); + let queryLower = query.toLowerCase(); + + let client = await pool.connect(); + try { + let db = drizzle(client); + return await db.transaction(async (tx) => { + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam); + if (pageEntity instanceof Response) return pageEntity; + + let blocks = await getPageBlocks(tx, pageEntity); + let entityIds = blocks.map((b) => b.value); + let allFacts = await getAllFactsForEntities(tx, entityIds); + + let results: { + blockId: string; + type: string; + text: string; + language?: string; + }[] = []; + + for (let b of blocks) { + if ( + b.type === "text" || + b.type === "heading" || + b.type === "blockquote" + ) { + let textFact = allFacts.find( + (f) => f.entity === b.value && f.attribute === "block/text", + ); + if (textFact) { + let plaintext = extractPlaintext((textFact.data as any).value); + if (plaintext.toLowerCase().includes(queryLower)) { + results.push({ blockId: b.value, type: b.type, text: plaintext }); + } + } + } else if (b.type === "code") { + let codeFact = allFacts.find( + (f) => f.entity === b.value && f.attribute === "block/code", + ); + if (codeFact) { + let code = (codeFact.data as any).value as string; + if (code.toLowerCase().includes(queryLower)) { + let langFact = allFacts.find( + (f) => + f.entity === b.value && f.attribute === "block/code-language", + ); + let language = langFact + ? ((langFact.data as any).value as string) + : undefined; + results.push({ + blockId: b.value, + type: b.type, + text: code, + ...(language ? { language } : {}), + }); + } + } + } + } + + return Response.json({ results }); + }); + } finally { + client.release(); + } +} -- 2.54.0