import { tool } from "@opencode-ai/plugin/tool" import { mkdir } from "fs/promises" import { join, dirname, basename, extname, resolve } from "path" import { getApiKey } from "../env" // Function to detect if we're in test mode function isTestMode(): boolean { // Only enable test mode when explicitly set return process.env.GEMINI_TEST_MODE === 'true' } // Function to get Gemini API key with automatic .env loading async function getGeminiApiKey(): Promise { if (isTestMode()) { return 'test-api-key' } return getApiKey('GEMINI_API_KEY') } interface ImageConfig { outputDir?: string; useTimestamp?: boolean; preserveOriginal?: boolean; customName?: string; } async function parseImageInput(input: string) { // Accepts file path ("./img.png") or data URL ("data:image/png;base64,...") if (input.startsWith("data:")) { const base64 = input.split(",")[1] const mime = input.substring(5, input.indexOf(";")) return { mime, base64 } } // Treat as file path const file = Bun.file(input) const arr = await file.arrayBuffer() const base64 = Buffer.from(arr).toString("base64") // Best-effort mime const mime = file.type || "image/png" return { mime, base64 } } async function ensureDirectoryExists(dirPath: string) { try { await mkdir(dirPath, { recursive: true }) } catch (error) { // Directory might already exist, that's fine } } function getDateBasedPath(baseDir?: string): string { // Default to assets/images at repo root if (!baseDir) { // Navigate from .opencode/tool/ to repo root, then to assets/images baseDir = resolve(process.cwd(), "../../assets/images") } const today = new Date().toISOString().split('T')[0] // YYYY-MM-DD format return join(baseDir, today) } async function getUniqueFilename(directory: string, baseName: string, extension: string, isEdit: boolean = false): Promise { await ensureDirectoryExists(directory) if (!isEdit) { // For generations, use timestamp if file exists const baseFilename = join(directory, `${baseName}${extension}`) const fileExists = await Bun.file(baseFilename).exists() if (!fileExists) { return baseFilename } // Add timestamp if file exists const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) // Remove milliseconds and Z return join(directory, `${baseName}_${timestamp}${extension}`) } // For edits, use incremental numbering let counter = 1 let filename: string do { const editSuffix = `_edit_${counter.toString().padStart(3, '0')}` filename = join(directory, `${baseName}${editSuffix}${extension}`) counter++ } while (await Bun.file(filename).exists()) return filename } export async function generateImage(prompt: string, config: ImageConfig = {}): Promise { const apiKey = await getGeminiApiKey() // Test mode - return mock response without API call if (isTestMode()) { const baseDir = config.outputDir || getDateBasedPath() const generationsDir = join(baseDir, "generations") let baseName = config.customName || "generated" if (baseName.endsWith('.png') || baseName.endsWith('.jpg') || baseName.endsWith('.jpeg')) { baseName = baseName.substring(0, baseName.lastIndexOf('.')) } const outputPath = await getUniqueFilename(generationsDir, baseName, ".png", false) return `[TEST MODE] Would generate image: ${outputPath} for prompt: "${prompt.substring(0, 50)}..."` } const body = { contents: [{ parts: [{ text: prompt }] }], } const res = await fetch( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent", { method: "POST", headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey, }, body: JSON.stringify(body), } ) if (!res.ok) { const errorText = await res.text() throw new Error(`API error (${res.status}): ${errorText}`) } const json = await res.json() // Look for image data in the response const candidates = json?.candidates if (!candidates || candidates.length === 0) { throw new Error("No candidates in response") } const parts = candidates[0]?.content?.parts if (!parts || parts.length === 0) { throw new Error("No parts in response") } let b64 = null for (const part of parts) { if (part.inlineData?.data) { b64 = part.inlineData.data break } } if (!b64) { throw new Error("No image data returned from Nano Banana model") } // Determine output path const baseDir = config.outputDir || getDateBasedPath() const generationsDir = join(baseDir, "generations") // Generate filename (remove extension if already present) let baseName = config.customName || "generated" if (baseName.endsWith('.png') || baseName.endsWith('.jpg') || baseName.endsWith('.jpeg')) { baseName = baseName.substring(0, baseName.lastIndexOf('.')) } const extension = ".png" const outputPath = await getUniqueFilename(generationsDir, baseName, extension, false) console.log(`Saving generated image to: ${outputPath}`) await Bun.write(outputPath, Buffer.from(b64, "base64")) const fileExists = await Bun.file(outputPath).exists() if (!fileExists) { throw new Error(`Failed to save file to ${outputPath}`) } const stats = await Bun.file(outputPath).stat() return `Generated image saved: ${outputPath} (${stats.size} bytes)` } export async function editImage(imagePath: string, prompt: string, config: ImageConfig = {}): Promise { const apiKey = await getGeminiApiKey() // Test mode - return mock response without API call if (isTestMode()) { const baseDir = config.outputDir || getDateBasedPath() const editsDir = join(baseDir, "edits") const originalName = basename(imagePath, extname(imagePath)) let baseName = config.customName || originalName if (baseName.endsWith('.png') || baseName.endsWith('.jpg') || baseName.endsWith('.jpeg')) { baseName = baseName.substring(0, baseName.lastIndexOf('.')) } const outputPath = await getUniqueFilename(editsDir, baseName, ".png", true) return `[TEST MODE] Would edit image: ${imagePath} -> ${outputPath} with prompt: "${prompt.substring(0, 50)}..."` } // Parse the input image const { mime, base64 } = await parseImageInput(imagePath) const body = { contents: [{ parts: [ { text: prompt }, { inlineData: { mimeType: mime, data: base64 } } ] }], } const res = await fetch( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image-preview:generateContent", { method: "POST", headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey, }, body: JSON.stringify(body), } ) if (!res.ok) { const errorText = await res.text() throw new Error(`API error (${res.status}): ${errorText}`) } const json = await res.json() // Look for image data in the response const candidates = json?.candidates if (!candidates || candidates.length === 0) { throw new Error("No candidates in response") } const parts = candidates[0]?.content?.parts if (!parts || parts.length === 0) { throw new Error("No parts in response") } let b64 = null for (const part of parts) { if (part.inlineData?.data) { b64 = part.inlineData.data break } } if (!b64) { throw new Error("No image data returned from Nano Banana model") } // Determine output path const baseDir = config.outputDir || getDateBasedPath() const editsDir = join(baseDir, "edits") // Extract original filename without extension const originalName = basename(imagePath, extname(imagePath)) let baseName = config.customName || originalName if (baseName.endsWith('.png') || baseName.endsWith('.jpg') || baseName.endsWith('.jpeg')) { baseName = baseName.substring(0, baseName.lastIndexOf('.')) } const extension = ".png" const outputPath = await getUniqueFilename(editsDir, baseName, extension, true) console.log(`Saving edited image to: ${outputPath}`) await Bun.write(outputPath, Buffer.from(b64, "base64")) const fileExists = await Bun.file(outputPath).exists() if (!fileExists) { throw new Error(`Failed to save file to ${outputPath}`) } const stats = await Bun.file(outputPath).stat() return `Edited image saved: ${outputPath} (${stats.size} bytes)` } export async function analyzeImage(imagePath: string, question: string): Promise { const apiKey = await getGeminiApiKey() // Test mode - return mock response without API call if (isTestMode()) { return `[TEST MODE] Would analyze image: ${imagePath} with question: "${question.substring(0, 50)}..." - Mock analysis: This is a test image analysis response.` } const { mime, base64 } = await parseImageInput(imagePath) const res = await fetch( "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent", { method: "POST", headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey, }, body: JSON.stringify({ contents: [{ parts: [ { text: question }, { inlineData: { mimeType: mime, data: base64 } } ] }], }), } ) if (!res.ok) { const errorText = await res.text() throw new Error(`API error (${res.status}): ${errorText}`) } const json = await res.json() const text = json?.candidates?.[0]?.content?.parts?.[0]?.text if (!text) { throw new Error("No analysis returned") } return text } // Tool for generating images from text export const generate = tool({ description: "Generate an image using Gemini Nano Banana from text prompt", args: { prompt: tool.schema.string().describe("Text description of the image to generate"), outputDir: tool.schema.string().optional().describe("Custom output directory (default: ./generated-images/YYYY-MM-DD/)"), filename: tool.schema.string().optional().describe("Custom filename (default: generated)"), }, async execute(args, context) { try { const config: ImageConfig = { outputDir: args.outputDir, customName: args.filename, } return await generateImage(args.prompt, config) } catch (error) { return `Error: ${error.message}` } }, }) // Tool for editing existing images export const edit = tool({ description: "Edit an existing image using Gemini Nano Banana", args: { image: tool.schema.string().describe("File path or data URL of image to edit"), prompt: tool.schema.string().describe("Edit instruction"), outputDir: tool.schema.string().optional().describe("Custom output directory (default: ./generated-images/YYYY-MM-DD/)"), filename: tool.schema.string().optional().describe("Custom filename (default: original name with _edit_XXX)"), }, async execute(args, context) { try { const config: ImageConfig = { outputDir: args.outputDir, customName: args.filename, } return await editImage(args.image, args.prompt, config) } catch (error) { return `Error: ${error.message}` } }, }) // Tool for analyzing images export const analyze = tool({ description: "Analyze an image using Gemini (text analysis only)", args: { image: tool.schema.string().describe("File path or data URL of image to analyze"), question: tool.schema.string().describe("What to analyze about the image"), }, async execute(args, context) { try { return await analyzeImage(args.image, args.question) } catch (error) { return `Error: ${error.message}` } }, }) // Default export for backward compatibility export default edit