pi-banana 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/extensions/index.ts +385 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Francesco Frapporti
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# pi-banana
|
|
2
|
+
|
|
3
|
+
Generate and edit images directly inside [pi](https://github.com/badlogic/pi-mono) using Google's **Nano Banana 2** (`gemini-3.1-flash-image-preview`) and **Nano Banana Pro** (`gemini-3-pro-image-preview`).
|
|
4
|
+
|
|
5
|
+
- **Inline preview** in Kitty / iTerm2 / WezTerm — image shows up right under the tool call, no copy-pasting paths.
|
|
6
|
+
- **Reference-image editing** — pass `referenceImage: "./logo.png"` and the model edits that picture instead of starting from scratch.
|
|
7
|
+
- **Auto-save** to `./generated/` so the agent and you both have a real file to refer back to.
|
|
8
|
+
- **One env var** — `GOOGLE_API_KEY`. Works with both AI Studio (`AIza…`) and Vertex AI Express (`AQ.…`) keys.
|
|
9
|
+
- **Backend-aware** — Vertex publishes the `-preview` model id, AI Studio sometimes publishes the GA name; the extension transparently retries the other variant on 404 so it just works.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pi install npm:pi-banana
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or for a single-session try:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pi -e npm:pi-banana
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then export your key:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
export GOOGLE_API_KEY="AIza…" # AI Studio key, or
|
|
27
|
+
export GOOGLE_API_KEY="AQ.…" # Vertex AI Express key
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Get one at [aistudio.google.com/apikey](https://aistudio.google.com/apikey).
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Just ask pi for what you want:
|
|
35
|
+
|
|
36
|
+
> *"Generate a 16:9 wallpaper of a foggy redwood forest at dawn"*
|
|
37
|
+
>
|
|
38
|
+
> *"Edit `./generated/logo-20260508.png` — make the background transparent"*
|
|
39
|
+
>
|
|
40
|
+
> *"Draw a pixel-art banana with sunglasses, 1:1, save it as `./assets/mascot.png`"*
|
|
41
|
+
|
|
42
|
+
The model calls `generate_image` automatically. The PNG is shown inline and written to disk.
|
|
43
|
+
|
|
44
|
+
## Tool: `generate_image`
|
|
45
|
+
|
|
46
|
+
| Param | Type | Default | Description |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| `prompt` | string | — | Required. What to draw, or what to change about the reference image. |
|
|
49
|
+
| `aspectRatio` | enum | `1:1` | `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` |
|
|
50
|
+
| `imageSize` | enum | `1K` | `1K`, `2K`, `4K`. `4K` requires `quality=high`. |
|
|
51
|
+
| `quality` | enum | `fast` | `fast` = Nano Banana 2 (~3–10 s, cheapest). `high` = Nano Banana Pro (slower, top quality). |
|
|
52
|
+
| `referenceImage` | string | — | Optional path to a PNG/JPEG/WebP/GIF to edit instead of generating from scratch. |
|
|
53
|
+
| `outputPath` | string | `./generated/<slug>-<ts>.png` | Optional output path. Parent dirs are created. |
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
Two env vars adjust defaults without touching tool parameters:
|
|
58
|
+
|
|
59
|
+
| Env var | Default | Effect |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `PI_IMAGE_DIR` | `generated` | Default save directory (relative paths resolve against cwd). |
|
|
62
|
+
| `PI_IMAGE_QUALITY` | `fast` | Default quality if the model doesn't pass one. |
|
|
63
|
+
|
|
64
|
+
## How is this different from `@benvargas/pi-antigravity-image-gen`?
|
|
65
|
+
|
|
66
|
+
| | pi-banana | @benvargas |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Auth | `GOOGLE_API_KEY` env var | OAuth via Antigravity (`pi /login`) |
|
|
69
|
+
| Models | Flash + Pro | Pro only |
|
|
70
|
+
| Image edit (input picture) | ✅ | ❌ |
|
|
71
|
+
| Auto-save default | ✅ to `./generated/` | ❌ disabled by default |
|
|
72
|
+
| Aspect ratios | 10 | 10 |
|
|
73
|
+
| Inline terminal preview | ✅ | ✅ |
|
|
74
|
+
|
|
75
|
+
If you have an Antigravity OAuth setup and want quota tracking, benvargas is great. If you just want to drop in an API key and start making pictures, this one is simpler.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
npm install
|
|
81
|
+
npm run typecheck # tsc --noEmit
|
|
82
|
+
GOOGLE_API_KEY=… npm run smoke # full live-API smoke test in _tmp/
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-image
|
|
3
|
+
*
|
|
4
|
+
* Image generation + editing tool for pi, powered by Google's
|
|
5
|
+
* - Nano Banana 2 (gemini-3.1-flash-image-preview) — fast, cheap, default
|
|
6
|
+
* - Nano Banana Pro (gemini-3-pro-image-preview) — top quality, slower
|
|
7
|
+
*
|
|
8
|
+
* Returns images as flat { type: "image", data, mimeType } content blocks so
|
|
9
|
+
* pi-coding-agent renders them inline (Kitty / iTerm2). Also writes the file
|
|
10
|
+
* to disk so the agent and the user always have a path to refer back to.
|
|
11
|
+
*
|
|
12
|
+
* Auth: GOOGLE_API_KEY in the environment.
|
|
13
|
+
* - AI Studio keys (AIza...) hit the Gemini API.
|
|
14
|
+
* - Vertex AI Express keys (AQ....) automatically switch to Vertex.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
19
|
+
import { GoogleGenAI } from "@google/genai";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
22
|
+
import { extname, isAbsolute, resolve } from "node:path";
|
|
23
|
+
import { Type } from "typebox";
|
|
24
|
+
|
|
25
|
+
// ─── Config ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
// Per Vertex AI / AI Studio docs, gemini-3.1-flash-image supports a wider set
|
|
28
|
+
// than older Imagen models. Keep to the broadly-supported subset.
|
|
29
|
+
const ASPECT_RATIOS = [
|
|
30
|
+
"1:1",
|
|
31
|
+
"2:3",
|
|
32
|
+
"3:2",
|
|
33
|
+
"3:4",
|
|
34
|
+
"4:3",
|
|
35
|
+
"4:5",
|
|
36
|
+
"5:4",
|
|
37
|
+
"9:16",
|
|
38
|
+
"16:9",
|
|
39
|
+
"21:9",
|
|
40
|
+
] as const;
|
|
41
|
+
const IMAGE_SIZES = ["1K", "2K", "4K"] as const;
|
|
42
|
+
const QUALITY = ["fast", "high"] as const;
|
|
43
|
+
|
|
44
|
+
// Resolved against a live Vertex Express (AQ.*) key on 2026-05-08:
|
|
45
|
+
// gemini-3.1-flash-image → 404
|
|
46
|
+
// gemini-3.1-flash-image-preview → ok (Nano Banana 2)
|
|
47
|
+
// gemini-3-pro-image → 404
|
|
48
|
+
// gemini-3-pro-image-preview → ok (Nano Banana Pro)
|
|
49
|
+
// AI Studio keys (AIza*) sometimes publish the un-suffixed GA names.
|
|
50
|
+
// `callWithModelFallback` below transparently retries with `-preview`
|
|
51
|
+
// if the un-suffixed id 404s, so both backends just work.
|
|
52
|
+
const MODEL_FOR_QUALITY: Record<(typeof QUALITY)[number], string> = {
|
|
53
|
+
fast: "gemini-3.1-flash-image-preview", // Nano Banana 2
|
|
54
|
+
high: "gemini-3-pro-image-preview", // Nano Banana Pro
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const DEFAULT_OUTPUT_DIR =
|
|
58
|
+
process.env.PI_IMAGE_DIR?.trim() || "generated";
|
|
59
|
+
const DEFAULT_QUALITY = (process.env.PI_IMAGE_QUALITY as
|
|
60
|
+
| "fast"
|
|
61
|
+
| "high"
|
|
62
|
+
| undefined) ?? "fast";
|
|
63
|
+
const SUPPORTED_INPUT_MIME = new Set([
|
|
64
|
+
"image/png",
|
|
65
|
+
"image/jpeg",
|
|
66
|
+
"image/webp",
|
|
67
|
+
"image/gif",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function buildClient(): GoogleGenAI {
|
|
73
|
+
const apiKey = process.env.GOOGLE_API_KEY;
|
|
74
|
+
if (!apiKey) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"GOOGLE_API_KEY not set. Get one at https://aistudio.google.com/apikey (AIza…) " +
|
|
77
|
+
"or use a Vertex AI Express key (AQ.…) — both work.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
// AQ.* express keys authenticate against Vertex; AIza* against Gemini API.
|
|
81
|
+
return new GoogleGenAI({
|
|
82
|
+
apiKey,
|
|
83
|
+
...(apiKey.startsWith("AQ.") ? { vertexai: true } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function slugify(text: string): string {
|
|
88
|
+
return (
|
|
89
|
+
text
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
92
|
+
.replace(/^-+|-+$/g, "")
|
|
93
|
+
.slice(0, 48) || "image"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function timestamp(): string {
|
|
98
|
+
const d = new Date();
|
|
99
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
100
|
+
return (
|
|
101
|
+
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
|
|
102
|
+
`-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extForMime(mime: string): string {
|
|
107
|
+
switch (mime.toLowerCase()) {
|
|
108
|
+
case "image/png":
|
|
109
|
+
return ".png";
|
|
110
|
+
case "image/jpeg":
|
|
111
|
+
case "image/jpg":
|
|
112
|
+
return ".jpg";
|
|
113
|
+
case "image/webp":
|
|
114
|
+
return ".webp";
|
|
115
|
+
case "image/gif":
|
|
116
|
+
return ".gif";
|
|
117
|
+
default:
|
|
118
|
+
return ".png";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mimeFromExt(path: string): string {
|
|
123
|
+
switch (extname(path).toLowerCase()) {
|
|
124
|
+
case ".png":
|
|
125
|
+
return "image/png";
|
|
126
|
+
case ".jpg":
|
|
127
|
+
case ".jpeg":
|
|
128
|
+
return "image/jpeg";
|
|
129
|
+
case ".webp":
|
|
130
|
+
return "image/webp";
|
|
131
|
+
case ".gif":
|
|
132
|
+
return "image/gif";
|
|
133
|
+
default:
|
|
134
|
+
return "image/png";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function loadReferenceImage(
|
|
139
|
+
cwd: string,
|
|
140
|
+
pathArg: string,
|
|
141
|
+
): Promise<{ data: string; mimeType: string }> {
|
|
142
|
+
// Strip leading @ — some models include it in path arguments.
|
|
143
|
+
const cleaned = pathArg.replace(/^@/, "");
|
|
144
|
+
const abs = isAbsolute(cleaned) ? cleaned : resolve(cwd, cleaned);
|
|
145
|
+
if (!existsSync(abs)) {
|
|
146
|
+
throw new Error(`Reference image not found: ${abs}`);
|
|
147
|
+
}
|
|
148
|
+
const mimeType = mimeFromExt(abs);
|
|
149
|
+
if (!SUPPORTED_INPUT_MIME.has(mimeType)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Unsupported reference image type: ${mimeType}. Use PNG, JPEG, WebP, or GIF.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const buf = await readFile(abs);
|
|
155
|
+
return { data: buf.toString("base64"), mimeType };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolveOutputPath(
|
|
159
|
+
cwd: string,
|
|
160
|
+
prompt: string,
|
|
161
|
+
mimeType: string,
|
|
162
|
+
override: string | undefined,
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
if (override) {
|
|
165
|
+
const cleaned = override.replace(/^@/, "");
|
|
166
|
+
const abs = isAbsolute(cleaned) ? cleaned : resolve(cwd, cleaned);
|
|
167
|
+
await mkdir(resolve(abs, ".."), { recursive: true });
|
|
168
|
+
return abs;
|
|
169
|
+
}
|
|
170
|
+
const dir = isAbsolute(DEFAULT_OUTPUT_DIR)
|
|
171
|
+
? DEFAULT_OUTPUT_DIR
|
|
172
|
+
: resolve(cwd, DEFAULT_OUTPUT_DIR);
|
|
173
|
+
await mkdir(dir, { recursive: true });
|
|
174
|
+
return resolve(dir, `${slugify(prompt)}-${timestamp()}${extForMime(mimeType)}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Extension ─────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export default function (pi: ExtensionAPI) {
|
|
180
|
+
pi.registerTool({
|
|
181
|
+
name: "generate_image",
|
|
182
|
+
label: "Image Gen",
|
|
183
|
+
description:
|
|
184
|
+
"Generate or edit a PNG/JPEG image with Google's Nano Banana 2 " +
|
|
185
|
+
"(Gemini 3.1 Flash Image) or Nano Banana Pro. The image is shown " +
|
|
186
|
+
"inline in the terminal AND saved to disk so it can be re-used. " +
|
|
187
|
+
"Pass `referenceImage` to iterate on an existing picture.",
|
|
188
|
+
promptSnippet:
|
|
189
|
+
"Generate or edit images with Google Nano Banana via the GOOGLE_API_KEY env var.",
|
|
190
|
+
promptGuidelines: [
|
|
191
|
+
"Call generate_image when the user asks to create, draw, illustrate, or edit a picture.",
|
|
192
|
+
"For tweaks like 'make the sky purple', pass the previous file path as referenceImage to generate_image.",
|
|
193
|
+
"Default quality 'fast' is right for most asks; switch to 'high' only when the user explicitly wants top quality.",
|
|
194
|
+
],
|
|
195
|
+
parameters: Type.Object({
|
|
196
|
+
prompt: Type.String({
|
|
197
|
+
description:
|
|
198
|
+
"What to draw. Be specific about subject, style, lighting, " +
|
|
199
|
+
"composition. For edits with a reference image, describe only the change.",
|
|
200
|
+
}),
|
|
201
|
+
aspectRatio: Type.Optional(
|
|
202
|
+
StringEnum(ASPECT_RATIOS, {
|
|
203
|
+
description: "Aspect ratio of the output image.",
|
|
204
|
+
default: "1:1",
|
|
205
|
+
}),
|
|
206
|
+
),
|
|
207
|
+
imageSize: Type.Optional(
|
|
208
|
+
StringEnum(IMAGE_SIZES, {
|
|
209
|
+
description:
|
|
210
|
+
"Largest dimension of the image. 4K is only available on quality=high.",
|
|
211
|
+
default: "1K",
|
|
212
|
+
}),
|
|
213
|
+
),
|
|
214
|
+
quality: Type.Optional(
|
|
215
|
+
StringEnum(QUALITY, {
|
|
216
|
+
description:
|
|
217
|
+
"'fast' = Nano Banana 2 (default, ~3s, cheap). 'high' = Nano Banana Pro (slower, top quality).",
|
|
218
|
+
default: DEFAULT_QUALITY,
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
referenceImage: Type.Optional(
|
|
222
|
+
Type.String({
|
|
223
|
+
description:
|
|
224
|
+
"Optional path to an existing image (PNG/JPEG/WebP/GIF) to edit or iterate on. " +
|
|
225
|
+
"Relative paths resolve to the current working directory.",
|
|
226
|
+
}),
|
|
227
|
+
),
|
|
228
|
+
outputPath: Type.Optional(
|
|
229
|
+
Type.String({
|
|
230
|
+
description:
|
|
231
|
+
"Optional output path. Defaults to ./generated/<slug>-<timestamp>.png " +
|
|
232
|
+
"(override the dir with PI_IMAGE_DIR).",
|
|
233
|
+
}),
|
|
234
|
+
),
|
|
235
|
+
}),
|
|
236
|
+
|
|
237
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
238
|
+
const aspectRatio = params.aspectRatio ?? "1:1";
|
|
239
|
+
const imageSize = params.imageSize ?? "1K";
|
|
240
|
+
const quality = params.quality ?? DEFAULT_QUALITY;
|
|
241
|
+
const model = MODEL_FOR_QUALITY[quality];
|
|
242
|
+
const cwd = ctx.cwd;
|
|
243
|
+
|
|
244
|
+
if (signal?.aborted) {
|
|
245
|
+
return { content: [{ type: "text", text: "Cancelled." }], details: {} };
|
|
246
|
+
}
|
|
247
|
+
if (imageSize === "4K" && quality !== "high") {
|
|
248
|
+
throw new Error(
|
|
249
|
+
"imageSize=4K requires quality='high' (Nano Banana Pro).",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const client = buildClient();
|
|
254
|
+
|
|
255
|
+
// Build content parts — text + (optional) reference image.
|
|
256
|
+
const parts: Array<{
|
|
257
|
+
text?: string;
|
|
258
|
+
inlineData?: { mimeType: string; data: string };
|
|
259
|
+
}> = [];
|
|
260
|
+
let editing = false;
|
|
261
|
+
if (params.referenceImage) {
|
|
262
|
+
const ref = await loadReferenceImage(cwd, params.referenceImage);
|
|
263
|
+
parts.push({ inlineData: ref });
|
|
264
|
+
editing = true;
|
|
265
|
+
}
|
|
266
|
+
parts.push({ text: params.prompt });
|
|
267
|
+
|
|
268
|
+
const verbLabel = editing ? "Editing" : "Generating";
|
|
269
|
+
onUpdate?.({
|
|
270
|
+
content: [
|
|
271
|
+
{
|
|
272
|
+
type: "text",
|
|
273
|
+
text: `🎨 ${verbLabel} ${aspectRatio} ${imageSize} image with ${quality === "fast" ? "Nano Banana 2" : "Nano Banana Pro"}…`,
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
details: { model, aspectRatio, imageSize, quality, editing },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const callWithFallback = async (modelId: string) => {
|
|
280
|
+
try {
|
|
281
|
+
return await client.models.generateContent({
|
|
282
|
+
model: modelId,
|
|
283
|
+
contents: [{ role: "user", parts }],
|
|
284
|
+
config: {
|
|
285
|
+
responseModalities: ["IMAGE"],
|
|
286
|
+
imageConfig: { aspectRatio, imageSize },
|
|
287
|
+
abortSignal: signal,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
const msg = String(err?.message ?? err);
|
|
292
|
+
// Vertex publishes the `-preview` id while AI Studio sometimes
|
|
293
|
+
// publishes the GA id. Retry the other variant on 404.
|
|
294
|
+
const is404 = /\b404\b|NOT_FOUND|not found/i.test(msg);
|
|
295
|
+
if (is404 && !modelId.endsWith("-preview")) {
|
|
296
|
+
return await client.models.generateContent({
|
|
297
|
+
model: `${modelId}-preview`,
|
|
298
|
+
contents: [{ role: "user", parts }],
|
|
299
|
+
config: {
|
|
300
|
+
responseModalities: ["IMAGE"],
|
|
301
|
+
imageConfig: { aspectRatio, imageSize },
|
|
302
|
+
abortSignal: signal,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (is404 && modelId.endsWith("-preview")) {
|
|
307
|
+
return await client.models.generateContent({
|
|
308
|
+
model: modelId.replace(/-preview$/, ""),
|
|
309
|
+
contents: [{ role: "user", parts }],
|
|
310
|
+
config: {
|
|
311
|
+
responseModalities: ["IMAGE"],
|
|
312
|
+
imageConfig: { aspectRatio, imageSize },
|
|
313
|
+
abortSignal: signal,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
let response;
|
|
322
|
+
try {
|
|
323
|
+
response = await callWithFallback(model);
|
|
324
|
+
} catch (err: any) {
|
|
325
|
+
// Surface API errors verbatim — they tell the user (and the agent)
|
|
326
|
+
// exactly what to fix (bad key, model not enabled, etc).
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Google image API error: ${err?.message ?? String(err)}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const candidate = response.candidates?.[0];
|
|
333
|
+
const imagePart = candidate?.content?.parts?.find((p) => p.inlineData);
|
|
334
|
+
if (!imagePart?.inlineData?.data) {
|
|
335
|
+
const reason = candidate?.finishReason ?? "unknown";
|
|
336
|
+
const safety = candidate?.safetyRatings
|
|
337
|
+
?.filter((r: any) => r.blocked || r.probability === "HIGH")
|
|
338
|
+
.map((r: any) => r.category)
|
|
339
|
+
.join(", ");
|
|
340
|
+
const textOut = response.candidates?.[0]?.content?.parts
|
|
341
|
+
?.map((p) => p.text)
|
|
342
|
+
.filter(Boolean)
|
|
343
|
+
.join(" ");
|
|
344
|
+
throw new Error(
|
|
345
|
+
`No image returned (finishReason=${reason}` +
|
|
346
|
+
(safety ? `, blocked=${safety}` : "") +
|
|
347
|
+
`). ${textOut ?? ""}`.trim(),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const base64 = imagePart.inlineData.data;
|
|
352
|
+
const mimeType = imagePart.inlineData.mimeType ?? "image/png";
|
|
353
|
+
const outPath = await resolveOutputPath(
|
|
354
|
+
cwd,
|
|
355
|
+
params.prompt,
|
|
356
|
+
mimeType,
|
|
357
|
+
params.outputPath,
|
|
358
|
+
);
|
|
359
|
+
await writeFile(outPath, Buffer.from(base64, "base64"));
|
|
360
|
+
|
|
361
|
+
const summary = editing
|
|
362
|
+
? `Edited "${params.referenceImage}" → ${outPath}`
|
|
363
|
+
: `Generated → ${outPath}`;
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
// Flat ImageContent block — pi-coding-agent auto-renders inline
|
|
367
|
+
// in Kitty/iTerm2 terminals. Older terminals get the text fallback.
|
|
368
|
+
content: [
|
|
369
|
+
{ type: "text", text: summary },
|
|
370
|
+
{ type: "image", data: base64, mimeType },
|
|
371
|
+
],
|
|
372
|
+
details: {
|
|
373
|
+
model,
|
|
374
|
+
quality,
|
|
375
|
+
aspectRatio,
|
|
376
|
+
imageSize,
|
|
377
|
+
mimeType,
|
|
378
|
+
outputPath: outPath,
|
|
379
|
+
editing,
|
|
380
|
+
referenceImage: params.referenceImage,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-banana",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Generate and edit images in pi using Google Nano Banana 2 (gemini-3.1-flash-image) and Nano Banana Pro. Inline terminal preview, reference-image editing, auto-save.",
|
|
5
|
+
"author": "Francesco Frapporti <effedue@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"homepage": "https://github.com/Fornace/pi-banana#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Fornace/pi-banana.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Fornace/pi-banana/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi",
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"pi-coding-agent",
|
|
21
|
+
"google",
|
|
22
|
+
"gemini",
|
|
23
|
+
"nano-banana",
|
|
24
|
+
"nano-banana-2",
|
|
25
|
+
"nano-banana-pro",
|
|
26
|
+
"image-generation",
|
|
27
|
+
"image-edit",
|
|
28
|
+
"imagen"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"extensions",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"extensions/index.ts"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"smoke": "node _tmp/smoke.mjs"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@google/genai": "^1.52.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "*",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
50
|
+
"typebox": "^1.1.24"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@earendil-works/pi-ai": "*",
|
|
54
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
55
|
+
"@types/node": "^22.0.0",
|
|
56
|
+
"jiti": "^2.7.0",
|
|
57
|
+
"typebox": "^1.1.24",
|
|
58
|
+
"typescript": "^6.0.3"
|
|
59
|
+
}
|
|
60
|
+
}
|