@astrofoundry/pi-astro 0.5.1 → 0.6.1
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/README.md +4 -0
- package/extensions/astro-agents/discovery.test.ts +152 -0
- package/extensions/astro-agents/index.test.ts +208 -0
- package/extensions/astro-agents/spawn.test.ts +218 -0
- package/extensions/claude-globals/index.test.ts +77 -0
- package/extensions/gemini-image/credentials.test.ts +130 -0
- package/extensions/gemini-image/credentials.ts +53 -0
- package/extensions/gemini-image/index.test.ts +400 -0
- package/extensions/gemini-image/index.ts +375 -0
- package/extensions/gemini-image/models.test.ts +45 -0
- package/extensions/gemini-image/models.ts +50 -0
- package/extensions/gemini-image/parseTweaks.test.ts +119 -0
- package/extensions/gemini-image/parseTweaks.ts +191 -0
- package/extensions/gemini-image/pricing.test.ts +95 -0
- package/extensions/gemini-image/pricing.ts +102 -0
- package/extensions/grimoire/index.test.ts +244 -0
- package/extensions/multi-edit/classic.test.ts +274 -0
- package/extensions/multi-edit/classic.ts +435 -0
- package/extensions/multi-edit/diff.test.ts +65 -0
- package/extensions/multi-edit/diff.ts +143 -0
- package/extensions/multi-edit/index.test.ts +170 -0
- package/extensions/multi-edit/index.ts +267 -0
- package/extensions/multi-edit/patch.test.ts +242 -0
- package/extensions/multi-edit/patch.ts +463 -0
- package/extensions/multi-edit/types.ts +53 -0
- package/extensions/multi-edit/workspace.test.ts +165 -0
- package/extensions/multi-edit/workspace.ts +85 -0
- package/package.json +9 -3
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
5
|
+
import { GoogleGenAI } from "@google/genai";
|
|
6
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { persistApiKey, resolveExistingApiKey } from "./credentials.ts";
|
|
9
|
+
import {
|
|
10
|
+
ALL_MODELS,
|
|
11
|
+
DEFAULT_MODEL,
|
|
12
|
+
GEMINI_ASPECT_RATIOS,
|
|
13
|
+
GEMINI_IMAGE_SIZES,
|
|
14
|
+
IMAGEN_ASPECT_RATIOS,
|
|
15
|
+
IMAGEN_IMAGE_SIZES,
|
|
16
|
+
PERSON_GENERATION_VALUES,
|
|
17
|
+
isGeminiNative,
|
|
18
|
+
isImagen,
|
|
19
|
+
type ImageModel,
|
|
20
|
+
} from "./models.ts";
|
|
21
|
+
import { parseTweaks } from "./parseTweaks.ts";
|
|
22
|
+
import { estimateCost, formatUsd, hasPricing } from "./pricing.ts";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_SAVE_SUBDIR = ".gemini-images";
|
|
25
|
+
|
|
26
|
+
const params = Type.Object({
|
|
27
|
+
prompt: Type.String({ description: "Text prompt describing the image to generate." }),
|
|
28
|
+
model: Type.Optional(
|
|
29
|
+
StringEnum(ALL_MODELS as unknown as readonly [string, ...string[]], {
|
|
30
|
+
description: `Model. Default: ${DEFAULT_MODEL}.`,
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
aspect_ratio: Type.Optional(Type.String({ description: "Aspect ratio; validated per model family." })),
|
|
34
|
+
image_size: Type.Optional(
|
|
35
|
+
StringEnum(["1K", "2K", "4K"] as const, { description: "Output resolution. 4K is Gemini-native only." }),
|
|
36
|
+
),
|
|
37
|
+
number_of_images: Type.Optional(
|
|
38
|
+
Type.Integer({ minimum: 1, maximum: 4, description: "Imagen only. 1-4." }),
|
|
39
|
+
),
|
|
40
|
+
negative_prompt: Type.Optional(Type.String({ description: "Imagen only." })),
|
|
41
|
+
person_generation: Type.Optional(
|
|
42
|
+
StringEnum(PERSON_GENERATION_VALUES as unknown as readonly [string, ...string[]], {
|
|
43
|
+
description: "People policy: dont_allow | allow_adult | allow_all.",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
seed: Type.Optional(Type.Integer({ description: "Imagen only. Reproducibility seed." })),
|
|
47
|
+
input_images: Type.Optional(
|
|
48
|
+
Type.Array(Type.String(), {
|
|
49
|
+
description: "Gemini native only. Absolute paths to images for editing.",
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
save_to: Type.Optional(
|
|
53
|
+
Type.String({ description: `Directory to save PNG(s). Default: <cwd>/${DEFAULT_SAVE_SUBDIR}/.` }),
|
|
54
|
+
),
|
|
55
|
+
skip_confirm: Type.Optional(Type.Boolean({ description: "Skip the review/confirm prompt and call the API directly." })),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
type Params = {
|
|
59
|
+
prompt: string;
|
|
60
|
+
model?: string;
|
|
61
|
+
aspect_ratio?: string;
|
|
62
|
+
image_size?: "1K" | "2K" | "4K";
|
|
63
|
+
number_of_images?: number;
|
|
64
|
+
negative_prompt?: string;
|
|
65
|
+
person_generation?: (typeof PERSON_GENERATION_VALUES)[number];
|
|
66
|
+
seed?: number;
|
|
67
|
+
input_images?: string[];
|
|
68
|
+
save_to?: string;
|
|
69
|
+
skip_confirm?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
async function ensureApiKey(ctx: ExtensionContext): Promise<string> {
|
|
73
|
+
const existing = resolveExistingApiKey();
|
|
74
|
+
if (existing) return existing;
|
|
75
|
+
|
|
76
|
+
if (!ctx.hasUI) {
|
|
77
|
+
throw new Error("Gemini API key is not configured and no interactive UI is available to ask for one.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pasted = await ctx.ui.input("Paste your Gemini API key:");
|
|
81
|
+
if (!pasted || !pasted.trim()) {
|
|
82
|
+
throw new Error("No API key provided.");
|
|
83
|
+
}
|
|
84
|
+
const key = pasted.trim();
|
|
85
|
+
persistApiKey(key);
|
|
86
|
+
ctx.ui.notify("Gemini API key saved.", "info");
|
|
87
|
+
return key;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateForModel(model: ImageModel, p: Params): void {
|
|
91
|
+
if (isImagen(model)) {
|
|
92
|
+
if (p.aspect_ratio && !(IMAGEN_ASPECT_RATIOS as readonly string[]).includes(p.aspect_ratio)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`aspect_ratio "${p.aspect_ratio}" is not supported for Imagen. Valid: ${IMAGEN_ASPECT_RATIOS.join(", ")}.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (p.image_size && !(IMAGEN_IMAGE_SIZES as readonly string[]).includes(p.image_size)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`image_size "${p.image_size}" is not supported for Imagen. Valid: ${IMAGEN_IMAGE_SIZES.join(", ")}.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (p.input_images && p.input_images.length > 0) {
|
|
103
|
+
throw new Error("Imagen does not support input_images (text-to-image only). Use a Gemini-native model.");
|
|
104
|
+
}
|
|
105
|
+
} else if (isGeminiNative(model)) {
|
|
106
|
+
if (p.aspect_ratio && !(GEMINI_ASPECT_RATIOS as readonly string[]).includes(p.aspect_ratio)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`aspect_ratio "${p.aspect_ratio}" is not supported for Gemini native. Valid: ${GEMINI_ASPECT_RATIOS.join(", ")}.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (p.image_size && !(GEMINI_IMAGE_SIZES as readonly string[]).includes(p.image_size)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`image_size "${p.image_size}" is not supported for Gemini native. Valid: ${GEMINI_IMAGE_SIZES.join(", ")}.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (p.number_of_images !== undefined && p.number_of_images !== 1) {
|
|
117
|
+
throw new Error("Gemini native produces a single image per call; number_of_images only applies to Imagen.");
|
|
118
|
+
}
|
|
119
|
+
if (p.negative_prompt) {
|
|
120
|
+
throw new Error("negative_prompt is Imagen-only.");
|
|
121
|
+
}
|
|
122
|
+
if (p.seed !== undefined) {
|
|
123
|
+
throw new Error("seed is Imagen-only.");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface DecodedImage {
|
|
129
|
+
data: string;
|
|
130
|
+
mimeType: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function callGeminiNative(
|
|
134
|
+
ai: GoogleGenAI,
|
|
135
|
+
model: string,
|
|
136
|
+
p: Params,
|
|
137
|
+
signal: AbortSignal | undefined,
|
|
138
|
+
): Promise<DecodedImage[]> {
|
|
139
|
+
const imageConfig: Record<string, string> = {};
|
|
140
|
+
if (p.aspect_ratio) imageConfig.aspectRatio = p.aspect_ratio;
|
|
141
|
+
if (p.image_size) imageConfig.imageSize = p.image_size;
|
|
142
|
+
if (p.person_generation) imageConfig.personGeneration = p.person_generation;
|
|
143
|
+
|
|
144
|
+
const contents: Array<Record<string, unknown>> = [{ text: p.prompt }];
|
|
145
|
+
for (const path of p.input_images ?? []) {
|
|
146
|
+
const buffer = readFileSync(path);
|
|
147
|
+
const mimeType = path.toLowerCase().endsWith(".jpg") || path.toLowerCase().endsWith(".jpeg")
|
|
148
|
+
? "image/jpeg"
|
|
149
|
+
: path.toLowerCase().endsWith(".webp")
|
|
150
|
+
? "image/webp"
|
|
151
|
+
: "image/png";
|
|
152
|
+
contents.push({
|
|
153
|
+
inlineData: {
|
|
154
|
+
mimeType,
|
|
155
|
+
data: buffer.toString("base64"),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const response = await ai.models.generateContent({
|
|
161
|
+
model,
|
|
162
|
+
contents,
|
|
163
|
+
config: {
|
|
164
|
+
responseModalities: ["Text", "Image"],
|
|
165
|
+
...(Object.keys(imageConfig).length > 0 ? { imageConfig } : {}),
|
|
166
|
+
...(signal ? { abortSignal: signal } : {}),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const images: DecodedImage[] = [];
|
|
171
|
+
for (const candidate of response.candidates ?? []) {
|
|
172
|
+
for (const part of candidate.content?.parts ?? []) {
|
|
173
|
+
if (part.inlineData?.data && part.inlineData.mimeType) {
|
|
174
|
+
images.push({ data: part.inlineData.data, mimeType: part.inlineData.mimeType });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return images;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function callImagen(
|
|
182
|
+
ai: GoogleGenAI,
|
|
183
|
+
model: string,
|
|
184
|
+
p: Params,
|
|
185
|
+
signal: AbortSignal | undefined,
|
|
186
|
+
): Promise<DecodedImage[]> {
|
|
187
|
+
const config: Record<string, unknown> = {};
|
|
188
|
+
if (p.aspect_ratio) config.aspectRatio = p.aspect_ratio;
|
|
189
|
+
if (p.image_size) config.imageSize = p.image_size;
|
|
190
|
+
if (p.number_of_images !== undefined) config.numberOfImages = p.number_of_images;
|
|
191
|
+
if (p.negative_prompt) config.negativePrompt = p.negative_prompt;
|
|
192
|
+
if (p.person_generation) config.personGeneration = p.person_generation;
|
|
193
|
+
if (p.seed !== undefined) config.seed = p.seed;
|
|
194
|
+
if (signal) config.abortSignal = signal;
|
|
195
|
+
|
|
196
|
+
const response = await ai.models.generateImages({
|
|
197
|
+
model,
|
|
198
|
+
prompt: p.prompt,
|
|
199
|
+
...(Object.keys(config).length > 0 ? { config } : {}),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const images: DecodedImage[] = [];
|
|
203
|
+
for (const generated of response.generatedImages ?? []) {
|
|
204
|
+
if (generated.image?.imageBytes) {
|
|
205
|
+
images.push({
|
|
206
|
+
data: generated.image.imageBytes,
|
|
207
|
+
mimeType: generated.image.mimeType ?? "image/png",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return images;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function saveImages(images: DecodedImage[], dir: string, prompt: string): string[] {
|
|
215
|
+
mkdirSync(dir, { recursive: true });
|
|
216
|
+
const slug =
|
|
217
|
+
prompt
|
|
218
|
+
.toLowerCase()
|
|
219
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
220
|
+
.replace(/^-+|-+$/g, "")
|
|
221
|
+
.slice(0, 40) || "image";
|
|
222
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
223
|
+
const paths: string[] = [];
|
|
224
|
+
for (let i = 0; i < images.length; i++) {
|
|
225
|
+
const ext = images[i].mimeType === "image/jpeg" ? "jpg" : "png";
|
|
226
|
+
const base = images.length > 1 ? `${slug}-${ts}-${i + 1}.${ext}` : `${slug}-${ts}.${ext}`;
|
|
227
|
+
const full = join(dir, base);
|
|
228
|
+
writeFileSync(full, Buffer.from(images[i].data, "base64"));
|
|
229
|
+
paths.push(full);
|
|
230
|
+
}
|
|
231
|
+
return paths;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderPreview(model: ImageModel, p: Params, estimate: { estimatedUsd: number; breakdown: string }): string {
|
|
235
|
+
const nImages = isImagen(model) ? p.number_of_images ?? 1 : 1;
|
|
236
|
+
const lines: string[] = [];
|
|
237
|
+
lines.push(`Model: ${model}`);
|
|
238
|
+
lines.push(`Prompt: "${p.prompt.length > 160 ? `${p.prompt.slice(0, 160)}…` : p.prompt}"`);
|
|
239
|
+
if (p.image_size) lines.push(`Size: ${p.image_size}`);
|
|
240
|
+
if (p.aspect_ratio) lines.push(`Aspect ratio: ${p.aspect_ratio}`);
|
|
241
|
+
lines.push(`Images: ${nImages}`);
|
|
242
|
+
if (p.negative_prompt) lines.push(`Negative: "${p.negative_prompt.slice(0, 80)}"`);
|
|
243
|
+
if (p.seed !== undefined) lines.push(`Seed: ${p.seed}`);
|
|
244
|
+
if (p.person_generation) lines.push(`People: ${p.person_generation}`);
|
|
245
|
+
lines.push(`Estimated: ~${formatUsd(estimate.estimatedUsd)} (${estimate.breakdown})`);
|
|
246
|
+
return lines.join(" | ");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function reviewAndConfirm(
|
|
250
|
+
ctx: ExtensionContext,
|
|
251
|
+
p: Params,
|
|
252
|
+
): Promise<Params> {
|
|
253
|
+
let current = { ...p };
|
|
254
|
+
while (true) {
|
|
255
|
+
const model = (current.model as ImageModel | undefined) ?? DEFAULT_MODEL;
|
|
256
|
+
const est = hasPricing(model)
|
|
257
|
+
? estimateCost({
|
|
258
|
+
model,
|
|
259
|
+
prompt: current.prompt,
|
|
260
|
+
numberOfImages: isImagen(model) ? current.number_of_images ?? 1 : 1,
|
|
261
|
+
imageSize: current.image_size,
|
|
262
|
+
})
|
|
263
|
+
: { estimatedUsd: 0, breakdown: "unknown pricing" };
|
|
264
|
+
|
|
265
|
+
const preview = renderPreview(model, current, est);
|
|
266
|
+
const input = await ctx.ui.input(
|
|
267
|
+
"Review — Enter to confirm, type changes, or 'cancel'",
|
|
268
|
+
preview,
|
|
269
|
+
);
|
|
270
|
+
if (input === undefined) {
|
|
271
|
+
throw new Error("User cancelled image generation.");
|
|
272
|
+
}
|
|
273
|
+
const parsed = parseTweaks(input);
|
|
274
|
+
if (parsed.intent === "cancel") {
|
|
275
|
+
throw new Error("User cancelled image generation.");
|
|
276
|
+
}
|
|
277
|
+
if (parsed.intent === "confirm") {
|
|
278
|
+
return current;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Apply patch. Re-validate before looping.
|
|
282
|
+
const next: Params = { ...current };
|
|
283
|
+
if (parsed.patch.prompt !== undefined) next.prompt = parsed.patch.prompt;
|
|
284
|
+
if (parsed.patch.model !== undefined) next.model = parsed.patch.model;
|
|
285
|
+
if (parsed.patch.aspect_ratio !== undefined) next.aspect_ratio = parsed.patch.aspect_ratio;
|
|
286
|
+
if (parsed.patch.image_size !== undefined) next.image_size = parsed.patch.image_size;
|
|
287
|
+
if (parsed.patch.number_of_images !== undefined) next.number_of_images = parsed.patch.number_of_images;
|
|
288
|
+
if (parsed.patch.negative_prompt !== undefined) next.negative_prompt = parsed.patch.negative_prompt;
|
|
289
|
+
if (parsed.patch.seed !== undefined) next.seed = parsed.patch.seed;
|
|
290
|
+
if (parsed.patch.person_generation !== undefined) next.person_generation = parsed.patch.person_generation;
|
|
291
|
+
if (parsed.patch.save_to !== undefined) next.save_to = parsed.patch.save_to;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
validateForModel((next.model as ImageModel | undefined) ?? DEFAULT_MODEL, next);
|
|
295
|
+
current = next;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
298
|
+
ctx.ui.notify(`Change rejected: ${msg}`, "warning");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (parsed.unrecognized.length > 0) {
|
|
302
|
+
ctx.ui.notify(
|
|
303
|
+
`Didn't understand: ${parsed.unrecognized.join(" ")}. Try keywords like 2K, 16:9, 'imagen ultra', 'prompt: ...'.`,
|
|
304
|
+
"warning",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export default function geminiImageExtension(pi: ExtensionAPI): void {
|
|
311
|
+
pi.registerTool({
|
|
312
|
+
name: "gemini_image",
|
|
313
|
+
label: "Gemini Image",
|
|
314
|
+
description:
|
|
315
|
+
"Generate or edit images via Google Gemini native and Imagen 4 models. Call this tool directly — it runs its own review/tweak/confirm gate before hitting the API, so there is no need to ask the user for a prompt or model in advance.",
|
|
316
|
+
promptGuidelines: [
|
|
317
|
+
"When the user asks to generate or edit an image, CALL THIS TOOL IMMEDIATELY. Do not ask them to confirm the prompt or model first — the tool shows a preview (prompt, model, size, estimated cost) and lets the user tweak or confirm inside its own UI.",
|
|
318
|
+
"By default, enhance the user's casual request into a stronger image-gen prompt (style, lighting, composition, detail qualifiers appropriate to what they asked for). The preview shows the final prompt so the user can catch unwanted enhancements and tweak before confirming.",
|
|
319
|
+
"If the user signals verbatim intent — wraps the prompt in backticks, or says 'as-is' / 'verbatim' / 'exactly' / 'don't change' / 'use my words' — pass the prompt UNCHANGED.",
|
|
320
|
+
"NEVER override explicit user parameters. If the user specifies model, resolution, aspect ratio, image count, negative prompt, seed, person-generation policy, or save path, pass those exact values through: 'use pro' → model=gemini-3-pro-image-preview; 'imagen ultra' → imagen-4.0-ultra-generate-001; '4K' → image_size=4K; '16:9' → aspect_ratio=16:9; 'give me 4' → number_of_images=4; 'save to <path>' → save_to=<path>.",
|
|
321
|
+
"Model selection when the user didn't specify one: gemini-2.5-flash-image for general use (default); gemini-3-pro-image-preview for studio-quality 4K, complex layouts, or precise text; imagen-4.0-*-generate-001 for batch (1-4 images per call), negative_prompt, or seeded reproducibility; image editing (input_images) is only supported by Gemini native models.",
|
|
322
|
+
],
|
|
323
|
+
parameters: params,
|
|
324
|
+
async execute(_toolCallId, input, signal, _onUpdate, ctx) {
|
|
325
|
+
const requested = input as Params;
|
|
326
|
+
const model = (requested.model as ImageModel | undefined) ?? DEFAULT_MODEL;
|
|
327
|
+
|
|
328
|
+
if (!(ALL_MODELS as readonly string[]).includes(model)) {
|
|
329
|
+
throw new Error(`Unknown model "${model}". Valid: ${ALL_MODELS.join(", ")}.`);
|
|
330
|
+
}
|
|
331
|
+
validateForModel(model, requested);
|
|
332
|
+
|
|
333
|
+
const apiKey = await ensureApiKey(ctx);
|
|
334
|
+
|
|
335
|
+
const final = requested.skip_confirm || !ctx.hasUI
|
|
336
|
+
? requested
|
|
337
|
+
: await reviewAndConfirm(ctx, requested);
|
|
338
|
+
|
|
339
|
+
const finalModel = (final.model as ImageModel | undefined) ?? DEFAULT_MODEL;
|
|
340
|
+
|
|
341
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
342
|
+
const images = isImagen(finalModel)
|
|
343
|
+
? await callImagen(ai, finalModel, final, signal)
|
|
344
|
+
: await callGeminiNative(ai, finalModel, final, signal);
|
|
345
|
+
|
|
346
|
+
if (images.length === 0) {
|
|
347
|
+
throw new Error("Model returned no images.");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const saveDir = final.save_to ? resolve(ctx.cwd, final.save_to) : join(ctx.cwd, DEFAULT_SAVE_SUBDIR);
|
|
351
|
+
const savedPaths = saveImages(images, saveDir, final.prompt);
|
|
352
|
+
|
|
353
|
+
const summaryLines = [
|
|
354
|
+
`Generated ${images.length} image${images.length > 1 ? "s" : ""} with ${finalModel}.`,
|
|
355
|
+
`Prompt: "${final.prompt}"`,
|
|
356
|
+
];
|
|
357
|
+
if (final.aspect_ratio) summaryLines.push(`Aspect ratio: ${final.aspect_ratio}`);
|
|
358
|
+
if (final.image_size) summaryLines.push(`Size: ${final.image_size}`);
|
|
359
|
+
if (final.negative_prompt) summaryLines.push(`Negative: "${final.negative_prompt}"`);
|
|
360
|
+
if (final.person_generation) summaryLines.push(`People: ${final.person_generation}`);
|
|
361
|
+
if (final.seed !== undefined) summaryLines.push(`Seed: ${final.seed}`);
|
|
362
|
+
summaryLines.push(`Saved to:\n ${savedPaths.join("\n ")}`);
|
|
363
|
+
|
|
364
|
+
const content: AgentToolResult<unknown>["content"] = [
|
|
365
|
+
{ type: "text", text: summaryLines.join("\n") },
|
|
366
|
+
...images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })),
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
content,
|
|
371
|
+
details: { model: finalModel, images: images.length, savedPaths },
|
|
372
|
+
};
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ALL_MODELS,
|
|
4
|
+
DEFAULT_MODEL,
|
|
5
|
+
GEMINI_ASPECT_RATIOS,
|
|
6
|
+
GEMINI_NATIVE_MODELS,
|
|
7
|
+
IMAGEN_ASPECT_RATIOS,
|
|
8
|
+
IMAGEN_MODELS,
|
|
9
|
+
isGeminiNative,
|
|
10
|
+
isImagen,
|
|
11
|
+
} from "./models.ts";
|
|
12
|
+
|
|
13
|
+
describe("models", () => {
|
|
14
|
+
it("has 3 Gemini native + 3 Imagen = 6 models", () => {
|
|
15
|
+
expect(GEMINI_NATIVE_MODELS).toHaveLength(3);
|
|
16
|
+
expect(IMAGEN_MODELS).toHaveLength(3);
|
|
17
|
+
expect(ALL_MODELS).toHaveLength(6);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("default is gemini-2.5-flash-image", () => {
|
|
21
|
+
expect(DEFAULT_MODEL).toBe("gemini-2.5-flash-image");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("Gemini aspect ratios include 4:5 and 5:4 (missing from the junior's impl)", () => {
|
|
25
|
+
expect(GEMINI_ASPECT_RATIOS).toContain("4:5");
|
|
26
|
+
expect(GEMINI_ASPECT_RATIOS).toContain("5:4");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("Imagen has 5 aspect ratios (no 2:3 / 3:2 / 4:5 / 5:4 / 21:9)", () => {
|
|
30
|
+
expect(IMAGEN_ASPECT_RATIOS).toHaveLength(5);
|
|
31
|
+
expect(IMAGEN_ASPECT_RATIOS).not.toContain("2:3");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("isGeminiNative narrows correctly", () => {
|
|
35
|
+
expect(isGeminiNative("gemini-2.5-flash-image")).toBe(true);
|
|
36
|
+
expect(isGeminiNative("imagen-4.0-generate-001")).toBe(false);
|
|
37
|
+
expect(isGeminiNative("unknown")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("isImagen narrows correctly", () => {
|
|
41
|
+
expect(isImagen("imagen-4.0-ultra-generate-001")).toBe(true);
|
|
42
|
+
expect(isImagen("gemini-3-pro-image-preview")).toBe(false);
|
|
43
|
+
expect(isImagen("unknown")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const GEMINI_NATIVE_MODELS = [
|
|
2
|
+
"gemini-2.5-flash-image",
|
|
3
|
+
"gemini-3.1-flash-image-preview",
|
|
4
|
+
"gemini-3-pro-image-preview",
|
|
5
|
+
] as const;
|
|
6
|
+
|
|
7
|
+
export const IMAGEN_MODELS = [
|
|
8
|
+
"imagen-4.0-fast-generate-001",
|
|
9
|
+
"imagen-4.0-generate-001",
|
|
10
|
+
"imagen-4.0-ultra-generate-001",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type GeminiNativeModel = (typeof GEMINI_NATIVE_MODELS)[number];
|
|
14
|
+
export type ImagenModel = (typeof IMAGEN_MODELS)[number];
|
|
15
|
+
export type ImageModel = GeminiNativeModel | ImagenModel;
|
|
16
|
+
|
|
17
|
+
export const ALL_MODELS: readonly ImageModel[] = [
|
|
18
|
+
...GEMINI_NATIVE_MODELS,
|
|
19
|
+
...IMAGEN_MODELS,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_MODEL: ImageModel = "gemini-2.5-flash-image";
|
|
23
|
+
|
|
24
|
+
export function isGeminiNative(model: string): model is GeminiNativeModel {
|
|
25
|
+
return (GEMINI_NATIVE_MODELS as readonly string[]).includes(model);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isImagen(model: string): model is ImagenModel {
|
|
29
|
+
return (IMAGEN_MODELS as readonly string[]).includes(model);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const GEMINI_ASPECT_RATIOS = [
|
|
33
|
+
"1:1",
|
|
34
|
+
"2:3",
|
|
35
|
+
"3:2",
|
|
36
|
+
"3:4",
|
|
37
|
+
"4:3",
|
|
38
|
+
"4:5",
|
|
39
|
+
"5:4",
|
|
40
|
+
"9:16",
|
|
41
|
+
"16:9",
|
|
42
|
+
"21:9",
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
export const IMAGEN_ASPECT_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
|
|
46
|
+
|
|
47
|
+
export const GEMINI_IMAGE_SIZES = ["1K", "2K", "4K"] as const;
|
|
48
|
+
export const IMAGEN_IMAGE_SIZES = ["1K", "2K"] as const;
|
|
49
|
+
|
|
50
|
+
export const PERSON_GENERATION_VALUES = ["dont_allow", "allow_adult", "allow_all"] as const;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseTweaks } from "./parseTweaks.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseTweaks intent", () => {
|
|
5
|
+
it("empty string = confirm", () => {
|
|
6
|
+
expect(parseTweaks("").intent).toBe("confirm");
|
|
7
|
+
expect(parseTweaks(" ").intent).toBe("confirm");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("'yes' / 'y' / 'go' / 'ok' / 'proceed' / 'confirm' = confirm", () => {
|
|
11
|
+
for (const word of ["yes", "y", "go", "ok", "proceed", "confirm", "YES"]) {
|
|
12
|
+
expect(parseTweaks(word).intent).toBe("confirm");
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("'cancel' / 'abort' / 'no' / 'n' / 'quit' = cancel", () => {
|
|
17
|
+
for (const word of ["cancel", "abort", "no", "n", "quit", "CANCEL"]) {
|
|
18
|
+
expect(parseTweaks(word).intent).toBe("cancel");
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("parseTweaks patches", () => {
|
|
24
|
+
it("recognizes image sizes 1K / 2K / 4K", () => {
|
|
25
|
+
expect(parseTweaks("2K").patch.image_size).toBe("2K");
|
|
26
|
+
expect(parseTweaks("4K").patch.image_size).toBe("4K");
|
|
27
|
+
expect(parseTweaks("use 1K please").patch.image_size).toBe("1K");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("recognizes aspect ratios", () => {
|
|
31
|
+
expect(parseTweaks("16:9").patch.aspect_ratio).toBe("16:9");
|
|
32
|
+
expect(parseTweaks("4:3").patch.aspect_ratio).toBe("4:3");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("recognizes full model ids", () => {
|
|
36
|
+
expect(parseTweaks("gemini-3-pro-image-preview").patch.model).toBe("gemini-3-pro-image-preview");
|
|
37
|
+
expect(parseTweaks("imagen-4.0-ultra-generate-001").patch.model).toBe("imagen-4.0-ultra-generate-001");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("recognizes model aliases", () => {
|
|
41
|
+
expect(parseTweaks("use pro").patch.model).toBe("gemini-3-pro-image-preview");
|
|
42
|
+
expect(parseTweaks("imagen ultra").patch.model).toBe("imagen-4.0-ultra-generate-001");
|
|
43
|
+
expect(parseTweaks("flash").patch.model).toBe("gemini-2.5-flash-image");
|
|
44
|
+
expect(parseTweaks("imagen fast").patch.model).toBe("imagen-4.0-fast-generate-001");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("longer aliases win over shorter (e.g. 'nano banana pro' over 'pro')", () => {
|
|
48
|
+
expect(parseTweaks("nano banana pro").patch.model).toBe("gemini-3-pro-image-preview");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("recognizes image counts", () => {
|
|
52
|
+
expect(parseTweaks("3 images").patch.number_of_images).toBe(3);
|
|
53
|
+
expect(parseTweaks("give me 4").patch.number_of_images).toBe(4);
|
|
54
|
+
expect(parseTweaks("n=2").patch.number_of_images).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("clamps number_of_images to 1-4", () => {
|
|
58
|
+
expect(parseTweaks("10 images").patch.number_of_images).toBe(4);
|
|
59
|
+
expect(parseTweaks("0 images").patch.number_of_images).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("recognizes prompt: form", () => {
|
|
63
|
+
expect(parseTweaks("prompt: a purple squirrel").patch.prompt).toBe("a purple squirrel");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("recognizes 'use prompt ...' form", () => {
|
|
67
|
+
expect(parseTweaks("use prompt a purple squirrel").patch.prompt).toBe("a purple squirrel");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("strips quotes around prompt", () => {
|
|
71
|
+
expect(parseTweaks(`prompt: "a cat"`).patch.prompt).toBe("a cat");
|
|
72
|
+
expect(parseTweaks(`prompt: 'a cat'`).patch.prompt).toBe("a cat");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("recognizes negative_prompt", () => {
|
|
76
|
+
expect(parseTweaks("negative: blurry").patch.negative_prompt).toBe("blurry");
|
|
77
|
+
expect(parseTweaks("negative_prompt: ugly, blurry").patch.negative_prompt).toBe("ugly, blurry");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("recognizes seed", () => {
|
|
81
|
+
expect(parseTweaks("seed: 42").patch.seed).toBe(42);
|
|
82
|
+
expect(parseTweaks("seed 1337").patch.seed).toBe(1337);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("recognizes save to <path>", () => {
|
|
86
|
+
expect(parseTweaks("save to ./out").patch.save_to).toBe("./out");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("recognizes verbatim / as-is / raw", () => {
|
|
90
|
+
expect(parseTweaks("verbatim").patch.verbatim).toBe(true);
|
|
91
|
+
expect(parseTweaks("as-is").patch.verbatim).toBe(true);
|
|
92
|
+
expect(parseTweaks("raw").patch.verbatim).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("recognizes person_generation tokens", () => {
|
|
96
|
+
expect(parseTweaks("allow_adult").patch.person_generation).toBe("allow_adult");
|
|
97
|
+
expect(parseTweaks("dont_allow").patch.person_generation).toBe("dont_allow");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("combines multiple changes in one utterance", () => {
|
|
101
|
+
const p = parseTweaks("use imagen ultra 16:9 3 images");
|
|
102
|
+
expect(p.patch.model).toBe("imagen-4.0-ultra-generate-001");
|
|
103
|
+
expect(p.patch.aspect_ratio).toBe("16:9");
|
|
104
|
+
expect(p.patch.number_of_images).toBe(3);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("unrecognized tokens are returned", () => {
|
|
108
|
+
const p = parseTweaks("frobnicate plz");
|
|
109
|
+
expect(p.unrecognized.length).toBeGreaterThan(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("filler words 'use', 'and', 'with' don't count as unrecognized", () => {
|
|
113
|
+
const p = parseTweaks("use 2K and 16:9 with allow_adult");
|
|
114
|
+
expect(p.unrecognized).toEqual([]);
|
|
115
|
+
expect(p.patch.image_size).toBe("2K");
|
|
116
|
+
expect(p.patch.aspect_ratio).toBe("16:9");
|
|
117
|
+
expect(p.patch.person_generation).toBe("allow_adult");
|
|
118
|
+
});
|
|
119
|
+
});
|