a tool for shared writing and social publishing

add super alpha ai api routes

+1451
+375
app/api/ai/blocks/[blockId]/route.ts
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/node-postgres"; 3 + import { sql, eq, and } from "drizzle-orm"; 4 + import { pool } from "supabase/pool"; 5 + import { facts, entities } from "drizzle/schema"; 6 + import { 7 + authenticateToken, 8 + broadcastPoke, 9 + tokenHash, 10 + hasWriteAccess, 11 + editYjsText, 12 + EditOperation, 13 + } from "../../lib"; 14 + 15 + type Params = { params: Promise<{ blockId: string }> }; 16 + 17 + // --- DELETE --- 18 + 19 + export async function DELETE(req: NextRequest, { params }: Params) { 20 + let auth = await authenticateToken(req); 21 + if (auth instanceof Response) return auth; 22 + 23 + if (!hasWriteAccess(auth)) { 24 + return Response.json({ error: "No write access" }, { status: 403 }); 25 + } 26 + 27 + let { blockId } = await params; 28 + 29 + let client = await pool.connect(); 30 + try { 31 + let db = drizzle(client); 32 + await db.transaction(async (tx) => { 33 + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); 34 + 35 + // Verify the block entity exists 36 + let [entity] = await tx 37 + .select({ id: entities.id, set: entities.set }) 38 + .from(entities) 39 + .where(eq(entities.id, blockId)); 40 + 41 + if (!entity) { 42 + throw Response.json({ error: "Block not found" }, { status: 404 }); 43 + } 44 + 45 + // Verify permission 46 + let hasAccess = auth.tokenRights.some( 47 + (r) => r.entity_set === entity.set && r.write, 48 + ); 49 + if (!hasAccess) { 50 + throw Response.json({ error: "Block not found" }, { status: 404 }); 51 + } 52 + 53 + // Check for image to clean up 54 + let [imageFact] = await tx 55 + .select({ data: facts.data }) 56 + .from(facts) 57 + .where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/image"))); 58 + 59 + if (imageFact) { 60 + let { createClient } = await import("@supabase/supabase-js"); 61 + let supabase = createClient( 62 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 63 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 64 + ); 65 + let src = (imageFact.data as any).src; 66 + if (src) { 67 + let paths = src.split("/"); 68 + await supabase.storage 69 + .from("minilink-user-assets") 70 + .remove([paths[paths.length - 1]]); 71 + } 72 + } 73 + 74 + // Delete the entity (cascades to facts) 75 + await tx.delete(entities).where(eq(entities.id, blockId)); 76 + 77 + // Also delete referencing facts (card/block pointing to this entity) 78 + await tx.delete(facts).where( 79 + and( 80 + eq(facts.attribute, "card/block"), 81 + sql`data->>'value' = ${blockId}`, 82 + ), 83 + ); 84 + }); 85 + 86 + await broadcastPoke(auth.rootEntity); 87 + return Response.json({ deleted: blockId }); 88 + } catch (e) { 89 + if (e instanceof Response) return e; 90 + console.error("AI API delete error:", e); 91 + return Response.json({ error: "Internal error" }, { status: 500 }); 92 + } finally { 93 + client.release(); 94 + } 95 + } 96 + 97 + // --- PATCH --- 98 + 99 + export async function PATCH(req: NextRequest, { params }: Params) { 100 + let auth = await authenticateToken(req); 101 + if (auth instanceof Response) return auth; 102 + 103 + if (!hasWriteAccess(auth)) { 104 + return Response.json({ error: "No write access" }, { status: 403 }); 105 + } 106 + 107 + let { blockId } = await params; 108 + 109 + let body: { 110 + action?: "replace" | "insert"; 111 + content?: string; 112 + position?: "start" | "end" | { before: string } | { after: string }; 113 + language?: string | null; 114 + }; 115 + try { 116 + body = await req.json(); 117 + } catch { 118 + return Response.json({ error: "Invalid JSON" }, { status: 400 }); 119 + } 120 + 121 + let hasContentEdit = body.action !== undefined || body.content !== undefined; 122 + if (hasContentEdit && (!body.action || body.content === undefined)) { 123 + return Response.json( 124 + { error: "action and content required together" }, 125 + { status: 400 }, 126 + ); 127 + } 128 + if (!hasContentEdit && body.language === undefined) { 129 + return Response.json( 130 + { error: "must provide action+content or language" }, 131 + { status: 400 }, 132 + ); 133 + } 134 + 135 + let client = await pool.connect(); 136 + try { 137 + let db = drizzle(client); 138 + let result: { blockId: string; newText: string } | null = null; 139 + 140 + await db.transaction(async (tx) => { 141 + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); 142 + 143 + // Verify the block entity exists 144 + let [entity] = await tx 145 + .select({ id: entities.id, set: entities.set }) 146 + .from(entities) 147 + .where(eq(entities.id, blockId)); 148 + 149 + if (!entity) { 150 + throw Response.json({ error: "Block not found" }, { status: 404 }); 151 + } 152 + 153 + let hasAccess = auth.tokenRights.some( 154 + (r) => r.entity_set === entity.set && r.write, 155 + ); 156 + if (!hasAccess) { 157 + throw Response.json({ error: "Block not found" }, { status: 404 }); 158 + } 159 + 160 + // Get block type 161 + let [typeFact] = await tx 162 + .select({ id: facts.id, data: facts.data }) 163 + .from(facts) 164 + .where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/type"))); 165 + 166 + if (!typeFact) { 167 + throw Response.json({ error: "Block has no type" }, { status: 400 }); 168 + } 169 + 170 + let blockType = (typeFact.data as any).value; 171 + 172 + if ( 173 + blockType === "text" || 174 + blockType === "heading" || 175 + blockType === "blockquote" 176 + ) { 177 + if (body.language !== undefined) { 178 + throw Response.json( 179 + { error: "language only applies to code blocks" }, 180 + { status: 400 }, 181 + ); 182 + } 183 + if (!hasContentEdit) { 184 + throw Response.json( 185 + { error: "action and content required" }, 186 + { status: 400 }, 187 + ); 188 + } 189 + // YJS content 190 + let [textFact] = await tx 191 + .select({ id: facts.id, data: facts.data }) 192 + .from(facts) 193 + .where( 194 + and(eq(facts.entity, blockId), eq(facts.attribute, "block/text")), 195 + ); 196 + 197 + let existingBase64 = textFact ? (textFact.data as any).value : null; 198 + let content = body.content as string; 199 + 200 + let operation: EditOperation; 201 + if (body.action === "replace") { 202 + operation = { type: "replace", content }; 203 + } else { 204 + operation = { 205 + type: "insert", 206 + position: body.position || "end", 207 + content, 208 + } as EditOperation; 209 + } 210 + 211 + if (!existingBase64) { 212 + // No existing text, create new 213 + let { createYjsText } = await import("../../lib"); 214 + let newBase64 = createYjsText(content); 215 + if (textFact) { 216 + await tx 217 + .update(facts) 218 + .set({ data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newBase64)}::jsonb)` }) 219 + .where(eq(facts.id, textFact.id)); 220 + } else { 221 + let { v7 } = await import("uuid"); 222 + await tx.insert(facts).values({ 223 + id: v7(), 224 + entity: blockId, 225 + attribute: "block/text", 226 + data: sql`${JSON.stringify({ type: "text", value: newBase64 })}::jsonb`, 227 + }); 228 + } 229 + result = { blockId, newText: content }; 230 + } else { 231 + let editResult = editYjsText(existingBase64, operation); 232 + 233 + if ("error" in editResult) { 234 + throw Response.json( 235 + { 236 + error: "search_not_found", 237 + blockText: editResult.fullText, 238 + }, 239 + { status: 400 }, 240 + ); 241 + } 242 + 243 + await tx 244 + .update(facts) 245 + .set({ 246 + data: sql`jsonb_set(data, '{value}', ${JSON.stringify(editResult.result)}::jsonb)`, 247 + }) 248 + .where(eq(facts.id, textFact.id)); 249 + 250 + result = { blockId, newText: editResult.plaintext }; 251 + } 252 + } else if (blockType === "code") { 253 + // Plain string content 254 + let [codeFact] = await tx 255 + .select({ id: facts.id, data: facts.data }) 256 + .from(facts) 257 + .where( 258 + and(eq(facts.entity, blockId), eq(facts.attribute, "block/code")), 259 + ); 260 + 261 + let existingCode = codeFact ? ((codeFact.data as any).value as string) : ""; 262 + let newCode = existingCode; 263 + 264 + if (hasContentEdit) { 265 + let content = body.content as string; 266 + if (body.action === "replace") { 267 + newCode = content; 268 + } else { 269 + let pos = body.position || "end"; 270 + if (pos === "start") { 271 + newCode = content + existingCode; 272 + } else if (pos === "end") { 273 + newCode = existingCode + content; 274 + } else if (typeof pos === "object" && "before" in pos) { 275 + let idx = existingCode.indexOf(pos.before); 276 + if (idx === -1) { 277 + throw Response.json( 278 + { error: "search_not_found", blockText: existingCode }, 279 + { status: 400 }, 280 + ); 281 + } 282 + newCode = 283 + existingCode.slice(0, idx) + 284 + content + 285 + existingCode.slice(idx); 286 + } else if (typeof pos === "object" && "after" in pos) { 287 + let idx = existingCode.indexOf(pos.after); 288 + if (idx === -1) { 289 + throw Response.json( 290 + { error: "search_not_found", blockText: existingCode }, 291 + { status: 400 }, 292 + ); 293 + } 294 + newCode = 295 + existingCode.slice(0, idx + pos.after.length) + 296 + content + 297 + existingCode.slice(idx + pos.after.length); 298 + } else { 299 + newCode = existingCode + content; 300 + } 301 + } 302 + 303 + if (codeFact) { 304 + await tx 305 + .update(facts) 306 + .set({ 307 + data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newCode)}::jsonb)`, 308 + }) 309 + .where(eq(facts.id, codeFact.id)); 310 + } else { 311 + let { v7 } = await import("uuid"); 312 + await tx.insert(facts).values({ 313 + id: v7(), 314 + entity: blockId, 315 + attribute: "block/code", 316 + data: sql`${JSON.stringify({ type: "string", value: newCode })}::jsonb`, 317 + }); 318 + } 319 + } 320 + 321 + // Handle language update 322 + if (body.language !== undefined) { 323 + let [langFact] = await tx 324 + .select({ id: facts.id }) 325 + .from(facts) 326 + .where( 327 + and( 328 + eq(facts.entity, blockId), 329 + eq(facts.attribute, "block/code-language"), 330 + ), 331 + ); 332 + 333 + if (body.language === null || body.language === "") { 334 + // Remove language 335 + if (langFact) { 336 + await tx.delete(facts).where(eq(facts.id, langFact.id)); 337 + } 338 + } else { 339 + let langData = { type: "string", value: body.language }; 340 + if (langFact) { 341 + await tx 342 + .update(facts) 343 + .set({ data: sql`${JSON.stringify(langData)}::jsonb` }) 344 + .where(eq(facts.id, langFact.id)); 345 + } else { 346 + let { v7 } = await import("uuid"); 347 + await tx.insert(facts).values({ 348 + id: v7(), 349 + entity: blockId, 350 + attribute: "block/code-language", 351 + data: sql`${JSON.stringify(langData)}::jsonb`, 352 + }); 353 + } 354 + } 355 + } 356 + 357 + result = { blockId, newText: newCode }; 358 + } else { 359 + throw Response.json( 360 + { error: `Cannot edit blocks of type '${blockType}'` }, 361 + { status: 400 }, 362 + ); 363 + } 364 + }); 365 + 366 + await broadcastPoke(auth.rootEntity); 367 + return Response.json(result); 368 + } catch (e) { 369 + if (e instanceof Response) return e; 370 + console.error("AI API patch error:", e); 371 + return Response.json({ error: "Internal error" }, { status: 500 }); 372 + } finally { 373 + client.release(); 374 + } 375 + }
+242
app/api/ai/blocks/route.ts
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/node-postgres"; 3 + import { sql, eq } from "drizzle-orm"; 4 + import { pool } from "supabase/pool"; 5 + import { permission_token_rights } from "drizzle/schema"; 6 + import { cachedServerMutationContext } from "src/replicache/cachedServerMutationContext"; 7 + import { generateKeyBetween } from "fractional-indexing"; 8 + import { v7 } from "uuid"; 9 + import { 10 + authenticateToken, 11 + resolvePageEntity, 12 + getPageBlocks, 13 + createYjsText, 14 + broadcastPoke, 15 + tokenHash, 16 + hasWriteAccess, 17 + } from "../lib"; 18 + 19 + type BlockInput = 20 + | { type: "text"; content: string } 21 + | { type: "heading"; content: string; level?: number } 22 + | { type: "code"; content: string; language?: string } 23 + | { type: "blockquote"; content: string } 24 + | { type: "horizontal-rule" }; 25 + 26 + type PositionInput = 27 + | "start" 28 + | "end" 29 + | { after: string } 30 + | { before: string }; 31 + 32 + export async function POST(req: NextRequest) { 33 + let auth = await authenticateToken(req); 34 + if (auth instanceof Response) return auth; 35 + 36 + if (!hasWriteAccess(auth)) { 37 + return Response.json({ error: "No write access" }, { status: 403 }); 38 + } 39 + 40 + let body: { page?: string; position: PositionInput; blocks: BlockInput[] }; 41 + try { 42 + body = await req.json(); 43 + } catch { 44 + return Response.json({ error: "Invalid JSON" }, { status: 400 }); 45 + } 46 + 47 + if (!body.blocks || !Array.isArray(body.blocks) || body.blocks.length === 0) { 48 + return Response.json({ error: "blocks array required" }, { status: 400 }); 49 + } 50 + if (!body.position) { 51 + return Response.json({ error: "position required" }, { status: 400 }); 52 + } 53 + 54 + let client = await pool.connect(); 55 + try { 56 + let db = drizzle(client); 57 + let createdBlocks: { blockId: string; type: string }[] = []; 58 + 59 + await db.transaction(async (tx) => { 60 + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); 61 + 62 + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, body.page); 63 + if (pageEntity instanceof Response) throw pageEntity; 64 + 65 + let token_rights = await tx 66 + .select() 67 + .from(permission_token_rights) 68 + .where(eq(permission_token_rights.token, auth.tokenId)); 69 + 70 + let { getContext, flush } = cachedServerMutationContext( 71 + tx, 72 + auth.tokenId, 73 + token_rights, 74 + ); 75 + let ctx = getContext("ai-api", 0); 76 + 77 + let existingBlocks = await getPageBlocks(tx, pageEntity as string); 78 + let sorted = existingBlocks.sort((a, b) => 79 + a.position > b.position ? 1 : -1, 80 + ); 81 + 82 + // Compute initial position based on body.position 83 + let currentPosition: string; 84 + let pos = body.position; 85 + 86 + if (pos === "start") { 87 + currentPosition = generateKeyBetween( 88 + null, 89 + sorted[0]?.position || null, 90 + ); 91 + } else if (pos === "end") { 92 + currentPosition = generateKeyBetween( 93 + sorted[sorted.length - 1]?.position || null, 94 + null, 95 + ); 96 + } else if ("after" in pos) { 97 + let targetIdx = sorted.findIndex((b) => b.value === pos.after); 98 + if (targetIdx === -1) { 99 + throw Response.json({ error: "Block not found for 'after'" }, { status: 404 }); 100 + } 101 + currentPosition = generateKeyBetween( 102 + sorted[targetIdx].position, 103 + sorted[targetIdx + 1]?.position || null, 104 + ); 105 + } else if ("before" in pos) { 106 + let targetIdx = sorted.findIndex((b) => b.value === pos.before); 107 + if (targetIdx === -1) { 108 + throw Response.json({ error: "Block not found for 'before'" }, { status: 404 }); 109 + } 110 + currentPosition = generateKeyBetween( 111 + sorted[targetIdx - 1]?.position || null, 112 + sorted[targetIdx].position, 113 + ); 114 + } else { 115 + throw Response.json({ error: "Invalid position" }, { status: 400 }); 116 + } 117 + 118 + // Track the next position boundary for chaining 119 + let nextBound: string | null = null; 120 + if (pos === "start" && sorted.length > 0) { 121 + nextBound = sorted[0].position; 122 + } else if (typeof pos === "object" && "before" in pos) { 123 + let targetIdx = sorted.findIndex((b) => b.value === pos.before); 124 + nextBound = sorted[targetIdx].position; 125 + } 126 + 127 + for (let i = 0; i < body.blocks.length; i++) { 128 + let block = body.blocks[i]; 129 + let newEntityID = v7(); 130 + let factID = v7(); 131 + 132 + // For subsequent blocks, chain after the previous position 133 + if (i > 0) { 134 + currentPosition = generateKeyBetween(currentPosition, nextBound); 135 + } 136 + 137 + await ctx.createEntity({ 138 + entityID: newEntityID, 139 + permission_set: auth.permissionSet!, 140 + }); 141 + 142 + await ctx.assertFact({ 143 + entity: pageEntity as string, 144 + id: factID, 145 + data: { 146 + type: "ordered-reference" as const, 147 + value: newEntityID, 148 + position: currentPosition, 149 + }, 150 + attribute: "card/block" as const, 151 + }); 152 + 153 + let blockType: string; 154 + 155 + if (block.type === "text") { 156 + blockType = "text"; 157 + await ctx.assertFact({ 158 + entity: newEntityID, 159 + data: { type: "block-type-union" as const, value: "text" }, 160 + attribute: "block/type" as const, 161 + }); 162 + await ctx.assertFact({ 163 + entity: newEntityID, 164 + data: { type: "text" as const, value: createYjsText(block.content) }, 165 + attribute: "block/text" as const, 166 + }); 167 + } else if (block.type === "heading") { 168 + blockType = "heading"; 169 + await ctx.assertFact({ 170 + entity: newEntityID, 171 + data: { type: "block-type-union" as const, value: "heading" }, 172 + attribute: "block/type" as const, 173 + }); 174 + await ctx.assertFact({ 175 + entity: newEntityID, 176 + data: { type: "text" as const, value: createYjsText(block.content) }, 177 + attribute: "block/text" as const, 178 + }); 179 + await ctx.assertFact({ 180 + entity: newEntityID, 181 + data: { type: "number" as const, value: block.level || 1 }, 182 + attribute: "block/heading-level" as const, 183 + }); 184 + } else if (block.type === "code") { 185 + blockType = "code"; 186 + await ctx.assertFact({ 187 + entity: newEntityID, 188 + data: { type: "block-type-union" as const, value: "code" }, 189 + attribute: "block/type" as const, 190 + }); 191 + await ctx.assertFact({ 192 + entity: newEntityID, 193 + data: { type: "string" as const, value: block.content }, 194 + attribute: "block/code" as const, 195 + }); 196 + if (block.language) { 197 + await ctx.assertFact({ 198 + entity: newEntityID, 199 + data: { type: "string" as const, value: block.language }, 200 + attribute: "block/code-language" as const, 201 + }); 202 + } 203 + } else if (block.type === "blockquote") { 204 + blockType = "blockquote"; 205 + await ctx.assertFact({ 206 + entity: newEntityID, 207 + data: { type: "block-type-union" as const, value: "blockquote" }, 208 + attribute: "block/type" as const, 209 + }); 210 + await ctx.assertFact({ 211 + entity: newEntityID, 212 + data: { type: "text" as const, value: createYjsText(block.content) }, 213 + attribute: "block/text" as const, 214 + }); 215 + } else if (block.type === "horizontal-rule") { 216 + blockType = "horizontal-rule"; 217 + await ctx.assertFact({ 218 + entity: newEntityID, 219 + data: { type: "block-type-union" as const, value: "horizontal-rule" }, 220 + attribute: "block/type" as const, 221 + }); 222 + } else { 223 + continue; 224 + } 225 + 226 + createdBlocks.push({ blockId: newEntityID, type: blockType }); 227 + } 228 + 229 + await flush(); 230 + }); 231 + 232 + await broadcastPoke(auth.rootEntity); 233 + 234 + return Response.json({ blocks: createdBlocks }); 235 + } catch (e) { 236 + if (e instanceof Response) return e; 237 + console.error("AI API blocks error:", e); 238 + return Response.json({ error: "Internal error" }, { status: 500 }); 239 + } finally { 240 + client.release(); 241 + } 242 + }
+138
app/api/ai/doc/route.ts
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/node-postgres"; 3 + import { pool } from "supabase/pool"; 4 + import { 5 + authenticateToken, 6 + resolvePageEntity, 7 + getPageBlocks, 8 + getAllFactsForEntities, 9 + blocksToMarkdown, 10 + extractPlaintext, 11 + } from "../lib"; 12 + 13 + export async function GET(req: NextRequest) { 14 + let auth = await authenticateToken(req); 15 + if (auth instanceof Response) return auth; 16 + 17 + let pageParam = req.nextUrl.searchParams.get("page"); 18 + 19 + let client = await pool.connect(); 20 + try { 21 + let db = drizzle(client); 22 + return await db.transaction(async (tx) => { 23 + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam); 24 + if (pageEntity instanceof Response) return pageEntity; 25 + 26 + let blocks = await getPageBlocks(tx, pageEntity); 27 + 28 + // Collect all entity IDs we need facts for 29 + let entityIds = new Set<string>(); 30 + for (let b of blocks) { 31 + entityIds.add(b.value); 32 + } 33 + let allFacts = await getAllFactsForEntities(tx, [...entityIds]); 34 + 35 + // For card blocks, also fetch subpage facts 36 + let subpages: { id: string; title: string }[] = []; 37 + for (let b of blocks) { 38 + if (b.type === "card") { 39 + let cardFacts = allFacts.filter( 40 + (f) => f.entity === b.value && f.attribute === "block/card", 41 + ); 42 + if (cardFacts[0]) { 43 + let cardEntityId = (cardFacts[0].data as any).value; 44 + entityIds.add(cardEntityId); 45 + } 46 + } 47 + } 48 + 49 + // Re-fetch with subpage entities included 50 + allFacts = await getAllFactsForEntities(tx, [...entityIds]); 51 + 52 + // Also fetch subpage block entities for titles 53 + let subpageBlockEntityIds = new Set<string>(); 54 + for (let b of blocks) { 55 + if (b.type === "card") { 56 + let cardFacts = allFacts.filter( 57 + (f) => f.entity === b.value && f.attribute === "block/card", 58 + ); 59 + if (cardFacts[0]) { 60 + let cardEntityId = (cardFacts[0].data as any).value; 61 + let blockRefs = allFacts 62 + .filter( 63 + (f) => 64 + f.entity === cardEntityId && f.attribute === "card/block", 65 + ) 66 + .sort( 67 + (a, b) => 68 + (a.data as any).position > (b.data as any).position ? 1 : -1, 69 + ); 70 + for (let ref of blockRefs) { 71 + subpageBlockEntityIds.add((ref.data as any).value); 72 + } 73 + } 74 + } 75 + } 76 + 77 + if (subpageBlockEntityIds.size > 0) { 78 + let subpageBlockFacts = await getAllFactsForEntities(tx, [ 79 + ...subpageBlockEntityIds, 80 + ]); 81 + allFacts = [...allFacts, ...subpageBlockFacts]; 82 + } 83 + 84 + // Build subpages list 85 + for (let b of blocks) { 86 + if (b.type === "card") { 87 + let cardFacts = allFacts.filter( 88 + (f) => f.entity === b.value && f.attribute === "block/card", 89 + ); 90 + if (cardFacts[0]) { 91 + let cardEntityId = (cardFacts[0].data as any).value; 92 + let blockRefs = allFacts 93 + .filter( 94 + (f) => 95 + f.entity === cardEntityId && f.attribute === "card/block", 96 + ) 97 + .sort( 98 + (a, b) => 99 + (a.data as any).position > (b.data as any).position ? 1 : -1, 100 + ); 101 + let title = ""; 102 + if (blockRefs[0]) { 103 + let firstBlockId = (blockRefs[0].data as any).value; 104 + let textFact = allFacts.find( 105 + (f) => 106 + f.entity === firstBlockId && f.attribute === "block/text", 107 + ); 108 + if (textFact) { 109 + title = extractPlaintext((textFact.data as any).value); 110 + } 111 + } 112 + subpages.push({ id: cardEntityId, title: title || "Untitled" }); 113 + } 114 + } 115 + } 116 + 117 + let markdown = await blocksToMarkdown(blocks, allFacts); 118 + 119 + // Extract document title from first heading 120 + let titleBlock = blocks.find( 121 + (b) => b.type === "heading" || b.type === "text", 122 + ); 123 + let title = ""; 124 + if (titleBlock) { 125 + let textFact = allFacts.find( 126 + (f) => f.entity === titleBlock.value && f.attribute === "block/text", 127 + ); 128 + if (textFact) { 129 + title = extractPlaintext((textFact.data as any).value); 130 + } 131 + } 132 + 133 + return Response.json({ title, markdown, subpages }); 134 + }); 135 + } finally { 136 + client.release(); 137 + } 138 + }
+609
app/api/ai/lib.tsx
··· 1 + import { createClient } from "@supabase/supabase-js"; 2 + import type { Database } from "supabase/database.types"; 3 + import { permission_tokens, permission_token_rights } from "drizzle/schema"; 4 + import { entities, facts } from "drizzle/schema"; 5 + import * as driz from "drizzle-orm"; 6 + import { PgTransaction } from "drizzle-orm/pg-core"; 7 + import * as Y from "yjs"; 8 + import * as base64 from "base64-js"; 9 + import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; 10 + import { Block } from "components/Blocks/Block"; 11 + import { parseBlocksToList, List } from "src/utils/parseBlocksToList"; 12 + import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 13 + 14 + // --- Auth --- 15 + 16 + export type AuthResult = { 17 + tokenId: string; 18 + rootEntity: string; 19 + tokenRights: { 20 + token: string; 21 + entity_set: string; 22 + read: boolean; 23 + write: boolean; 24 + create_token: boolean; 25 + change_entity_set: boolean; 26 + }[]; 27 + permissionSet: string | null; 28 + }; 29 + 30 + export async function authenticateToken( 31 + request: Request, 32 + ): Promise<AuthResult | Response> { 33 + let auth = request.headers.get("Authorization"); 34 + if (!auth || !auth.startsWith("Bearer ")) { 35 + return Response.json({ error: "Missing Authorization header" }, { status: 401 }); 36 + } 37 + let tokenId = auth.slice("Bearer ".length).trim(); 38 + if (!tokenId) { 39 + return Response.json({ error: "Invalid token" }, { status: 401 }); 40 + } 41 + 42 + let supabase = createClient<Database>( 43 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 44 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 45 + ); 46 + 47 + let { data: token } = await supabase 48 + .from("permission_tokens") 49 + .select("id, root_entity, blocked_by_admin") 50 + .eq("id", tokenId) 51 + .single(); 52 + 53 + if (!token) { 54 + return Response.json({ error: "Invalid token" }, { status: 401 }); 55 + } 56 + if (token.blocked_by_admin) { 57 + return Response.json({ error: "Token blocked" }, { status: 403 }); 58 + } 59 + 60 + let { data: rights } = await supabase 61 + .from("permission_token_rights") 62 + .select("token, entity_set, read, write, create_token, change_entity_set") 63 + .eq("token", tokenId); 64 + 65 + let tokenRights = rights || []; 66 + let permissionSet = 67 + tokenRights.find((r) => r.write)?.entity_set ?? null; 68 + 69 + return { 70 + tokenId, 71 + rootEntity: token.root_entity, 72 + tokenRights, 73 + permissionSet, 74 + }; 75 + } 76 + 77 + // --- Page resolution --- 78 + 79 + export async function resolvePageEntity( 80 + tx: PgTransaction<any, any, any>, 81 + rootEntity: string, 82 + pageParam?: string | null, 83 + ): Promise<string | Response> { 84 + let rootPageFacts = await tx 85 + .select({ data: facts.data }) 86 + .from(facts) 87 + .where( 88 + driz.and( 89 + driz.eq(facts.entity, rootEntity), 90 + driz.eq(facts.attribute, "root/page"), 91 + ), 92 + ); 93 + 94 + let mainPage = (rootPageFacts[0]?.data as any)?.value as string | undefined; 95 + if (!mainPage) { 96 + return Response.json({ error: "No main page found" }, { status: 404 }); 97 + } 98 + 99 + if (!pageParam) return mainPage; 100 + 101 + // Verify the requested page exists as an entity in this document 102 + let [pageEntity] = await tx 103 + .select({ id: entities.id }) 104 + .from(entities) 105 + .where(driz.eq(entities.id, pageParam)); 106 + 107 + if (!pageEntity) { 108 + return Response.json({ error: "Page not found" }, { status: 404 }); 109 + } 110 + 111 + return pageParam; 112 + } 113 + 114 + // --- Block fetching (server-side version of getBlocksWithTypeLocal) --- 115 + 116 + type FactRow = { 117 + id: string; 118 + entity: string; 119 + attribute: string; 120 + data: any; 121 + }; 122 + 123 + export async function getPageBlocks( 124 + tx: PgTransaction<any, any, any>, 125 + pageEntity: string, 126 + ): Promise<Block[]> { 127 + // Get all facts for this page's blocks in bulk 128 + let blockRefs = await tx 129 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 130 + .from(facts) 131 + .where( 132 + driz.and( 133 + driz.eq(facts.entity, pageEntity), 134 + driz.eq(facts.attribute, "card/block"), 135 + ), 136 + ); 137 + 138 + blockRefs.sort((a, b) => { 139 + let posA = (a.data as any).position; 140 + let posB = (b.data as any).position; 141 + if (posA === posB) return a.id > b.id ? 1 : -1; 142 + return posA > posB ? 1 : -1; 143 + }); 144 + 145 + if (blockRefs.length === 0) return []; 146 + 147 + // Collect all block entity IDs 148 + let blockEntityIds = blockRefs.map((r) => (r.data as any).value as string); 149 + 150 + // Fetch all facts for these block entities in one query 151 + let allBlockFacts = await tx 152 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 153 + .from(facts) 154 + .where(driz.inArray(facts.entity, blockEntityIds)); 155 + 156 + let factsByEntity = new Map<string, FactRow[]>(); 157 + for (let f of allBlockFacts) { 158 + let arr = factsByEntity.get(f.entity); 159 + if (!arr) { 160 + arr = []; 161 + factsByEntity.set(f.entity, arr); 162 + } 163 + arr.push(f); 164 + } 165 + 166 + let result: Block[] = []; 167 + 168 + for (let ref of blockRefs) { 169 + let blockEntityId = (ref.data as any).value as string; 170 + let blockFacts = factsByEntity.get(blockEntityId) || []; 171 + let typeFact = blockFacts.find((f) => f.attribute === "block/type"); 172 + if (!typeFact) continue; 173 + 174 + let isListFact = blockFacts.find((f) => f.attribute === "block/is-list"); 175 + if (isListFact && (isListFact.data as any).value) { 176 + let children = await getListChildren(tx, ref, pageEntity, 1, []); 177 + result.push(...children); 178 + } else { 179 + result.push({ 180 + value: blockEntityId, 181 + position: (ref.data as any).position, 182 + factID: ref.id, 183 + type: (typeFact.data as any).value, 184 + parent: pageEntity, 185 + }); 186 + } 187 + } 188 + 189 + computeDisplayNumbers(result); 190 + return result; 191 + } 192 + 193 + async function getListChildren( 194 + tx: PgTransaction<any, any, any>, 195 + root: FactRow, 196 + pageParent: string, 197 + depth: number, 198 + path: { depth: number; entity: string }[], 199 + ): Promise<Block[]> { 200 + let rootValue = (root.data as any).value as string; 201 + 202 + let childRefs = await tx 203 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 204 + .from(facts) 205 + .where( 206 + driz.and( 207 + driz.eq(facts.entity, rootValue), 208 + driz.eq(facts.attribute, "card/block"), 209 + ), 210 + ); 211 + childRefs.sort((a, b) => 212 + (a.data as any).position > (b.data as any).position ? 1 : -1, 213 + ); 214 + 215 + let rootFacts = await tx 216 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 217 + .from(facts) 218 + .where(driz.eq(facts.entity, rootValue)); 219 + 220 + let typeFact = rootFacts.find((f) => f.attribute === "block/type"); 221 + if (!typeFact) return []; 222 + 223 + let listStyleFact = rootFacts.find((f) => f.attribute === "block/list-style"); 224 + let listNumberFact = rootFacts.find((f) => f.attribute === "block/list-number"); 225 + 226 + let newPath = [...path, { entity: rootValue, depth }]; 227 + 228 + let childBlocks: Block[] = []; 229 + for (let c of childRefs) { 230 + let children = await getListChildren(tx, c, rootValue, depth + 1, newPath); 231 + childBlocks.push(...children); 232 + } 233 + 234 + return [ 235 + { 236 + value: rootValue, 237 + position: (root.data as any).position, 238 + factID: root.id, 239 + type: (typeFact.data as any).value, 240 + parent: pageParent, 241 + listData: { 242 + depth, 243 + parent: root.entity, 244 + path: newPath, 245 + listStyle: listStyleFact ? (listStyleFact.data as any).value : undefined, 246 + listStart: listNumberFact ? (listNumberFact.data as any).value : undefined, 247 + }, 248 + }, 249 + ...childBlocks, 250 + ]; 251 + } 252 + 253 + function computeDisplayNumbers(blocks: Block[]): void { 254 + let counters = new Map<string, number>(); 255 + for (let block of blocks) { 256 + if (!block.listData) { 257 + counters.clear(); 258 + continue; 259 + } 260 + if (block.listData.listStyle !== "ordered") continue; 261 + let parent = block.listData.parent; 262 + if (block.listData.listStart !== undefined) { 263 + counters.set(parent, block.listData.listStart); 264 + } else if (!counters.has(parent)) { 265 + counters.set(parent, 1); 266 + } 267 + block.listData.displayNumber = counters.get(parent)!; 268 + counters.set(parent, counters.get(parent)! + 1); 269 + } 270 + } 271 + 272 + // --- Server-side YJS to HTML rendering --- 273 + 274 + function escapeHtml(s: string): string { 275 + return s 276 + .replace(/&/g, "&amp;") 277 + .replace(/</g, "&lt;") 278 + .replace(/>/g, "&gt;") 279 + .replace(/"/g, "&quot;"); 280 + } 281 + 282 + function renderYjsToHTML( 283 + base64Value: string, 284 + wrapper: "p" | "h1" | "h2" | "h3" | "blockquote", 285 + attrs?: Record<string, string>, 286 + ): string { 287 + let attrStr = attrs 288 + ? Object.entries(attrs) 289 + .filter(([, v]) => v !== undefined) 290 + .map(([k, v]) => ` ${k}="${escapeHtml(v)}"`) 291 + .join("") 292 + : ""; 293 + 294 + if (!base64Value) return `<${wrapper}${attrStr}></${wrapper}>`; 295 + 296 + let doc = new Y.Doc(); 297 + Y.applyUpdate(doc, base64.toByteArray(base64Value)); 298 + let [node] = doc.getXmlElement("prosemirror").toArray(); 299 + if (!node || node.constructor !== Y.XmlElement) return `<${wrapper}${attrStr}></${wrapper}>`; 300 + 301 + let children = node.toArray(); 302 + if (children.length === 0) return `<${wrapper}${attrStr}><br/></${wrapper}>`; 303 + 304 + let inner = children 305 + .map((child) => { 306 + if (child.constructor === Y.XmlText) { 307 + let deltas = child.toDelta() as { insert: string; attributes?: any }[]; 308 + if (deltas.length === 0) return "<br/>"; 309 + return deltas 310 + .map((d) => { 311 + let text = escapeHtml(d.insert); 312 + if (d.attributes?.link) return `<a href="${escapeHtml(d.attributes.link.href)}">${text}</a>`; 313 + if (d.attributes?.strong) text = `<strong>${text}</strong>`; 314 + if (d.attributes?.em) text = `<em>${text}</em>`; 315 + if (d.attributes?.code) text = `<code>${text}</code>`; 316 + return text; 317 + }) 318 + .join(""); 319 + } 320 + if (child.constructor === Y.XmlElement) { 321 + if (child.nodeName === "hard_break") return "<br/>"; 322 + if (child.nodeName === "didMention" || child.nodeName === "atMention") { 323 + let text = child.getAttribute("text") || ""; 324 + return escapeHtml(text); 325 + } 326 + } 327 + return ""; 328 + }) 329 + .join(""); 330 + 331 + return `<${wrapper}${attrStr}>${inner}</${wrapper}>`; 332 + } 333 + 334 + // --- Block-to-HTML (server-side, reads from pre-fetched facts) --- 335 + 336 + export async function getAllFactsForEntities( 337 + tx: PgTransaction<any, any, any>, 338 + entityIds: string[], 339 + ): Promise<FactRow[]> { 340 + if (entityIds.length === 0) return []; 341 + return tx 342 + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) 343 + .from(facts) 344 + .where(driz.inArray(facts.entity, entityIds)); 345 + } 346 + 347 + function factsLookup(allFacts: FactRow[], entity: string, attribute: string): FactRow[] { 348 + return allFacts.filter((f) => f.entity === entity && f.attribute === attribute); 349 + } 350 + 351 + async function renderBlockToHTML( 352 + b: Block, 353 + allFacts: FactRow[], 354 + ): Promise<string> { 355 + let [alignment] = factsLookup(allFacts, b.value, "block/text-alignment"); 356 + let a = alignment ? (alignment.data as any).value : undefined; 357 + 358 + switch (b.type) { 359 + case "text": { 360 + let [value] = factsLookup(allFacts, b.value, "block/text"); 361 + return renderYjsToHTML(value?.data.value, "p", a ? { "data-alignment": a } : undefined); 362 + } 363 + case "heading": { 364 + let [value] = factsLookup(allFacts, b.value, "block/text"); 365 + let [headingLevel] = factsLookup(allFacts, b.value, "block/heading-level"); 366 + let wrapper = ("h" + ((headingLevel?.data as any)?.value || 1)) as "h1" | "h2" | "h3"; 367 + return renderYjsToHTML(value?.data.value, wrapper, a ? { "data-alignment": a } : undefined); 368 + } 369 + case "blockquote": { 370 + let [value] = factsLookup(allFacts, b.value, "block/text"); 371 + return renderYjsToHTML(value?.data.value, "blockquote", a ? { "data-alignment": a } : undefined); 372 + } 373 + case "code": { 374 + let [code] = factsLookup(allFacts, b.value, "block/code"); 375 + let [lang] = factsLookup(allFacts, b.value, "block/code-language"); 376 + let langValue = (lang?.data as any)?.value as string | undefined; 377 + let codeAttr = langValue ? ` class="language-${escapeHtml(langValue)}"` : ""; 378 + return `<pre><code${codeAttr}>${escapeHtml((code?.data as any)?.value || "")}</code></pre>`; 379 + } 380 + case "image": { 381 + let [src] = factsLookup(allFacts, b.value, "block/image"); 382 + if (!src) return ""; 383 + let alignAttr = a ? ` data-alignment="${escapeHtml(a)}"` : ""; 384 + return `<img src="${escapeHtml((src.data as any).src)}"${alignAttr}/>`; 385 + } 386 + case "horizontal-rule": 387 + return "<hr/>"; 388 + case "card": { 389 + let [card] = factsLookup(allFacts, b.value, "block/card"); 390 + if (!card) return ""; 391 + let cardEntityId = (card.data as any).value; 392 + let title = await getSubpageTitle(allFacts, cardEntityId); 393 + return `<a href="subpage:${cardEntityId}">${escapeHtml(title || "Untitled")}</a>`; 394 + } 395 + case "link": { 396 + let [url] = factsLookup(allFacts, b.value, "link/url"); 397 + let [title] = factsLookup(allFacts, b.value, "link/title"); 398 + if (!url) return ""; 399 + return `<a href="${escapeHtml((url.data as any).value)}" target="_blank">${escapeHtml((title?.data as any)?.value || "")}</a>`; 400 + } 401 + case "button": { 402 + let [text] = factsLookup(allFacts, b.value, "button/text"); 403 + let [url] = factsLookup(allFacts, b.value, "button/url"); 404 + if (!text || !url) return ""; 405 + return `<a href="${escapeHtml((url.data as any).value)}">${escapeHtml((text.data as any).value)}</a>`; 406 + } 407 + case "math": { 408 + let [math] = factsLookup(allFacts, b.value, "block/math"); 409 + return `<code>${escapeHtml((math?.data as any)?.value || "")}</code>`; 410 + } 411 + default: 412 + return ""; 413 + } 414 + } 415 + 416 + async function getSubpageTitle( 417 + allFacts: FactRow[], 418 + cardEntityId: string, 419 + ): Promise<string> { 420 + // Look for card/block children of the subpage to find first heading 421 + let blockRefs = allFacts 422 + .filter((f) => f.entity === cardEntityId && f.attribute === "card/block") 423 + .sort((a, b) => ((a.data as any).position > (b.data as any).position ? 1 : -1)); 424 + 425 + if (blockRefs.length === 0) return ""; 426 + 427 + let firstBlockId = (blockRefs[0].data as any).value; 428 + let [textFact] = allFacts.filter( 429 + (f) => f.entity === firstBlockId && f.attribute === "block/text", 430 + ); 431 + 432 + if (textFact) { 433 + return extractPlaintext((textFact.data as any).value); 434 + } 435 + return ""; 436 + } 437 + 438 + async function renderListToHTML(l: List, allFacts: FactRow[]): Promise<string> { 439 + let children = ( 440 + await Promise.all(l.children.map((c) => renderListToHTML(c, allFacts))) 441 + ).join("\n"); 442 + 443 + let checkedFacts = factsLookup(allFacts, l.block.value, "block/check-list"); 444 + let checked = checkedFacts[0]; 445 + 446 + let isOrdered = l.children[0]?.block.listData?.listStyle === "ordered"; 447 + let tag = isOrdered ? "ol" : "ul"; 448 + 449 + return `<li ${checked ? `data-checked=${(checked.data as any).value}` : ""}>${await renderBlockToHTML(l.block, allFacts)} ${ 450 + l.children.length > 0 ? `<${tag}>${children}</${tag}>` : "" 451 + }</li>`; 452 + } 453 + 454 + export async function blocksToHTML( 455 + blocks: Block[], 456 + allFacts: FactRow[], 457 + ): Promise<string[]> { 458 + let result: string[] = []; 459 + let parsed = parseBlocksToList(blocks); 460 + 461 + for (let pb of parsed) { 462 + if (pb.type === "block") { 463 + result.push(await renderBlockToHTML(pb.block, allFacts)); 464 + } else { 465 + let isOrdered = pb.children[0]?.block.listData?.listStyle === "ordered"; 466 + let tag = isOrdered ? "ol" : "ul"; 467 + let listItems = ( 468 + await Promise.all( 469 + pb.children.map((c) => renderListToHTML(c, allFacts)), 470 + ) 471 + ).join("\n"); 472 + result.push(`<${tag}>${listItems}</${tag}>`); 473 + } 474 + } 475 + return result; 476 + } 477 + 478 + // --- Blocks-to-markdown --- 479 + 480 + export async function blocksToMarkdown( 481 + blocks: Block[], 482 + allFacts: FactRow[], 483 + ): Promise<string> { 484 + let htmlParts = await blocksToHTML(blocks, allFacts); 485 + let html = htmlParts.join("\n"); 486 + return htmlToMarkdown(html); 487 + } 488 + 489 + // --- YJS plaintext extraction --- 490 + 491 + export function extractPlaintext(base64Value: string): string { 492 + if (!base64Value) return ""; 493 + let doc = new Y.Doc(); 494 + Y.applyUpdate(doc, base64.toByteArray(base64Value)); 495 + let nodes = doc.getXmlElement("prosemirror").toArray(); 496 + if (nodes.length === 0) return ""; 497 + return YJSFragmentToString(nodes[0]); 498 + } 499 + 500 + // --- YJS text creation --- 501 + 502 + export function createYjsText(plaintext: string): string { 503 + let doc = new Y.Doc(); 504 + let fragment = doc.getXmlFragment("prosemirror"); 505 + let paragraph = new Y.XmlElement("paragraph"); 506 + let textNode = new Y.XmlText(); 507 + textNode.insert(0, plaintext); 508 + paragraph.insert(0, [textNode]); 509 + fragment.insert(0, [paragraph]); 510 + return base64.fromByteArray(Y.encodeStateAsUpdate(doc)); 511 + } 512 + 513 + // --- YJS text editing --- 514 + 515 + export type EditOperation = 516 + | { type: "replace"; content: string } 517 + | { type: "insert"; position: "start" | "end"; content: string } 518 + | { type: "insert"; position: { before: string } | { after: string }; content: string }; 519 + 520 + export function editYjsText( 521 + existingBase64: string, 522 + operation: EditOperation, 523 + ): { result: string; plaintext: string } | { error: "search_not_found"; fullText: string } { 524 + let doc = new Y.Doc(); 525 + Y.applyUpdate(doc, base64.toByteArray(existingBase64)); 526 + 527 + let element = doc.getXmlElement("prosemirror"); 528 + let paragraph = element.toArray()[0]; 529 + if (!paragraph || paragraph.constructor !== Y.XmlElement) { 530 + return { error: "search_not_found", fullText: "" }; 531 + } 532 + 533 + // Find the XmlText child 534 + let textNodes = paragraph.toArray(); 535 + let xmlText: Y.XmlText | null = null; 536 + for (let n of textNodes) { 537 + if (n.constructor === Y.XmlText) { 538 + xmlText = n; 539 + break; 540 + } 541 + } 542 + 543 + if (!xmlText) { 544 + // No text node exists yet, create one for replace/insert 545 + xmlText = new Y.XmlText(); 546 + paragraph.insert(0, [xmlText]); 547 + } 548 + 549 + let currentText = (xmlText.toDelta() as { insert: string }[]) 550 + .map((d) => d.insert) 551 + .join(""); 552 + 553 + if (operation.type === "replace") { 554 + xmlText.delete(0, currentText.length); 555 + xmlText.insert(0, operation.content); 556 + } else if (operation.type === "insert") { 557 + let pos = operation.position; 558 + if (pos === "start") { 559 + xmlText.insert(0, operation.content); 560 + } else if (pos === "end") { 561 + xmlText.insert(currentText.length, operation.content); 562 + } else if ("before" in pos) { 563 + let idx = currentText.indexOf(pos.before); 564 + if (idx === -1) return { error: "search_not_found", fullText: currentText }; 565 + xmlText.insert(idx, operation.content); 566 + } else if ("after" in pos) { 567 + let idx = currentText.indexOf(pos.after); 568 + if (idx === -1) return { error: "search_not_found", fullText: currentText }; 569 + xmlText.insert(idx + pos.after.length, operation.content); 570 + } 571 + } 572 + 573 + let newText = (xmlText.toDelta() as { insert: string }[]) 574 + .map((d) => d.insert) 575 + .join(""); 576 + 577 + return { 578 + result: base64.fromByteArray(Y.encodeStateAsUpdate(doc)), 579 + plaintext: newText, 580 + }; 581 + } 582 + 583 + // --- Realtime poke --- 584 + 585 + export async function broadcastPoke(rootEntity: string) { 586 + let supabase = createClient<Database>( 587 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 588 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 589 + ); 590 + let channel = supabase.channel(`rootEntity:${rootEntity}`); 591 + await channel.send({ 592 + type: "broadcast", 593 + event: "poke", 594 + payload: { message: "poke" }, 595 + }); 596 + await supabase.removeChannel(channel); 597 + } 598 + 599 + // --- Helpers --- 600 + 601 + export function tokenHash(tokenId: string): number { 602 + return tokenId.split("").reduce((acc, char) => { 603 + return ((acc << 5) - acc + char.charCodeAt(0)) | 0; 604 + }, 0); 605 + } 606 + 607 + export function hasWriteAccess(auth: AuthResult): boolean { 608 + return auth.tokenRights.some((r) => r.write); 609 + }
+87
app/api/ai/search/route.ts
··· 1 + import { NextRequest } from "next/server"; 2 + import { drizzle } from "drizzle-orm/node-postgres"; 3 + import { pool } from "supabase/pool"; 4 + import { 5 + authenticateToken, 6 + resolvePageEntity, 7 + getPageBlocks, 8 + getAllFactsForEntities, 9 + extractPlaintext, 10 + } from "../lib"; 11 + 12 + export async function GET(req: NextRequest) { 13 + let auth = await authenticateToken(req); 14 + if (auth instanceof Response) return auth; 15 + 16 + let query = req.nextUrl.searchParams.get("q"); 17 + if (!query) { 18 + return Response.json({ error: "Missing q parameter" }, { status: 400 }); 19 + } 20 + 21 + let pageParam = req.nextUrl.searchParams.get("page"); 22 + let queryLower = query.toLowerCase(); 23 + 24 + let client = await pool.connect(); 25 + try { 26 + let db = drizzle(client); 27 + return await db.transaction(async (tx) => { 28 + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam); 29 + if (pageEntity instanceof Response) return pageEntity; 30 + 31 + let blocks = await getPageBlocks(tx, pageEntity); 32 + let entityIds = blocks.map((b) => b.value); 33 + let allFacts = await getAllFactsForEntities(tx, entityIds); 34 + 35 + let results: { 36 + blockId: string; 37 + type: string; 38 + text: string; 39 + language?: string; 40 + }[] = []; 41 + 42 + for (let b of blocks) { 43 + if ( 44 + b.type === "text" || 45 + b.type === "heading" || 46 + b.type === "blockquote" 47 + ) { 48 + let textFact = allFacts.find( 49 + (f) => f.entity === b.value && f.attribute === "block/text", 50 + ); 51 + if (textFact) { 52 + let plaintext = extractPlaintext((textFact.data as any).value); 53 + if (plaintext.toLowerCase().includes(queryLower)) { 54 + results.push({ blockId: b.value, type: b.type, text: plaintext }); 55 + } 56 + } 57 + } else if (b.type === "code") { 58 + let codeFact = allFacts.find( 59 + (f) => f.entity === b.value && f.attribute === "block/code", 60 + ); 61 + if (codeFact) { 62 + let code = (codeFact.data as any).value as string; 63 + if (code.toLowerCase().includes(queryLower)) { 64 + let langFact = allFacts.find( 65 + (f) => 66 + f.entity === b.value && f.attribute === "block/code-language", 67 + ); 68 + let language = langFact 69 + ? ((langFact.data as any).value as string) 70 + : undefined; 71 + results.push({ 72 + blockId: b.value, 73 + type: b.type, 74 + text: code, 75 + ...(language ? { language } : {}), 76 + }); 77 + } 78 + } 79 + } 80 + } 81 + 82 + return Response.json({ results }); 83 + }); 84 + } finally { 85 + client.release(); 86 + } 87 + }