diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..919df1a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + "configurations": [ + { + "name": "Attach V8 Inspector", + "type": "node", + "address": "127.0.0.1", + "request": "attach", + + // This is important: You must enter the path where your edge functions + // folders are located. + "localRoot": "${workspaceFolder}/supabase/functions/", + + // CLI mounts all functions under /home/deno/functions + "remoteRoot": "/home/deno/functions/", + + // As you can see from the two options above, VsCode tries to locate the script + // paths being used by the inspector session locally by matching local and remote + // paths. + // + // If a valid path cannot be found on either side, or the script contents of + // those paths do not match each other, the breakpoint setting may not proceed + // correctly. + // + // We set the two options below for this. + "sourceMaps": true, + "enableContentValidation": false, + + // Properties below are optional. + "restart": true, + "timeout": 1000000, + + // Inspector is accepting sessions on 8083 by default. + "port": 8083 + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/deno.lock b/deno.lock index 0967f08..c50e4d8 100644 --- a/deno.lock +++ b/deno.lock @@ -1,12 +1,53 @@ { "version": "5", "specifiers": { + "jsr:@openai/openai@*": "6.16.0", + "jsr:@supabase/functions-js@*": "2.90.1", "npm:@supabase/supabase-js@^2.90.1": "2.90.1", "npm:@types/bun@^1.3.5": "1.3.5", + "npm:deno@^2.6.4": "2.6.4", "npm:supabase-js@^1.0.4": "1.0.4", "npm:supabase@^2.72.6": "2.72.6" }, + "jsr": { + "@openai/openai@6.16.0": { + "integrity": "ccee548f61c382d715091fff0c2c3390a4487823644430b8235df543d6d6a78b" + }, + "@supabase/functions-js@2.90.1": { + "integrity": "9a077ecf42aa84594ef3aac621d643e15ffaa9506a4c051e35c93f127ffdb035" + } + }, "npm": { + "@deno/darwin-arm64@2.6.4": { + "integrity": "sha512-5pocuV2IVWYUzomXze+bDSO86uX1WPxUn82BS7BrLI3ix8Eq7l8DhvYgz064w7rwCM4CiLLSc6MAQpAI+0gUag==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@deno/darwin-x64@2.6.4": { + "integrity": "sha512-tX7Fz47HcoXTYR4wN2WzXjqJtEPTftzjEknCYkZa8D4BzvpN7Zo1ra3Jd6bWPN9+T6mgTL6YMPMMSigXddIUcA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@deno/linux-arm64-glibc@2.6.4": { + "integrity": "sha512-wY4hjjVLq5idClWj6e1de9IUZ0QZ1rxo16o0dwCJpMi+w1lolxtYxGF4y7pkcxAdZOpMGsXZuYMPq1Z2n51esA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@deno/linux-x64-glibc@2.6.4": { + "integrity": "sha512-H9b0WJw+eWyC6GcMGCXxC/3lz2kVveyWyUrzGyQ8hJmtDJpWiDnCU2iPqEpEcwXHgAUFFyjnRaFwedwwv5ApdA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@deno/win32-arm64@2.6.4": { + "integrity": "sha512-fMH1dx54ORbCTpMorGjDshwonIu6KfgfPC+Pq+14jeq/8604EIFXiA484p3Ov+ViZycndNHEPe8ZKPNd8xLjJA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@deno/win32-x64@2.6.4": { + "integrity": "sha512-hAxqNrBLHCINtqDgKcRiu8QtMbtdnHOVW3OFQjzA3gZEcpnGUK3JiF5ql++83SjuQH8ViZKGcdqkioLR7Bf14A==", + "os": ["win32"], + "cpu": ["x64"] + }, "@isaacs/fs-minipass@4.0.1": { "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dependencies": [ @@ -112,6 +153,19 @@ "ms" ] }, + "deno@2.6.4": { + "integrity": "sha512-/nHUtrwoqUsjVTBWGqz3SjAbtBSfqCLb38YdkwstrO5qpQd3RC20dxsGYPPANy9RqMFJMfDyARL7RtOcUfatpw==", + "optionalDependencies": [ + "@deno/darwin-arm64", + "@deno/darwin-x64", + "@deno/linux-arm64-glibc", + "@deno/linux-x64-glibc", + "@deno/win32-arm64", + "@deno/win32-x64" + ], + "scripts": true, + "bin": true + }, "fetch-blob@3.2.0": { "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "dependencies": [ @@ -227,6 +281,7 @@ "dependencies": [ "npm:@supabase/supabase-js@^2.90.1", "npm:@types/bun@^1.3.5", + "npm:deno@^2.6.4", "npm:supabase-js@^1.0.4", "npm:supabase@^2.72.6" ] diff --git a/files/carta.pdf b/files/carta.pdf new file mode 100644 index 0000000..0f0af37 Binary files /dev/null and b/files/carta.pdf differ diff --git a/package.json b/package.json index 033340f..ded9479 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@supabase/supabase-js": "^2.90.1", + "deno": "^2.6.4", "supabase": "^2.72.6", "supabase-js": "^1.0.4" }, diff --git a/supabase/functions/ai-structured/index.ts b/supabase/functions/ai-structured/index.ts index c72dea8..3754010 100644 --- a/supabase/functions/ai-structured/index.ts +++ b/supabase/functions/ai-structured/index.ts @@ -1,7 +1,7 @@ // supabase/functions/ai-structured/index.ts -import OpenAI from'jsr:@openai/openai'; -import 'jsr:@supabase/functions-js/edge-runtime.d.ts' -import { createClient } from 'npm:@supabase/supabase-js@2' +import OpenAI from "npm:openai"; +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "npm:@supabase/supabase-js@2"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -9,6 +9,8 @@ const corsHeaders = { "authorization, x-client-info, apikey, content-type", }; +const MB = 1024 * 1024; + function json(body: unknown, status = 200) { return new Response(JSON.stringify(body, null, 2), { status, @@ -45,9 +47,12 @@ function normalizeInputWithOpenAIFileIds(input: any, fileIds: string[]) { if (!fileIds?.length) return input; // Responses API acepta input como string o array. - const items = Array.isArray(input) - ? [...input] - : [{ role: "user", content: input ?? "" }]; + let items = Array.isArray(input) ? [...input] : [{ + role: "user", + content: [ + input ?? "", + ], + }]; // Busca último mensaje "user" let idx = -1; @@ -58,39 +63,72 @@ function normalizeInputWithOpenAIFileIds(input: any, fileIds: string[]) { } } if (idx === -1) { - items.push({ role: "user", content: "" }); + items.push({ role: "user", content: [] }); idx = items.length - 1; } const msg = items[idx]; const fileParts = fileIds.map((id) => ({ type: "input_file", file_id: id })); + // Normalize content to array format if (typeof msg.content === "string") { msg.content = [{ type: "input_text", text: msg.content }, ...fileParts]; } else if (Array.isArray(msg.content)) { msg.content = [...msg.content, ...fileParts]; } else { // fallback raro - msg.content = [{ type: "input_text", text: String(msg.content) }, ...fileParts]; + msg.content = [ + { type: "input_text", text: String(msg.content) }, + ...fileParts, + ]; } items[idx] = msg; return items; } +function normalizeInputUser(input: any) { + if (typeof input === "string") { + return [{ + role: "user", + content: [{ + type: "input_text", + text: input, + }], + }]; + } else if (Array.isArray(input)) { + return input; + } else { + return [{ + role: "user", + content: [{ + type: "input_text", + text: String(input), + }], + }]; + } +} + Deno.serve(async (req) => { - if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders }); + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); + } try { // 1) Auth requerido ✅ - const authHeader = - req.headers.get("Authorization") ?? req.headers.get("authorization"); - if (!authHeader) return json({ ok: false, error: "Missing Authorization header" }, 401); + const authHeader = req.headers.get("Authorization") ?? + req.headers.get("authorization"); + if (!authHeader) { + return json({ ok: false, error: "Missing Authorization header" }, 401); + } const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { - return json({ ok: false, error: "Missing SUPABASE_URL / SUPABASE_ANON_KEY" }, 500); + return json({ + ok: false, + error: "Missing SUPABASE_URL / SUPABASE_ANON_KEY", + }, 500); } // Cliente con el JWT del usuario (para validar quién llama) @@ -98,7 +136,8 @@ Deno.serve(async (req) => { global: { headers: { Authorization: authHeader } }, }); - const { data: userData, error: userErr } = await supabaseUser.auth.getUser(); + const { data: userData, error: userErr } = await supabaseUser.auth + .getUser(); if (userErr || !userData?.user) { return json({ ok: false, error: "Invalid token" }, 401); } @@ -116,17 +155,32 @@ Deno.serve(async (req) => { let tempFiles: File[] = []; if (contentType.includes("multipart/form-data")) { - const form = await req.formData(); - const raw = form.get("payload")?.toString() ?? "{}"; + let form; + try { + form = await req.formData(); + } catch (error) { + return json({ + ok: false, + error: "Invalid multipart/form-data body", + details: error, + }, 400); + } + const raw = form.get("payload") ?? "{}"; payload = JSON.parse(raw); // Soporta nombres comunes - for (const key of ["files", "files[]", "archivos", "archivosTemporales"]) { - for (const v of form.getAll(key)) if (v instanceof File) tempFiles.push(v); + for ( + const key of ["files", "files[]", "archivos", "archivosTemporales"] + ) { + for (const v of form.getAll(key)) { + if (v instanceof File) tempFiles.push(v); + } } // Fallback: toma cualquier File en el form if (tempFiles.length === 0) { - for (const [, v] of form.entries()) if (v instanceof File) tempFiles.push(v); + for (const [, v] of form.entries()) { + if (v instanceof File) tempFiles.push(v); + } } } else { payload = await req.json(); @@ -140,11 +194,24 @@ Deno.serve(async (req) => { }; // Conveniencia top-level - if (payload.input !== undefined && responseCfg.input === undefined) responseCfg.input = payload.input; + if (payload.input !== undefined && responseCfg.input === undefined) { + responseCfg.input = payload.input; + } + // Normaliza input a array de mensajes + if (responseCfg.input !== undefined) { + responseCfg.input = normalizeInputUser(responseCfg.input); + } if (payload.model && !responseCfg.model) responseCfg.model = payload.model; - if (payload.conversationId && responseCfg.conversation === undefined) responseCfg.conversation = payload.conversationId; - if (payload.previousResponseId && responseCfg.previous_response_id === undefined) responseCfg.previous_response_id = payload.previousResponseId; - if (payload.reasoning && responseCfg.reasoning === undefined) responseCfg.reasoning = payload.reasoning; + if (payload.conversationId && responseCfg.conversation === undefined) { + responseCfg.conversation = payload.conversationId; + } + if ( + payload.previousResponseId && + responseCfg.previous_response_id === undefined + ) responseCfg.previous_response_id = payload.previousResponseId; + if (payload.reasoning && responseCfg.reasoning === undefined) { + responseCfg.reasoning = payload.reasoning; + } // Defaults responseCfg.model = responseCfg.model ?? "gpt-5"; @@ -152,21 +219,32 @@ Deno.serve(async (req) => { // Regla API: no mezclar conversation + previous_response_id if (responseCfg.conversation && responseCfg.previous_response_id) { return json( - { ok: false, error: "Do not send both `conversation` and `previous_response_id`." }, - 400 + { + ok: false, + error: "Do not send both `conversation` and `previous_response_id`.", + }, + 400, ); } // 4) Structured Outputs (JSON Schema) ✅ // Espera payload.structured (o payload.outputSchema) como objeto o string JSON let structured = payload.structured ?? payload.outputSchema; - if (!structured) return json({ ok: false, error: "Missing `structured` (JSON schema format)." }, 400); + if (!structured) { + return json({ + ok: false, + error: "Missing `structured` (JSON schema format).", + }, 400); + } if (typeof structured === "string") { try { structured = JSON.parse(structured); } catch { - return json({ ok: false, error: "`structured` is a string but not valid JSON." }, 400); + return json({ + ok: false, + error: "`structured` is a string but not valid JSON.", + }, 400); } } @@ -177,43 +255,60 @@ Deno.serve(async (req) => { if (format.type === "json_schema") { format.name = format.name ?? "structured_output"; format.strict = format.strict ?? true; - if (!format.schema) return json({ ok: false, error: "structured.schema is required." }, 400); + if (!format.schema) { + return json( + { ok: false, error: "structured.schema is required." }, + 400, + ); + } } responseCfg.text = { ...(responseCfg.text ?? {}), format }; // 5) Referencias (3 tipos) 📎 const references = payload.references ?? {}; - const openaiFileIds: string[] = - references.openaiFileIds ?? payload.openaiFileIds ?? []; - const vectorStoreIds: string[] = - references.vectorStoreIds ?? payload.vectorStoreIds ?? []; + const openaiFileIds: string[] = references.openaiFileIds ?? + payload.openaiFileIds ?? []; + const vectorStoreIds: string[] = references.vectorStoreIds ?? + payload.vectorStoreIds ?? []; // 5a) Vector stores => file_search tool - const tools: any[] = Array.isArray(responseCfg.tools) ? [...responseCfg.tools] : []; + const tools: any[] = Array.isArray(responseCfg.tools) + ? [...responseCfg.tools] + : []; if (vectorStoreIds.length) { tools.push({ type: "file_search", vector_store_ids: vectorStoreIds }); // Si quieres ver resultados de búsqueda en la respuesta - const include = Array.isArray(responseCfg.include) ? responseCfg.include : []; - if (!include.includes("file_search_call.results")) include.push("file_search_call.results"); + const include = Array.isArray(responseCfg.include) + ? responseCfg.include + : []; + if (!include.includes("file_search_call.results")) { + include.push("file_search_call.results"); + } responseCfg.include = include; } // 5b) MCP opcional - const usarMCP: boolean = Boolean(payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled); + const usarMCP: boolean = Boolean( + payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled, + ); if (usarMCP) { const mcp = payload.mcp ?? {}; const server_url = mcp.server_url ?? Deno.env.get("MCP_SERVER_URL"); if (!server_url) { return json( - { ok: false, error: "usarMCP=true pero falta MCP_SERVER_URL o mcp.server_url" }, - 400 + { + ok: false, + error: "usarMCP=true pero falta MCP_SERVER_URL o mcp.server_url", + }, + 400, ); } tools.push({ type: "mcp", - server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ?? "supabase", + server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ?? + "supabase", server_description: mcp.server_description ?? "Supabase MCP server", server_url, require_approval: mcp.require_approval ?? "never", @@ -226,7 +321,10 @@ Deno.serve(async (req) => { // 5c) OpenAI file IDs => se insertan al input como input_file if (openaiFileIds.length) { - responseCfg.input = normalizeInputWithOpenAIFileIds(responseCfg.input, openaiFileIds); + responseCfg.input = normalizeInputWithOpenAIFileIds( + responseCfg.input, + openaiFileIds, + ); } // 6) Archivos temporales => subir a Supabase Storage (NO a OpenAI) ☁️ @@ -239,19 +337,38 @@ Deno.serve(async (req) => { }> = []; if (tempFiles.length) { - const bucket = payload.storage?.bucket ?? Deno.env.get("AI_FILES_BUCKET") ?? "ai-temp"; - const prefix = payload.storage?.prefix ?? Deno.env.get("AI_FILES_PREFIX") ?? "tmp"; + const bucket = payload.storage?.bucket ?? + Deno.env.get("AI_FILES_BUCKET") ?? "ai-temp"; + const prefix = payload.storage?.prefix ?? + Deno.env.get("AI_FILES_PREFIX") ?? "tmp"; for (const f of tempFiles) { const safeName = (f.name ?? "file").replaceAll(/[^a-zA-Z0-9._-]/g, "_"); const path = `${prefix}/${user.id}/${crypto.randomUUID()}-${safeName}`; + const bytes = new Uint8Array(await f.arrayBuffer()); const { data, error } = await supabaseAdmin.storage .from(bucket) - .upload(path, f, { contentType: f.type, upsert: false }); + .upload(path, bytes, { contentType: f.type, upsert: false }); if (error) { - return json({ ok: false, error: `Storage upload failed: ${error.message}`, file: f.name }, 500); + return json({ + ok: false, + error: `Storage upload failed: ${error.message}`, + file: f.name, + }, 500); + } + // Add as an input_file base64 reference to the last user message content + const fileBase64 = btoa(String.fromCharCode(...bytes)); + if (Array.isArray(responseCfg.input) && responseCfg.input.length > 0) { + const lastMsg = responseCfg.input[responseCfg.input.length - 1]; + if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) { + lastMsg.content.push({ + type: "input_file", + filename: f.name, + file_data: `data:${f.type};base64,${fileBase64}`, + }); + } } uploadedToStorage.push({ @@ -266,19 +383,22 @@ Deno.serve(async (req) => { // 7) Llamada a OpenAI Responses API 🚀 const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY"); - if (!OPENAI_API_KEY) return json({ ok: false, error: "Missing OPENAI_API_KEY" }, 500); + if (!OPENAI_API_KEY) { + return json({ ok: false, error: "Missing OPENAI_API_KEY" }, 500); + } const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); let resp = null; try { - resp = await openai.responses.create(responseCfg); - } catch (error) { - return json({ ok: false, error: "OpenAI API error", details: (error as Error)?.message ?? String(error) }, 500); + return json({ + ok: false, + error: "OpenAI API error", + details: (error as Error)?.message ?? String(error), + }, 500); } - - + const outputText = extractOutputText(resp); let output: unknown = null; let outputParseError: string | null = null; @@ -288,12 +408,10 @@ Deno.serve(async (req) => { outputParseError = (e as Error)?.message ?? String(e); } - const conv = - resp?.conversation?.id ?? + const conv = resp?.conversation?.id ?? (typeof resp?.conversation === "string" ? resp.conversation - : responseCfg.conversation ?? null) - + : responseCfg.conversation ?? null); return json({ ok: outputParseError ? false : true, @@ -311,7 +429,8 @@ Deno.serve(async (req) => { openaiFileIds, vectorStoreIds, // Si quieres mandar tus UUID internos de archivos, aquí van (solo eco) - archivosReferenciaIds: references.archivosReferenciaIds ?? payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [], + archivosReferenciaIds: references.archivosReferenciaIds ?? + payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [], }, tools: sanitizeTools(responseCfg.tools), diff --git a/testers/ai-structured-files.ts b/testers/ai-structured-files.ts new file mode 100644 index 0000000..5a10360 --- /dev/null +++ b/testers/ai-structured-files.ts @@ -0,0 +1,25 @@ +import { supabase } from "./new-user"; + +const file = Bun.file("files/carta.pdf"); + +await supabase.auth.signInWithPassword({ + email: "guillermo.arrieta@lasalle.mx", + password: "admin", +}); + +const fd = new FormData(); +fd.append("payload", JSON.stringify({ + response: { input: "Resume estos documentos en JSON.", model: "gpt-5" }, + structured: { type: "json_schema", name: "resumen", strict: true, schema: { + type: "object", properties: { resumen: { type: "string" } }, required: ["resumen"], additionalProperties: false + } }, + storage: { prefix: "tmp" }, +})); +fd.append("files", file); + +const response = await supabase.functions.invoke("ai-structured", { + method: "POST", + body: fd, +}); + +console.log({ d: response.data, e: response.error }); \ No newline at end of file