This commit is contained in:
2026-01-16 12:16:30 -06:00
parent 81262d706a
commit 58bdcd17a6
9 changed files with 3815 additions and 51 deletions
+10 -1
View File
@@ -1,2 +1,11 @@
{
}
"deno.enablePaths": ["supabase/functions"],
"deno.lint": true,
"[typescript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"notebook.defaultFormatter": "denoland.vscode-deno",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"deno.enable": true,
"editor.formatOnSave": true
}
+11
View File
@@ -0,0 +1,11 @@
[functions.ai-generate-plan]
enabled = true
verify_jwt = true
import_map = "./functions/ai-generate-plan/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/ai-generate-plan/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/ai-generate-plan/*.html" ]
+5
View File
@@ -0,0 +1,5 @@
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
@@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
@@ -0,0 +1,3 @@
{
"imports": {}
}
@@ -0,0 +1,56 @@
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders } from "../_shared/cors.ts";
console.log("Hello from Functions!");
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
try {
const contentType = (req.headers.get("content-type") || "").toLowerCase();
if (contentType.startsWith("multipart/form-data")) {
const formData = await req.formData();
console.log("Received multipart/form-data:", formData);
// 1. Usa .getAll() para sacar TODOS los valores de esa llave
const archivos = formData.getAll("archivosAdjuntos");
// 2. Imprime la longitud y el arreglo explícito
console.log(`Total archivos recibidos: ${archivos.length}`);
console.log("Lista de archivos:", archivos);
return new Response(
JSON.stringify({ message: "Multipart/form-data received" }),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 200,
},
);
} else {
console.error("Unsupported content type:", contentType);
return new Response(
JSON.stringify({ error: "Unsupported content type" }),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 400,
},
);
}
} catch (error) {
// Log full error server-side for diagnostics
if (error instanceof Error) {
console.error("Request handler error:", error.message);
}
const message = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: message }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
status: 400,
});
}
});
+141 -50
View File
@@ -3,12 +3,7 @@
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": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
import { corsHeaders } from "../_shared/cors.ts";
const MB = 1024 * 1024;
@@ -126,12 +121,16 @@ function safePreview(val: unknown, maxLen = 1200) {
if (typeof v === "string") {
if (v.length > 500) return v.slice(0, 500) + "…[TRUNCATED]";
// evita imprimir base64/data URLs
if (v.startsWith("data:") && v.includes(";base64,")) return "[DATA_URL_REDACTED]";
if (v.startsWith("data:") && v.includes(";base64,")) {
return "[DATA_URL_REDACTED]";
}
}
return v;
});
if (!jsonStr) return String(val);
return jsonStr.length > maxLen ? jsonStr.slice(0, maxLen) + "…[TRUNCATED]" : jsonStr;
return jsonStr.length > maxLen
? jsonStr.slice(0, maxLen) + "…[TRUNCATED]"
: jsonStr;
} catch {
return String(val);
}
@@ -187,13 +186,17 @@ function toolsSummary(tools: any[]) {
return arr.map((t) => {
if (!t) return t;
const base: any = { type: t.type };
if (t.type === "file_search") base.vector_store_ids_count = (t.vector_store_ids ?? []).length;
if (t.type === "file_search") {
base.vector_store_ids_count = (t.vector_store_ids ?? []).length;
}
if (t.type === "mcp") {
base.server_label = t.server_label ?? null;
base.server_url = t.server_url ? "[SET]" : null;
base.require_approval = t.require_approval ?? null;
base.authorization = t.authorization ? "[REDACTED]" : null;
base.allowed_tools_count = Array.isArray(t.allowed_tools) ? t.allowed_tools.length : null;
base.allowed_tools_count = Array.isArray(t.allowed_tools)
? t.allowed_tools.length
: null;
}
return base;
});
@@ -207,7 +210,10 @@ Deno.serve(async (req) => {
const log = (msg: string, extra?: unknown) => {
if (extra !== undefined) {
console.log(`[${nowIso()}][ai-structured][${cid}] ${msg}`, safePreview(extra));
console.log(
`[${nowIso()}][ai-structured][${cid}] ${msg}`,
safePreview(extra),
);
} else {
console.log(`[${nowIso()}][ai-structured][${cid}] ${msg}`);
}
@@ -215,7 +221,10 @@ Deno.serve(async (req) => {
const warn = (msg: string, extra?: unknown) => {
if (extra !== undefined) {
console.warn(`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`, safePreview(extra));
console.warn(
`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`,
safePreview(extra),
);
} else {
console.warn(`[${nowIso()}][ai-structured][${cid}] ⚠️ ${msg}`);
}
@@ -223,7 +232,10 @@ Deno.serve(async (req) => {
const errlog = (msg: string, extra?: unknown) => {
if (extra !== undefined) {
console.error(`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`, safePreview(extra));
console.error(
`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`,
safePreview(extra),
);
} else {
console.error(`[${nowIso()}][ai-structured][${cid}] ❌ ${msg}`);
}
@@ -247,11 +259,17 @@ Deno.serve(async (req) => {
const authHeaderRaw = req.headers.get("Authorization") ??
req.headers.get("authorization");
log("Authorization header present?", { present: Boolean(authHeaderRaw), value: redactAuthHeader(authHeaderRaw) });
log("Authorization header present?", {
present: Boolean(authHeaderRaw),
value: redactAuthHeader(authHeaderRaw),
});
if (!authHeaderRaw) {
warn("Missing Authorization header");
return json({ ok: false, error: "Missing Authorization header", cid }, 401);
return json(
{ ok: false, error: "Missing Authorization header", cid },
401,
);
}
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
@@ -275,8 +293,11 @@ Deno.serve(async (req) => {
});
const tAuth = performance.now();
const { data: userData, error: userErr } = await supabaseUser.auth.getUser();
log("Supabase auth.getUser() done ⏱️", { ms: Math.round(performance.now() - tAuth) });
const { data: userData, error: userErr } = await supabaseUser.auth
.getUser();
log("Supabase auth.getUser() done ⏱️", {
ms: Math.round(performance.now() - tAuth),
});
if (userErr || !userData?.user) {
warn("Invalid token", { userErr: userErr?.message ?? userErr ?? null });
@@ -284,10 +305,15 @@ Deno.serve(async (req) => {
}
const user = userData.user;
log("Authenticated user ✅", { userId: user.id, email: user.email ?? null });
log("Authenticated user ✅", {
userId: user.id,
email: user.email ?? null,
});
const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
log("Env presence (service role) 🗝️", { hasSERVICE_ROLE: Boolean(SERVICE_ROLE) });
log("Env presence (service role) 🗝️", {
hasSERVICE_ROLE: Boolean(SERVICE_ROLE),
});
const supabaseAdmin = SERVICE_ROLE
? createClient(SUPABASE_URL, SERVICE_ROLE)
@@ -343,9 +369,13 @@ Deno.serve(async (req) => {
}, 400);
}
for (const key of ["files", "files[]", "archivos", "archivosTemporales"]) {
for (
const key of ["files", "files[]", "archivos", "archivosTemporales"]
) {
const vals = form.getAll(key);
if (vals?.length) log(`Found multipart field "${key}"`, { count: vals.length });
if (vals?.length) {
log(`Found multipart field "${key}"`, { count: vals.length });
}
for (const v of vals) {
if (v instanceof File) tempFiles.push(v);
}
@@ -361,19 +391,29 @@ Deno.serve(async (req) => {
log("Multipart files detected 📎", {
count: tempFiles.length,
files: tempFiles.map((f) => ({ name: f.name, type: f.type, size: f.size })),
files: tempFiles.map((f) => ({
name: f.name,
type: f.type,
size: f.size,
})),
});
} else {
log("Parsing JSON body 🧾");
try {
payload = await req.json();
} catch (e) {
errlog("Invalid JSON body", { error: (e as Error)?.message ?? String(e) });
errlog("Invalid JSON body", {
error: (e as Error)?.message ?? String(e),
});
return json({ ok: false, error: "Invalid JSON body", cid }, 400);
}
}
log("Body parsed ⏱️", { ms: Math.round(performance.now() - tParse) });
log("Payload top-level keys", { keys: Object.keys(payload ?? {}), hasOpenAI: Boolean(payload?.openai), hasResponse: Boolean(payload?.response) });
log("Payload top-level keys", {
keys: Object.keys(payload ?? {}),
hasOpenAI: Boolean(payload?.openai),
hasResponse: Boolean(payload?.response),
});
// 3) Construye config final para OpenAI (passthrough + overrides) 🧠
log("Step 3: Build responseCfg 🧠");
@@ -389,7 +429,10 @@ Deno.serve(async (req) => {
if (responseCfg.input !== undefined) {
responseCfg.input = normalizeInputUser(responseCfg.input);
log("responseCfg.input normalized ✅", summarizeInputShape(responseCfg.input));
log(
"responseCfg.input normalized ✅",
summarizeInputShape(responseCfg.input),
);
} else {
warn("responseCfg.input is undefined (is that expected?)");
}
@@ -445,11 +488,15 @@ Deno.serve(async (req) => {
}
if (typeof structured === "string") {
log("Parsing structured from string 🧩", { preview: structured.slice(0, 200) });
log("Parsing structured from string 🧩", {
preview: structured.slice(0, 200),
});
try {
structured = JSON.parse(structured);
} catch (e) {
errlog("`structured` string is not valid JSON", { error: (e as Error)?.message ?? String(e) });
errlog("`structured` string is not valid JSON", {
error: (e as Error)?.message ?? String(e),
});
return json({
ok: false,
error: "`structured` is a string but not valid JSON.",
@@ -486,7 +533,9 @@ Deno.serve(async (req) => {
log("References summary", {
openaiFileIdsCount: openaiFileIds.length,
vectorStoreIdsCount: vectorStoreIds.length,
usarMCP: Boolean(payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled),
usarMCP: Boolean(
payload.usarMCP ?? references.usarMCP ?? payload.mcp?.enabled,
),
});
// 5a) Vector stores => file_search tool
@@ -506,7 +555,10 @@ Deno.serve(async (req) => {
}
responseCfg.include = include;
log("Added file_search tool 🗂️", { vectorStoreIdsCount: vectorStoreIds.length, include: responseCfg.include });
log("Added file_search tool 🗂️", {
vectorStoreIdsCount: vectorStoreIds.length,
include: responseCfg.include,
});
}
// 5b) MCP opcional
@@ -518,8 +570,11 @@ Deno.serve(async (req) => {
const server_url = mcp.server_url ?? Deno.env.get("MCP_SERVER_URL");
log("MCP config presence", {
hasServerUrl: Boolean(server_url),
hasAuthorization: Boolean(mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION")),
server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ?? "supabase",
hasAuthorization: Boolean(
mcp.authorization ?? Deno.env.get("MCP_AUTHORIZATION"),
),
server_label: mcp.server_label ?? Deno.env.get("MCP_SERVER_LABEL") ??
"supabase",
});
if (!server_url) {
@@ -553,12 +608,17 @@ Deno.serve(async (req) => {
// 5c) OpenAI file IDs => se insertan al input como input_file
if (openaiFileIds.length) {
log("Injecting OpenAI file_ids into input 🧷", { count: openaiFileIds.length });
log("Injecting OpenAI file_ids into input 🧷", {
count: openaiFileIds.length,
});
responseCfg.input = normalizeInputWithOpenAIFileIds(
responseCfg.input,
openaiFileIds,
);
log("Input after OpenAI file_ids injection", summarizeInputShape(responseCfg.input));
log(
"Input after OpenAI file_ids injection",
summarizeInputShape(responseCfg.input),
);
}
// 6) Archivos temporales => subir a Supabase Storage (NO a OpenAI) ☁️
@@ -583,28 +643,43 @@ Deno.serve(async (req) => {
const safeName = (f.name ?? "file").replaceAll(/[^a-zA-Z0-9._-]/g, "_");
const path = `${prefix}/${user.id}/${crypto.randomUUID()}-${safeName}`;
log("Uploading file to storage ⬆️", { name: f.name, type: f.type, size: f.size, path });
log("Uploading file to storage ⬆️", {
name: f.name,
type: f.type,
size: f.size,
path,
});
const tUp = performance.now();
const bytes = new Uint8Array(await f.arrayBuffer());
// warning útil: btoa + spread puede tronarse con archivos grandes
if (bytes.length > 2 * MB) {
warn("Large file detected: base64 building might be heavy (btoa + spread) 🧨", {
name: f.name,
bytes: bytes.length,
approxBase64Chars: Math.ceil(bytes.length * 4 / 3),
});
warn(
"Large file detected: base64 building might be heavy (btoa + spread) 🧨",
{
name: f.name,
bytes: bytes.length,
approxBase64Chars: Math.ceil(bytes.length * 4 / 3),
},
);
}
const { data, error } = await supabaseAdmin.storage
.from(bucket)
.upload(path, bytes, { contentType: f.type, upsert: false });
log("Storage upload done ⏱️", { ms: Math.round(performance.now() - tUp) });
log("Storage upload done ⏱️", {
ms: Math.round(performance.now() - tUp),
});
if (error) {
errlog("Storage upload failed", { message: error.message, file: f.name, bucket, path });
errlog("Storage upload failed", {
message: error.message,
file: f.name,
bucket,
path,
});
return json({
ok: false,
error: `Storage upload failed: ${error.message}`,
@@ -616,7 +691,10 @@ Deno.serve(async (req) => {
// Add as an input_file base64 reference to the last user message content
const tB64 = performance.now();
const fileBase64 = btoa(String.fromCharCode(...bytes)); // ⚠️ potencialmente pesado
log("Base64 built ⏱️", { ms: Math.round(performance.now() - tB64), base64Chars: fileBase64.length });
log("Base64 built ⏱️", {
ms: Math.round(performance.now() - tB64),
base64Chars: fileBase64.length,
});
if (Array.isArray(responseCfg.input) && responseCfg.input.length > 0) {
const lastMsg = responseCfg.input[responseCfg.input.length - 1];
@@ -633,13 +711,19 @@ Deno.serve(async (req) => {
// no imprimimos file_data
});
} else {
warn("Could not append input_file: last message is not user/content array", {
lastRole: lastMsg?.role ?? null,
lastContentType: typeof lastMsg?.content,
});
warn(
"Could not append input_file: last message is not user/content array",
{
lastRole: lastMsg?.role ?? null,
lastContentType: typeof lastMsg?.content,
},
);
}
} else {
warn("responseCfg.input not array or empty; cannot append file_data", summarizeInputShape(responseCfg.input));
warn(
"responseCfg.input not array or empty; cannot append file_data",
summarizeInputShape(responseCfg.input),
);
}
uploadedToStorage.push({
@@ -657,7 +741,9 @@ Deno.serve(async (req) => {
// 7) Llamada a OpenAI Responses API 🚀
log("Step 7: OpenAI call 🚀");
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
log("Env presence (OpenAI) 🔑", { hasOPENAI_API_KEY: Boolean(OPENAI_API_KEY) });
log("Env presence (OpenAI) 🔑", {
hasOPENAI_API_KEY: Boolean(OPENAI_API_KEY),
});
if (!OPENAI_API_KEY) {
errlog("Missing OPENAI_API_KEY");
@@ -710,7 +796,9 @@ Deno.serve(async (req) => {
const tParseOut = performance.now();
try {
output = JSON.parse(outputText);
log("Output JSON parsed ✅", { ms: Math.round(performance.now() - tParseOut) });
log("Output JSON parsed ✅", {
ms: Math.round(performance.now() - tParseOut),
});
} catch (e) {
outputParseError = (e as Error)?.message ?? String(e);
warn("Output JSON parse FAILED 🧯", {
@@ -759,6 +847,9 @@ Deno.serve(async (req) => {
stack: (e as Error)?.stack ?? null,
totalMs: Math.round(performance.now() - t0),
});
return json({ ok: false, error: (e as Error)?.message ?? String(e), cid }, 500);
return json(
{ ok: false, error: (e as Error)?.message ?? String(e), cid },
500,
);
}
});
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long