diff --git a/extensions/index.ts b/extensions/index.ts index cb97d0a..f8ecfdf 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -229,10 +229,19 @@ async function runAgy( // Helper: estrarre il percorso immagine dall'output // --------------------------------------------------------------------------- function extractImagePath(text: string): string | undefined { - const m = - text.match(/IMAGE_PATH:\s*(\S+)/i) || - text.match(/(\/[^\s]+\.(?:png|jpe?g|webp|gif))/i); - return m ? m[1] : undefined; + // Candidati: prima la riga IMAGE_PATH, poi qualsiasi path immagine assoluto nel testo. + // I placeholder ecoati dal prompt (es. "IMAGE_PATH: ") e i + // path inesistenti vengono scartati: senza il filtro si ottenevano percorsi fasulli. + const candidates: string[] = []; + const m = text.match(/IMAGE_PATH:\s*(\S+)/i); + if (m?.[1]) candidates.push(m[1]); + for (const mm of text.matchAll(/(\/[^\s"'`]+?\.(?:png|jpe?g|webp|gif))/gi)) candidates.push(mm[1]); + for (const candidate of candidates) { + const clean = candidate.replace(/[.,;:)\]}>]+$/, ""); + if (!clean || clean.includes("<") || clean.includes(">")) continue; + if (fs.existsSync(clean)) return clean; + } + return undefined; } // --------------------------------------------------------------------------- @@ -643,6 +652,121 @@ async function executeAgy(opts: AgyExecOptions) { const IMG_SUFFIX = "\n\nAlla fine della risposta, scrivi su una riga esattamente: IMAGE_PATH: "; +// --------------------------------------------------------------------------- +// Task immagine unificato: prima il protocollo diretto Antigravity (nessun +// subprocess, ~7-25s), con fallback automatico al percorso agy CLI se il +// modello non è disponibile o la risposta non contiene immagini. +// --------------------------------------------------------------------------- +const AGY_IMAGE_OUT_DIR = path.join(AGY_CHAT_DIR, "generated_images"); + +function antgSaveImages(images: AntgInlineImage[], outputDir: string | undefined, prefix: string): string[] { + const dir = outputDir ?? AGY_IMAGE_OUT_DIR; + const saved: string[] = []; + try { + fs.mkdirSync(dir, { recursive: true }); + } catch { + return saved; + } + const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14); + images.forEach((img, i) => { + const ext = img.mimeType.includes("jpeg") ? "jpg" : img.mimeType.includes("webp") ? "webp" : "png"; + const file = path.join(dir, `${prefix}_${stamp}_${i}.${ext}`); + try { + fs.writeFileSync(file, Buffer.from(img.data, "base64")); + saved.push(file); + } catch { + /* ignora */ + } + }); + return saved; +} + +async function agyImageTask(opts: { + prompt: string; // prompt in stile CLI (può contenere i percorsi + IMG_SUFFIX) + images?: string[]; + aspectRatio?: string; + imageSize?: string; + model?: string; + outputDir?: string; + signal?: AbortSignal; + label: string; +}): Promise<{ + text: string; + imagePaths: string[]; + engine: "direct" | "cli"; + usage?: any; + exitCode?: number; + directError?: string; +}> { + const imageModel = opts.model && /image/i.test(opts.model) ? opts.model : ANTG_IMAGE_MODEL_DEFAULT; + let directError: string | undefined; + const quotaBlocked = Date.now() < antgImageBlockedUntilMs; + try { + if (quotaBlocked) { + const mins = Math.ceil((antgImageBlockedUntilMs - Date.now()) / 60000); + throw new Error(`quota API immagine esaurita (reset stimato tra ~${mins} min): ${antgImageBlockedReason}`); + } + // Prompt per il modello: niente IMG_SUFFIX, percorsi → riferimenti allegati, + // più un vincolo esplicito di composizione (migliora affidabilità su input). + let prompt = opts.prompt.split(IMG_SUFFIX).join("").trim(); + for (const file of opts.images ?? []) prompt = prompt.split(file).join("(immagine allegata)"); + if (opts.images?.length) { + prompt += "\nMantieni esattamente la composizione e l'aspect ratio dell'immagine di input; non ritagliare e non aggiungere testo."; + } + const res = await antgGenerateImages({ + prompt, + images: opts.images, + aspectRatio: opts.aspectRatio, + imageSize: opts.imageSize, + model: imageModel, + signal: opts.signal, + }); + if (!res.images.length) { + throw new Error( + res.finishMessage + ? `il modello non ha prodotto immagini: ${res.finishMessage.slice(0, 200)}` + : `nessuna immagine nella risposta (finishReason=${res.finishReason ?? "?"})`, + ); + } + const imagePaths = antgSaveImages(res.images, opts.outputDir, opts.label.replace(/[^a-z0-9]+/gi, "_")); + const outTok = res.usage?.candidatesTokenCount; + const text = + (res.text ? `${res.text}\n\n` : "") + + `✅ ${opts.label}: ${imagePaths.length} immagine/i via API diretta Antigravity (${res.model}${outTok ? `, ${outTok} token output` : ""}).` + + (imagePaths.length ? `\n${imagePaths.map((f) => `- ${f}`).join("\n")}` : ""); + return { text, imagePaths, engine: "direct", usage: res.usage }; + } catch (e: any) { + directError = e?.message ?? String(e); + } + + // Fallback: percorso CLI agy (comportamento precedente all'integrazione diretta) + const res = await executeAgy({ + prompt: opts.prompt, + mode: "image", + model: opts.model, + stateless: true, + filePaths: opts.images, + outputDir: opts.outputDir, + yolo: true, + signal: opts.signal, + }); + // Il path deve esistere davvero: se il CLI fallisce (es. quota) l'output può + // contenere il placeholder del prompt, che non è un'immagine. + const cliPath = res.imagePath && fs.existsSync(res.imagePath) ? res.imagePath : undefined; + const note = cliPath + ? `\n\nℹ️ Fallback CLI agy (percorso diretto non disponibile): ${directError ?? "motivo non specificato"}` + : `\n\n⚠️ Nessuna immagine prodotta. API diretta: ${directError ?? "errore non specificato"}${ + res.exitCode ? ` | CLI agy exitCode ${res.exitCode}` : "" + }`; + return { + text: res.text + note, + imagePaths: cliPath ? [cliPath] : [], + engine: "cli", + exitCode: res.exitCode, + directError, + }; +} + // --------------------------------------------------------------------------- // Registrazione microfono (F12) e trascrizione // --------------------------------------------------------------------------- @@ -1329,6 +1453,162 @@ async function antgLoadCodeAssist(token: string, base: string): Promise return String(pid); } +// --------------------------------------------------------------------------- +// Generazione immagini via protocollo diretto Antigravity (stesso token OAuth +// del canale account). Validato con probe il 13/09/2026: +// POST v1internal:generateContent (NON streaming), requestType "image_gen", +// responseModalities ["IMAGE"], imageConfig {aspectRatio, imageSize}; +// immagine in candidates[0].content.parts[].inlineData (base64, image/jpeg). +// I parts con thought=true sono immagini intermedie di ragionamento: ignorarli. +// HTTP 200 può non contenere immagini: in tal caso candidate.finishMessage +// spiega il rifiuto (nessun addebito) → si ricade sul percorso CLI. +// gemini-3-pro-image NON è disponibile su questo account (HTTP 404). +// --------------------------------------------------------------------------- +const ANTG_IMAGE_MODEL_DEFAULT = "gemini-3.1-flash-image"; // Nano Banana 2 +const ANTG_IMAGE_MAX_OUTPUT_TOKENS = 32768; // 4K richiede ~2.9K token di output + +// Quota immagine: l'API diretta risponde 429 RESOURCE_EXHAUSTED con +// "quota will reset after XhYmZs" quando la capacità del modello è satura. +// Finché il blocco è attivo si va diretti al percorso CLI, evitando round-trip +// inutili. +let antgImageBlockedUntilMs = 0; +let antgImageBlockedReason = ""; + +function antgParseQuotaDelayMs(message: string): number { + const m = message.match(/reset after\s*(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/i); + if (!m) return 0; + const h = Number(m[1] ?? 0); + const min = Number(m[2] ?? 0); + const sec = Number(m[3] ?? 0); + return ((h * 60 + min) * 60 + sec) * 1000; +} + +interface AntgInlineImage { + mimeType: string; + data: string; +} + +interface AntgImageCallResult { + images: AntgInlineImage[]; + text: string; + finishReason?: string; + finishMessage?: string; + usage?: any; + model: string; +} + +function antgMimeFromPath(file: string): string { + switch (path.extname(file).toLowerCase()) { + case ".png": + return "image/png"; + case ".webp": + return "image/webp"; + case ".gif": + return "image/gif"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + default: + return "image/png"; + } +} + +async function antgGenerateImages(opts: { + prompt: string; + images?: string[]; + aspectRatio?: string; + imageSize?: string; + model?: string; + signal?: AbortSignal; +}): Promise { + const model = opts.model ?? ANTG_IMAGE_MODEL_DEFAULT; + const { pid, base } = await antgGetProject(); + const parts: any[] = []; + for (const file of opts.images ?? []) { + try { + parts.push({ + inlineData: { mimeType: antgMimeFromPath(file), data: fs.readFileSync(file).toString("base64") }, + }); + } catch (e: any) { + throw new Error(`Impossibile leggere l'immagine ${file}: ${e.message}`); + } + } + parts.push({ text: opts.prompt }); + + const generationConfig: any = { + temperature: 1.0, + topP: 0.95, + topK: 40, + maxOutputTokens: ANTG_IMAGE_MAX_OUTPUT_TOKENS, + responseModalities: ["IMAGE"], // 1 sola immagine (TEXT+IMAGE può duplicarla) + }; + const imageConfig: any = {}; + if (opts.aspectRatio) imageConfig.aspectRatio = opts.aspectRatio; + if (opts.imageSize) imageConfig.imageSize = opts.imageSize; + if (Object.keys(imageConfig).length) generationConfig.imageConfig = imageConfig; + + const envelope = { + project: pid, + model, + requestType: "image_gen", + userAgent: "antigravity", + requestId: `agent-${crypto.randomUUID().replace(/-/g, "")}`, + request: { contents: [{ role: "user", parts }], generationConfig }, + }; + + const call = (tok: string) => + fetch(`${base}/v1internal:generateContent`, { + method: "POST", + headers: antgHeaders(tok), + body: JSON.stringify(envelope), + signal: antgAbortSignal(opts.signal, 300_000), + }); + let resp = await call(await antgGetAccessToken()); + if (resp.status === 401 || resp.status === 403) { + antgToken = null; + resp = await call(await antgGetAccessToken()); + } + const raw = await resp.text(); + if (!resp.ok) { + if (resp.status === 429 || /QUOTA_EXHAUSTED|RESOURCE_EXHAUSTED/.test(raw)) { + const delay = antgParseQuotaDelayMs(raw); + antgImageBlockedUntilMs = Date.now() + (delay || 30 * 60 * 1000); + antgImageBlockedReason = raw.replace(/\s+/g, " ").slice(0, 200); + } + throw new Error(`Antigravity image HTTP ${resp.status}: ${raw.slice(0, 300)}`); + } + + let payload: any; + try { + payload = JSON.parse(raw); + } catch { + throw new Error(`Risposta immagine non JSON: ${raw.slice(0, 200)}`); + } + const gem = payload?.response ?? payload; + const candidate = gem?.candidates?.[0]; + const responseParts: any[] = candidate?.content?.parts ?? []; + const images: AntgInlineImage[] = []; + const texts: string[] = []; + for (const part of responseParts) { + const inline = part?.inlineData ?? part?.inline_data; + if (inline?.data) { + if (part.thought === true) continue; // immagine intermedia di ragionamento + images.push({ mimeType: inline.mimeType ?? inline.mime_type ?? "image/png", data: inline.data }); + } else if (typeof part?.text === "string" && part.thought !== true) { + texts.push(part.text); + } + } + + return { + images, + text: texts.join("\n").trim(), + finishReason: candidate?.finishReason, + finishMessage: candidate?.finishMessage, + usage: gem?.usageMetadata, + model, + }; +} + async function antgGetProject(): Promise<{ pid: string; base: string }> { if (antgProject) return antgProject; const token = await antgGetAccessToken(); @@ -2122,6 +2402,9 @@ export default function agyExtension(pi: ExtensionAPI) { text: Type.Optional(Type.String({ description: "Testo da includere nell'immagine (tra virgolette)." })), negative: Type.Optional(Type.String({ description: "Cosa evitare, in framing positivo (es. 'nessun testo')." })), model: Type.Optional(Type.String({ description: "Modello agy." })), + imageSize: Type.Optional( + Type.String({ description: 'Risoluzione immagine via API diretta: "512", "1K", "2K", "4K" (default 1K).' }), + ), outputDir: Type.Optional(Type.String({ description: "Cartella dove copiare l'immagine generata." })), }), async execute(toolCallId, params, signal, onUpdate, ctx) { @@ -2138,17 +2421,18 @@ export default function agyExtension(pi: ExtensionAPI) { const prompt = parts.join(". ") + IMG_SUFFIX; toolUpdate(onUpdate, "agy_generate: generazione immagine..."); - const res = await executeAgy({ + const res = await agyImageTask({ prompt, - mode: "image", + aspectRatio: p.aspectRatio, + imageSize: p.imageSize ?? "1K", model: p.model, - stateless: true, outputDir: p.outputDir, signal, + label: "agy_generate", }); return { content: [{ type: "text", text: res.text }], - details: { imagePath: res.imagePath, exitCode: res.exitCode }, + details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, engine: res.engine, directError: res.directError, exitCode: res.exitCode }, }; }, }); @@ -2185,19 +2469,17 @@ export default function agyExtension(pi: ExtensionAPI) { prompt += IMG_SUFFIX; toolUpdate(onUpdate, "agy_edit: modifica immagine..."); - const res = await executeAgy({ + const res = await agyImageTask({ prompt, - mode: "image", + images: [p.baseImage], model: p.model, - stateless: true, - filePaths: [p.baseImage], outputDir: p.outputDir, - yolo: true, signal, + label: "agy_edit", }); return { content: [{ type: "text", text: res.text }], - details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode }, + details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, baseImage: p.baseImage, engine: res.engine, directError: res.directError, exitCode: res.exitCode }, }; }, }); @@ -2230,19 +2512,17 @@ export default function agyExtension(pi: ExtensionAPI) { IMG_SUFFIX; toolUpdate(onUpdate, "agy_inpaint: modifica zona specifica..."); - const res = await executeAgy({ + const res = await agyImageTask({ prompt, - mode: "image", + images: [p.baseImage], model: p.model, - stateless: true, - filePaths: [p.baseImage], outputDir: p.outputDir, - yolo: true, signal, + label: "agy_inpaint", }); return { content: [{ type: "text", text: res.text }], - details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode }, + details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, baseImage: p.baseImage, engine: res.engine, directError: res.directError, exitCode: res.exitCode }, }; }, }); @@ -2274,19 +2554,17 @@ export default function agyExtension(pi: ExtensionAPI) { IMG_SUFFIX; toolUpdate(onUpdate, "agy_style_transfer: applica stile..."); - const res = await executeAgy({ + const res = await agyImageTask({ prompt, - mode: "image", + images: [p.baseImage], model: p.model, - stateless: true, - filePaths: [p.baseImage], outputDir: p.outputDir, - yolo: true, signal, + label: "agy_style_transfer", }); return { content: [{ type: "text", text: res.text }], - details: { imagePath: res.imagePath, baseImage: p.baseImage, exitCode: res.exitCode }, + details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, baseImage: p.baseImage, engine: res.engine, directError: res.directError, exitCode: res.exitCode }, }; }, }); @@ -2319,19 +2597,17 @@ export default function agyExtension(pi: ExtensionAPI) { IMG_SUFFIX; toolUpdate(onUpdate, "agy_compose: combina immagini..."); - const res = await executeAgy({ + const res = await agyImageTask({ prompt, - mode: "image", + images: p.images, model: p.model, - stateless: true, - filePaths: p.images, outputDir: p.outputDir, - yolo: true, signal, + label: "agy_compose", }); return { content: [{ type: "text", text: res.text }], - details: { imagePath: res.imagePath, images: p.images, exitCode: res.exitCode }, + details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, images: p.images, engine: res.engine, directError: res.directError, exitCode: res.exitCode }, }; }, }); @@ -2362,19 +2638,17 @@ export default function agyExtension(pi: ExtensionAPI) { IMG_SUFFIX; toolUpdate(onUpdate, "agy_character: mantieni consistenza personaggio..."); - const res = await executeAgy({ + const res = await agyImageTask({ prompt, - mode: "image", + images: [p.referenceImage], model: p.model, - stateless: true, - filePaths: [p.referenceImage], outputDir: p.outputDir, - yolo: true, signal, + label: "agy_character", }); return { content: [{ type: "text", text: res.text }], - details: { imagePath: res.imagePath, referenceImage: p.referenceImage, exitCode: res.exitCode }, + details: { imagePath: res.imagePaths[0], imagePaths: res.imagePaths, referenceImage: p.referenceImage, engine: res.engine, directError: res.directError, exitCode: res.exitCode }, }; }, });