@astrofoundry/pi-astro 0.6.0 → 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.
|
@@ -40,7 +40,9 @@ function makePi(): { tools: ToolDef[]; registerTool: (t: ToolDef) => void } {
|
|
|
40
40
|
return { tools, registerTool: (t) => tools.push(t) };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
type InputResponse = string | undefined;
|
|
44
|
+
|
|
45
|
+
function makeCtx(opts: { hasUI?: boolean; cwd?: string; inputs?: InputResponse[] } = {}): {
|
|
44
46
|
cwd: string;
|
|
45
47
|
hasUI: boolean;
|
|
46
48
|
ui: {
|
|
@@ -49,25 +51,27 @@ function makeCtx(override: Partial<{ hasUI: boolean; confirm: boolean; inputValu
|
|
|
49
51
|
input: ReturnType<typeof vi.fn>;
|
|
50
52
|
};
|
|
51
53
|
} {
|
|
52
|
-
const
|
|
54
|
+
const responses = [...(opts.inputs ?? [])];
|
|
53
55
|
return {
|
|
54
|
-
cwd: "/tmp/cwd",
|
|
55
|
-
hasUI,
|
|
56
|
+
cwd: opts.cwd ?? "/tmp/cwd",
|
|
57
|
+
hasUI: opts.hasUI ?? true,
|
|
56
58
|
ui: {
|
|
57
59
|
notify: vi.fn(),
|
|
58
|
-
confirm: vi.fn().mockResolvedValue(
|
|
59
|
-
input: vi.fn().
|
|
60
|
+
confirm: vi.fn().mockResolvedValue(true),
|
|
61
|
+
input: vi.fn().mockImplementation(() => Promise.resolve(responses.shift())),
|
|
60
62
|
},
|
|
61
63
|
};
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
describe("gemini-image extension", () => {
|
|
65
67
|
let fakeHome: string;
|
|
68
|
+
let fakeCwd: string;
|
|
66
69
|
const originalHome = process.env.HOME;
|
|
67
70
|
const originalKey = process.env.GEMINI_API_KEY;
|
|
68
71
|
|
|
69
72
|
beforeEach(() => {
|
|
70
73
|
fakeHome = mkdtempSync(join(tmpdir(), "gem-ext-"));
|
|
74
|
+
fakeCwd = mkdtempSync(join(tmpdir(), "gem-cwd-"));
|
|
71
75
|
process.env.HOME = fakeHome;
|
|
72
76
|
process.env.GEMINI_API_KEY = "test-key";
|
|
73
77
|
vi.resetModules();
|
|
@@ -77,6 +81,7 @@ describe("gemini-image extension", () => {
|
|
|
77
81
|
|
|
78
82
|
afterEach(() => {
|
|
79
83
|
rmSync(fakeHome, { recursive: true, force: true });
|
|
84
|
+
rmSync(fakeCwd, { recursive: true, force: true });
|
|
80
85
|
process.env.HOME = originalHome;
|
|
81
86
|
if (originalKey === undefined) delete process.env.GEMINI_API_KEY;
|
|
82
87
|
else process.env.GEMINI_API_KEY = originalKey;
|
|
@@ -89,78 +94,214 @@ describe("gemini-image extension", () => {
|
|
|
89
94
|
return { tool: pi.tools[0] };
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
const { tool } = await load();
|
|
94
|
-
expect(tool.name).toBe("gemini_image");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("uses default model gemini-2.5-flash-image and calls generateContent", async () => {
|
|
97
|
+
function pngImageResponse(): void {
|
|
98
98
|
generateContentMock.mockResolvedValue({
|
|
99
99
|
candidates: [
|
|
100
100
|
{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "BASE64DATA" } }] } },
|
|
101
101
|
],
|
|
102
102
|
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it("registers tool named gemini_image", async () => {
|
|
106
|
+
const { tool } = await load();
|
|
107
|
+
expect(tool.name).toBe("gemini_image");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("skip_confirm bypasses review and calls generateContent", async () => {
|
|
111
|
+
pngImageResponse();
|
|
103
112
|
const { tool } = await load();
|
|
104
|
-
const ctx = makeCtx();
|
|
105
|
-
const res = await tool.execute("t", { prompt: "a cat" }, undefined, undefined, ctx);
|
|
113
|
+
const ctx = makeCtx({ cwd: fakeCwd });
|
|
114
|
+
const res = await tool.execute("t", { prompt: "a cat", skip_confirm: true }, undefined, undefined, ctx);
|
|
106
115
|
expect(res.details.model).toBe("gemini-2.5-flash-image");
|
|
107
|
-
|
|
116
|
+
// ui.input should NOT be called — skip_confirm bypasses the review loop
|
|
117
|
+
expect(ctx.ui.input).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("images + text returned in content; image AFTER text", async () => {
|
|
121
|
+
pngImageResponse();
|
|
122
|
+
const { tool } = await load();
|
|
123
|
+
const res = await tool.execute(
|
|
124
|
+
"t",
|
|
125
|
+
{ prompt: "x", skip_confirm: true },
|
|
126
|
+
undefined,
|
|
127
|
+
undefined,
|
|
128
|
+
makeCtx({ cwd: fakeCwd }),
|
|
129
|
+
);
|
|
130
|
+
expect((res.content as Array<{ type: string }>)[0].type).toBe("text");
|
|
131
|
+
expect((res.content as Array<{ type: string }>)[1].type).toBe("image");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("always saves to <cwd>/.gemini-images by default", async () => {
|
|
135
|
+
pngImageResponse();
|
|
136
|
+
const { tool } = await load();
|
|
137
|
+
const res = await tool.execute(
|
|
138
|
+
"t",
|
|
139
|
+
{ prompt: "a-thing", skip_confirm: true },
|
|
140
|
+
undefined,
|
|
141
|
+
undefined,
|
|
142
|
+
makeCtx({ cwd: fakeCwd }),
|
|
143
|
+
);
|
|
144
|
+
const paths = res.details.savedPaths as string[];
|
|
145
|
+
expect(paths).toHaveLength(1);
|
|
146
|
+
expect(paths[0]).toContain(".gemini-images");
|
|
147
|
+
expect(readdirSync(join(fakeCwd, ".gemini-images"))).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("save_to override writes into that directory (resolved against cwd)", async () => {
|
|
151
|
+
pngImageResponse();
|
|
152
|
+
const { tool } = await load();
|
|
153
|
+
const res = await tool.execute(
|
|
154
|
+
"t",
|
|
155
|
+
{ prompt: "x", skip_confirm: true, save_to: "out" },
|
|
156
|
+
undefined,
|
|
157
|
+
undefined,
|
|
158
|
+
makeCtx({ cwd: fakeCwd }),
|
|
159
|
+
);
|
|
160
|
+
const paths = res.details.savedPaths as string[];
|
|
161
|
+
expect(paths[0]).toContain(join(fakeCwd, "out"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("silently saves API key on first paste (no confirm prompt)", async () => {
|
|
165
|
+
delete process.env.GEMINI_API_KEY;
|
|
166
|
+
pngImageResponse();
|
|
167
|
+
const { tool } = await load();
|
|
168
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["pasted-key"] });
|
|
169
|
+
await tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, ctx);
|
|
170
|
+
// Only ONE input call (the key paste). confirm never used.
|
|
171
|
+
expect(ctx.ui.input).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
|
173
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/saved/i), "info");
|
|
108
174
|
});
|
|
109
175
|
|
|
110
|
-
it("
|
|
176
|
+
it("errors when API key missing and no UI available", async () => {
|
|
177
|
+
delete process.env.GEMINI_API_KEY;
|
|
178
|
+
const { tool } = await load();
|
|
179
|
+
const ctx = makeCtx({ hasUI: false, cwd: fakeCwd });
|
|
180
|
+
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/no interactive UI/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("errors when user provides empty key at prompt", async () => {
|
|
184
|
+
delete process.env.GEMINI_API_KEY;
|
|
185
|
+
const { tool } = await load();
|
|
186
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: [" "] });
|
|
187
|
+
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/No API key/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("review loop: empty input confirms and proceeds", async () => {
|
|
191
|
+
pngImageResponse();
|
|
192
|
+
const { tool } = await load();
|
|
193
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: [""] });
|
|
194
|
+
await tool.execute("t", { prompt: "hello" }, undefined, undefined, ctx);
|
|
195
|
+
expect(ctx.ui.input).toHaveBeenCalledTimes(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("review loop: 'cancel' throws cancelled", async () => {
|
|
199
|
+
const { tool } = await load();
|
|
200
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["cancel"] });
|
|
201
|
+
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/cancelled/i);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("review loop: undefined input (escape) throws cancelled", async () => {
|
|
205
|
+
const { tool } = await load();
|
|
206
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: [undefined] });
|
|
207
|
+
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/cancelled/i);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("review loop: tweak '2K' updates size then Enter confirms", async () => {
|
|
211
|
+
pngImageResponse();
|
|
212
|
+
const { tool } = await load();
|
|
213
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["2K", ""] });
|
|
214
|
+
await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
|
|
215
|
+
// Two input calls: one for 2K tweak, one empty confirm
|
|
216
|
+
expect(ctx.ui.input).toHaveBeenCalledTimes(2);
|
|
217
|
+
// The second input's preview message should mention 2K
|
|
218
|
+
const secondPreview = ctx.ui.input.mock.calls[1][1] as string;
|
|
219
|
+
expect(secondPreview).toContain("2K");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("review loop: tweak 'use imagen ultra, 16:9, 3 images' + empty confirms and calls Imagen", async () => {
|
|
111
223
|
generateImagesMock.mockResolvedValue({
|
|
112
224
|
generatedImages: [
|
|
113
225
|
{ image: { imageBytes: "IMG1", mimeType: "image/png" } },
|
|
114
226
|
{ image: { imageBytes: "IMG2", mimeType: "image/png" } },
|
|
227
|
+
{ image: { imageBytes: "IMG3", mimeType: "image/png" } },
|
|
115
228
|
],
|
|
116
229
|
});
|
|
117
230
|
const { tool } = await load();
|
|
118
|
-
const ctx = makeCtx();
|
|
119
|
-
const res = await tool.execute(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
},
|
|
126
|
-
undefined,
|
|
127
|
-
undefined,
|
|
128
|
-
ctx,
|
|
129
|
-
);
|
|
130
|
-
expect(res.details.images).toBe(2);
|
|
131
|
-
const args = generateImagesMock.mock.calls[0][0];
|
|
132
|
-
expect(args.config.numberOfImages).toBe(2);
|
|
231
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["use imagen ultra 16:9 3 images", ""] });
|
|
232
|
+
const res = await tool.execute("t", { prompt: "landscape" }, undefined, undefined, ctx);
|
|
233
|
+
expect(res.details.model).toBe("imagen-4.0-ultra-generate-001");
|
|
234
|
+
expect(res.details.images).toBe(3);
|
|
235
|
+
const cfg = generateImagesMock.mock.calls[0][0].config;
|
|
236
|
+
expect(cfg.numberOfImages).toBe(3);
|
|
237
|
+
expect(cfg.aspectRatio).toBe("16:9");
|
|
133
238
|
});
|
|
134
239
|
|
|
135
|
-
it("
|
|
240
|
+
it("review loop: tweak that fails validation is rejected with notify warning, user can re-tweak", async () => {
|
|
241
|
+
pngImageResponse();
|
|
242
|
+
generateImagesMock.mockResolvedValue({
|
|
243
|
+
generatedImages: [{ image: { imageBytes: "IMG", mimeType: "image/png" } }],
|
|
244
|
+
});
|
|
245
|
+
const { tool } = await load();
|
|
246
|
+
// user tries 4K with Imagen → rejected; then switches back and confirms
|
|
247
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["imagen fast 4K", "imagen fast 2K", ""] });
|
|
248
|
+
await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
|
|
249
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Change rejected/), "warning");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("review loop: unrecognized token surfaces as warning", async () => {
|
|
253
|
+
pngImageResponse();
|
|
254
|
+
const { tool } = await load();
|
|
255
|
+
const ctx = makeCtx({ cwd: fakeCwd, inputs: ["frobnicate plz", ""] });
|
|
256
|
+
await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
|
|
257
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Didn't understand/), "warning");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("rejects unknown model at call time", async () => {
|
|
136
261
|
const { tool } = await load();
|
|
137
262
|
await expect(
|
|
138
|
-
tool.execute(
|
|
263
|
+
tool.execute(
|
|
264
|
+
"t",
|
|
265
|
+
{ prompt: "x", model: "bogus-model", skip_confirm: true },
|
|
266
|
+
undefined,
|
|
267
|
+
undefined,
|
|
268
|
+
makeCtx({ cwd: fakeCwd }),
|
|
269
|
+
),
|
|
139
270
|
).rejects.toThrow(/Unknown model/);
|
|
140
271
|
});
|
|
141
272
|
|
|
142
|
-
it("rejects invalid aspect_ratio for Imagen
|
|
273
|
+
it("rejects invalid aspect_ratio for Imagen at validate time", async () => {
|
|
143
274
|
const { tool } = await load();
|
|
144
275
|
await expect(
|
|
145
276
|
tool.execute(
|
|
146
277
|
"t",
|
|
147
|
-
{
|
|
278
|
+
{
|
|
279
|
+
prompt: "x",
|
|
280
|
+
model: "imagen-4.0-generate-001",
|
|
281
|
+
aspect_ratio: "21:9",
|
|
282
|
+
skip_confirm: true,
|
|
283
|
+
},
|
|
148
284
|
undefined,
|
|
149
285
|
undefined,
|
|
150
|
-
makeCtx(),
|
|
286
|
+
makeCtx({ cwd: fakeCwd }),
|
|
151
287
|
),
|
|
152
288
|
).rejects.toThrow(/aspect_ratio .* Imagen/);
|
|
153
289
|
});
|
|
154
290
|
|
|
155
|
-
it("rejects 4K
|
|
291
|
+
it("rejects 4K for Imagen", async () => {
|
|
156
292
|
const { tool } = await load();
|
|
157
293
|
await expect(
|
|
158
294
|
tool.execute(
|
|
159
295
|
"t",
|
|
160
|
-
{
|
|
296
|
+
{
|
|
297
|
+
prompt: "x",
|
|
298
|
+
model: "imagen-4.0-generate-001",
|
|
299
|
+
image_size: "4K",
|
|
300
|
+
skip_confirm: true,
|
|
301
|
+
},
|
|
161
302
|
undefined,
|
|
162
303
|
undefined,
|
|
163
|
-
makeCtx(),
|
|
304
|
+
makeCtx({ cwd: fakeCwd }),
|
|
164
305
|
),
|
|
165
306
|
).rejects.toThrow(/image_size .* Imagen/);
|
|
166
307
|
});
|
|
@@ -174,10 +315,11 @@ describe("gemini-image extension", () => {
|
|
|
174
315
|
prompt: "x",
|
|
175
316
|
model: "imagen-4.0-generate-001",
|
|
176
317
|
input_images: ["/tmp/x.png"],
|
|
318
|
+
skip_confirm: true,
|
|
177
319
|
},
|
|
178
320
|
undefined,
|
|
179
321
|
undefined,
|
|
180
|
-
makeCtx(),
|
|
322
|
+
makeCtx({ cwd: fakeCwd }),
|
|
181
323
|
),
|
|
182
324
|
).rejects.toThrow(/input_images/);
|
|
183
325
|
});
|
|
@@ -187,10 +329,10 @@ describe("gemini-image extension", () => {
|
|
|
187
329
|
await expect(
|
|
188
330
|
tool.execute(
|
|
189
331
|
"t",
|
|
190
|
-
{ prompt: "x", negative_prompt: "ugly" },
|
|
332
|
+
{ prompt: "x", negative_prompt: "ugly", skip_confirm: true },
|
|
191
333
|
undefined,
|
|
192
334
|
undefined,
|
|
193
|
-
makeCtx(),
|
|
335
|
+
makeCtx({ cwd: fakeCwd }),
|
|
194
336
|
),
|
|
195
337
|
).rejects.toThrow(/negative_prompt/);
|
|
196
338
|
});
|
|
@@ -198,135 +340,37 @@ describe("gemini-image extension", () => {
|
|
|
198
340
|
it("rejects seed on Gemini native", async () => {
|
|
199
341
|
const { tool } = await load();
|
|
200
342
|
await expect(
|
|
201
|
-
tool.execute("t", { prompt: "x", seed: 42 }, undefined, undefined, makeCtx()),
|
|
343
|
+
tool.execute("t", { prompt: "x", seed: 42, skip_confirm: true }, undefined, undefined, makeCtx({ cwd: fakeCwd })),
|
|
202
344
|
).rejects.toThrow(/seed/);
|
|
203
345
|
});
|
|
204
346
|
|
|
205
|
-
it("rejects number_of_images > 1 on Gemini native", async () => {
|
|
206
|
-
const { tool } = await load();
|
|
207
|
-
await expect(
|
|
208
|
-
tool.execute("t", { prompt: "x", number_of_images: 3 }, undefined, undefined, makeCtx()),
|
|
209
|
-
).rejects.toThrow(/number_of_images/);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("prompts for API key when none configured and saves if confirmed", async () => {
|
|
213
|
-
delete process.env.GEMINI_API_KEY;
|
|
214
|
-
generateContentMock.mockResolvedValue({
|
|
215
|
-
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
|
|
216
|
-
});
|
|
217
|
-
const { tool } = await load();
|
|
218
|
-
const ctx = makeCtx({ inputValue: "pasted-key" });
|
|
219
|
-
await tool.execute("t", { prompt: "x" }, undefined, undefined, ctx);
|
|
220
|
-
expect(ctx.ui.input).toHaveBeenCalled();
|
|
221
|
-
// Confirm was called for both the save prompt and the cost confirmation
|
|
222
|
-
expect(ctx.ui.confirm.mock.calls.length).toBeGreaterThanOrEqual(1);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("errors when API key missing and no UI available", async () => {
|
|
226
|
-
delete process.env.GEMINI_API_KEY;
|
|
227
|
-
const { tool } = await load();
|
|
228
|
-
const ctx = makeCtx({ hasUI: false });
|
|
229
|
-
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/no interactive UI/);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("errors when user provides empty key at prompt", async () => {
|
|
233
|
-
delete process.env.GEMINI_API_KEY;
|
|
234
|
-
const { tool } = await load();
|
|
235
|
-
const ctx = makeCtx({ inputValue: " " });
|
|
236
|
-
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/No API key/);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("cost confirmation: throws when user rejects", async () => {
|
|
240
|
-
generateContentMock.mockResolvedValue({
|
|
241
|
-
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
|
|
242
|
-
});
|
|
243
|
-
const { tool } = await load();
|
|
244
|
-
const ctx = makeCtx({ confirm: false });
|
|
245
|
-
await expect(tool.execute("t", { prompt: "x" }, undefined, undefined, ctx)).rejects.toThrow(/User cancelled/);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("skip_confirm bypasses cost prompt", async () => {
|
|
249
|
-
generateContentMock.mockResolvedValue({
|
|
250
|
-
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
|
|
251
|
-
});
|
|
252
|
-
const { tool } = await load();
|
|
253
|
-
const ctx = makeCtx();
|
|
254
|
-
await tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, ctx);
|
|
255
|
-
// confirm should not be called for cost
|
|
256
|
-
expect(ctx.ui.confirm).not.toHaveBeenCalled();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("save_to writes PNG files and reports paths", async () => {
|
|
260
|
-
generateImagesMock.mockResolvedValue({
|
|
261
|
-
generatedImages: [
|
|
262
|
-
{ image: { imageBytes: Buffer.from("fakeimage").toString("base64"), mimeType: "image/png" } },
|
|
263
|
-
],
|
|
264
|
-
});
|
|
265
|
-
const saveDir = mkdtempSync(join(tmpdir(), "gem-save-"));
|
|
266
|
-
const { tool } = await load();
|
|
267
|
-
const ctx = makeCtx();
|
|
268
|
-
const res = await tool.execute(
|
|
269
|
-
"t",
|
|
270
|
-
{
|
|
271
|
-
prompt: "a-landscape",
|
|
272
|
-
model: "imagen-4.0-fast-generate-001",
|
|
273
|
-
save_to: saveDir,
|
|
274
|
-
},
|
|
275
|
-
undefined,
|
|
276
|
-
undefined,
|
|
277
|
-
ctx,
|
|
278
|
-
);
|
|
279
|
-
const paths = res.details.savedPaths as string[];
|
|
280
|
-
expect(paths).toHaveLength(1);
|
|
281
|
-
expect(readdirSync(saveDir)).toHaveLength(1);
|
|
282
|
-
rmSync(saveDir, { recursive: true, force: true });
|
|
283
|
-
});
|
|
284
|
-
|
|
285
347
|
it("throws when model returns no images", async () => {
|
|
286
348
|
generateContentMock.mockResolvedValue({ candidates: [] });
|
|
287
349
|
const { tool } = await load();
|
|
288
|
-
await expect(
|
|
350
|
+
await expect(
|
|
351
|
+
tool.execute("t", { prompt: "x", skip_confirm: true }, undefined, undefined, makeCtx({ cwd: fakeCwd })),
|
|
352
|
+
).rejects.toThrow(/no images/);
|
|
289
353
|
});
|
|
290
354
|
|
|
291
355
|
it("input_images are read from disk and passed as inlineData", async () => {
|
|
292
356
|
const sourceDir = mkdtempSync(join(tmpdir(), "gem-input-"));
|
|
293
357
|
const imgPath = join(sourceDir, "input.png");
|
|
294
358
|
writeFileSync(imgPath, "binary-image");
|
|
295
|
-
|
|
296
|
-
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "OUT" } }] } }],
|
|
297
|
-
});
|
|
359
|
+
pngImageResponse();
|
|
298
360
|
const { tool } = await load();
|
|
299
|
-
const ctx = makeCtx();
|
|
361
|
+
const ctx = makeCtx({ cwd: fakeCwd });
|
|
300
362
|
await tool.execute(
|
|
301
363
|
"t",
|
|
302
|
-
{ prompt: "edit", input_images: [imgPath] },
|
|
364
|
+
{ prompt: "edit", input_images: [imgPath], skip_confirm: true },
|
|
303
365
|
undefined,
|
|
304
366
|
undefined,
|
|
305
367
|
ctx,
|
|
306
368
|
);
|
|
307
369
|
const callArgs = generateContentMock.mock.calls[0][0];
|
|
308
|
-
// First content is the text, subsequent are inlineData parts
|
|
309
370
|
expect(callArgs.contents.some((c: Record<string, unknown>) => "inlineData" in c)).toBe(true);
|
|
310
371
|
rmSync(sourceDir, { recursive: true, force: true });
|
|
311
372
|
});
|
|
312
373
|
|
|
313
|
-
it("passes aspect_ratio + image_size into imageConfig for Gemini native", async () => {
|
|
314
|
-
generateContentMock.mockResolvedValue({
|
|
315
|
-
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "X" } }] } }],
|
|
316
|
-
});
|
|
317
|
-
const { tool } = await load();
|
|
318
|
-
await tool.execute(
|
|
319
|
-
"t",
|
|
320
|
-
{ prompt: "x", aspect_ratio: "16:9", image_size: "2K" },
|
|
321
|
-
undefined,
|
|
322
|
-
undefined,
|
|
323
|
-
makeCtx(),
|
|
324
|
-
);
|
|
325
|
-
const cfg = generateContentMock.mock.calls[0][0].config;
|
|
326
|
-
expect(cfg.imageConfig.aspectRatio).toBe("16:9");
|
|
327
|
-
expect(cfg.imageConfig.imageSize).toBe("2K");
|
|
328
|
-
});
|
|
329
|
-
|
|
330
374
|
it("Imagen config: numberOfImages + negative_prompt + seed + person_generation all pass through", async () => {
|
|
331
375
|
generateImagesMock.mockResolvedValue({
|
|
332
376
|
generatedImages: [{ image: { imageBytes: "X", mimeType: "image/png" } }],
|
|
@@ -341,10 +385,11 @@ describe("gemini-image extension", () => {
|
|
|
341
385
|
negative_prompt: "blurry",
|
|
342
386
|
seed: 1337,
|
|
343
387
|
person_generation: "allow_adult",
|
|
388
|
+
skip_confirm: true,
|
|
344
389
|
},
|
|
345
390
|
undefined,
|
|
346
391
|
undefined,
|
|
347
|
-
makeCtx(),
|
|
392
|
+
makeCtx({ cwd: fakeCwd }),
|
|
348
393
|
);
|
|
349
394
|
const cfg = generateImagesMock.mock.calls[0][0].config;
|
|
350
395
|
expect(cfg.numberOfImages).toBe(2);
|
|
@@ -352,18 +397,4 @@ describe("gemini-image extension", () => {
|
|
|
352
397
|
expect(cfg.seed).toBe(1337);
|
|
353
398
|
expect(cfg.personGeneration).toBe("allow_adult");
|
|
354
399
|
});
|
|
355
|
-
|
|
356
|
-
it("rejects invalid Gemini aspect_ratio", async () => {
|
|
357
|
-
const { tool } = await load();
|
|
358
|
-
await expect(
|
|
359
|
-
tool.execute("t", { prompt: "x", aspect_ratio: "17:3" }, undefined, undefined, makeCtx()),
|
|
360
|
-
).rejects.toThrow(/aspect_ratio .* Gemini native/);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
it("rejects invalid Gemini image_size", async () => {
|
|
364
|
-
const { tool } = await load();
|
|
365
|
-
await expect(
|
|
366
|
-
tool.execute("t", { prompt: "x", image_size: "8K" as "4K" }, undefined, undefined, makeCtx()),
|
|
367
|
-
).rejects.toThrow(/image_size/);
|
|
368
|
-
});
|
|
369
400
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
3
4
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
4
5
|
import { GoogleGenAI } from "@google/genai";
|
|
@@ -17,8 +18,11 @@ import {
|
|
|
17
18
|
isImagen,
|
|
18
19
|
type ImageModel,
|
|
19
20
|
} from "./models.ts";
|
|
21
|
+
import { parseTweaks } from "./parseTweaks.ts";
|
|
20
22
|
import { estimateCost, formatUsd, hasPricing } from "./pricing.ts";
|
|
21
23
|
|
|
24
|
+
const DEFAULT_SAVE_SUBDIR = ".gemini-images";
|
|
25
|
+
|
|
22
26
|
const params = Type.Object({
|
|
23
27
|
prompt: Type.String({ description: "Text prompt describing the image to generate." }),
|
|
24
28
|
model: Type.Optional(
|
|
@@ -45,8 +49,10 @@ const params = Type.Object({
|
|
|
45
49
|
description: "Gemini native only. Absolute paths to images for editing.",
|
|
46
50
|
}),
|
|
47
51
|
),
|
|
48
|
-
save_to: Type.Optional(
|
|
49
|
-
|
|
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." })),
|
|
50
56
|
});
|
|
51
57
|
|
|
52
58
|
type Params = {
|
|
@@ -76,11 +82,8 @@ async function ensureApiKey(ctx: ExtensionContext): Promise<string> {
|
|
|
76
82
|
throw new Error("No API key provided.");
|
|
77
83
|
}
|
|
78
84
|
const key = pasted.trim();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (save) {
|
|
82
|
-
persistApiKey(key);
|
|
83
|
-
}
|
|
85
|
+
persistApiKey(key);
|
|
86
|
+
ctx.ui.notify("Gemini API key saved.", "info");
|
|
84
87
|
return key;
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -123,7 +126,7 @@ function validateForModel(model: ImageModel, p: Params): void {
|
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
interface DecodedImage {
|
|
126
|
-
data: string;
|
|
129
|
+
data: string;
|
|
127
130
|
mimeType: string;
|
|
128
131
|
}
|
|
129
132
|
|
|
@@ -209,104 +212,163 @@ async function callImagen(
|
|
|
209
212
|
}
|
|
210
213
|
|
|
211
214
|
function saveImages(images: DecodedImage[], dir: string, prompt: string): string[] {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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";
|
|
217
222
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
218
223
|
const paths: string[] = [];
|
|
219
224
|
for (let i = 0; i < images.length; i++) {
|
|
220
225
|
const ext = images[i].mimeType === "image/jpeg" ? "jpg" : "png";
|
|
221
226
|
const base = images.length > 1 ? `${slug}-${ts}-${i + 1}.${ext}` : `${slug}-${ts}.${ext}`;
|
|
222
|
-
const full =
|
|
227
|
+
const full = join(dir, base);
|
|
223
228
|
writeFileSync(full, Buffer.from(images[i].data, "base64"));
|
|
224
229
|
paths.push(full);
|
|
225
230
|
}
|
|
226
231
|
return paths;
|
|
227
232
|
}
|
|
228
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
|
+
|
|
229
310
|
export default function geminiImageExtension(pi: ExtensionAPI): void {
|
|
230
311
|
pi.registerTool({
|
|
231
312
|
name: "gemini_image",
|
|
232
313
|
label: "Gemini Image",
|
|
233
314
|
description:
|
|
234
|
-
"Generate or edit images via Google
|
|
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.",
|
|
235
316
|
promptGuidelines: [
|
|
236
|
-
"
|
|
237
|
-
"
|
|
238
|
-
"
|
|
239
|
-
"
|
|
240
|
-
"4K
|
|
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.",
|
|
241
322
|
],
|
|
242
323
|
parameters: params,
|
|
243
324
|
async execute(_toolCallId, input, signal, _onUpdate, ctx) {
|
|
244
|
-
const
|
|
245
|
-
const model = (
|
|
325
|
+
const requested = input as Params;
|
|
326
|
+
const model = (requested.model as ImageModel | undefined) ?? DEFAULT_MODEL;
|
|
246
327
|
|
|
247
328
|
if (!(ALL_MODELS as readonly string[]).includes(model)) {
|
|
248
329
|
throw new Error(`Unknown model "${model}". Valid: ${ALL_MODELS.join(", ")}.`);
|
|
249
330
|
}
|
|
250
|
-
|
|
251
|
-
validateForModel(model, p);
|
|
331
|
+
validateForModel(model, requested);
|
|
252
332
|
|
|
253
333
|
const apiKey = await ensureApiKey(ctx);
|
|
254
334
|
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const est = estimateCost({
|
|
259
|
-
model,
|
|
260
|
-
prompt: p.prompt,
|
|
261
|
-
numberOfImages: numImages,
|
|
262
|
-
imageSize: p.image_size,
|
|
263
|
-
});
|
|
264
|
-
const proceed = await ctx.ui.confirm(
|
|
265
|
-
"Confirm image generation",
|
|
266
|
-
`Model: ${model}\nImages: ${numImages}${p.image_size ? `\nSize: ${p.image_size}` : ""}\nEstimated: ~${formatUsd(est.estimatedUsd)} (${est.breakdown})`,
|
|
267
|
-
);
|
|
268
|
-
if (!proceed) {
|
|
269
|
-
throw new Error("User cancelled image generation.");
|
|
270
|
-
}
|
|
271
|
-
}
|
|
335
|
+
const final = requested.skip_confirm || !ctx.hasUI
|
|
336
|
+
? requested
|
|
337
|
+
: await reviewAndConfirm(ctx, requested);
|
|
272
338
|
|
|
273
|
-
const
|
|
339
|
+
const finalModel = (final.model as ImageModel | undefined) ?? DEFAULT_MODEL;
|
|
274
340
|
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
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);
|
|
278
345
|
|
|
279
346
|
if (images.length === 0) {
|
|
280
347
|
throw new Error("Model returned no images.");
|
|
281
348
|
}
|
|
282
349
|
|
|
283
|
-
const
|
|
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);
|
|
284
352
|
|
|
285
353
|
const summaryLines = [
|
|
286
|
-
`Generated ${images.length} image${images.length > 1 ? "s" : ""} with ${
|
|
287
|
-
`Prompt: "${
|
|
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 })),
|
|
288
367
|
];
|
|
289
|
-
if (p.aspect_ratio) summaryLines.push(`Aspect ratio: ${p.aspect_ratio}`);
|
|
290
|
-
if (p.image_size) summaryLines.push(`Size: ${p.image_size}`);
|
|
291
|
-
if (p.negative_prompt) summaryLines.push(`Negative: "${p.negative_prompt}"`);
|
|
292
|
-
if (p.person_generation) summaryLines.push(`People: ${p.person_generation}`);
|
|
293
|
-
if (p.seed !== undefined) summaryLines.push(`Seed: ${p.seed}`);
|
|
294
|
-
if (savedPaths.length > 0) summaryLines.push(`Saved to:\n ${savedPaths.join("\n ")}`);
|
|
295
|
-
|
|
296
|
-
const content: AgentToolResult<unknown>["content"] = images.map((img) => ({
|
|
297
|
-
type: "image",
|
|
298
|
-
data: img.data,
|
|
299
|
-
mimeType: img.mimeType,
|
|
300
|
-
}));
|
|
301
|
-
content.push({ type: "text", text: summaryLines.join("\n") });
|
|
302
368
|
|
|
303
369
|
return {
|
|
304
370
|
content,
|
|
305
|
-
details: {
|
|
306
|
-
model,
|
|
307
|
-
images: images.length,
|
|
308
|
-
savedPaths,
|
|
309
|
-
},
|
|
371
|
+
details: { model: finalModel, images: images.length, savedPaths },
|
|
310
372
|
};
|
|
311
373
|
},
|
|
312
374
|
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a free-text review/tweak utterance into partial parameter updates.
|
|
3
|
+
*
|
|
4
|
+
* Intentionally conservative: we recognize a known vocabulary (models,
|
|
5
|
+
* aspect ratios, sizes, numeric counts, person-gen tokens, and explicit
|
|
6
|
+
* `prompt:` / `negative:` / `seed:` / `save to <path>` phrases). Unknown
|
|
7
|
+
* tokens are returned in `unrecognized` so the caller can surface them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ALL_MODELS,
|
|
12
|
+
GEMINI_ASPECT_RATIOS,
|
|
13
|
+
IMAGEN_ASPECT_RATIOS,
|
|
14
|
+
GEMINI_IMAGE_SIZES,
|
|
15
|
+
PERSON_GENERATION_VALUES,
|
|
16
|
+
type ImageModel,
|
|
17
|
+
} from "./models.ts";
|
|
18
|
+
|
|
19
|
+
export interface TweakPatch {
|
|
20
|
+
prompt?: string;
|
|
21
|
+
model?: ImageModel;
|
|
22
|
+
aspect_ratio?: string;
|
|
23
|
+
image_size?: "1K" | "2K" | "4K";
|
|
24
|
+
number_of_images?: number;
|
|
25
|
+
negative_prompt?: string;
|
|
26
|
+
person_generation?: (typeof PERSON_GENERATION_VALUES)[number];
|
|
27
|
+
seed?: number;
|
|
28
|
+
save_to?: string;
|
|
29
|
+
verbatim?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ParseOutcome {
|
|
33
|
+
patch: TweakPatch;
|
|
34
|
+
unrecognized: string[];
|
|
35
|
+
intent: "confirm" | "cancel" | "tweak";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MODEL_ALIASES: Record<string, ImageModel> = {
|
|
39
|
+
flash: "gemini-2.5-flash-image",
|
|
40
|
+
"flash image": "gemini-2.5-flash-image",
|
|
41
|
+
"gemini flash": "gemini-2.5-flash-image",
|
|
42
|
+
"2.5 flash": "gemini-2.5-flash-image",
|
|
43
|
+
"nano banana 2": "gemini-3.1-flash-image-preview",
|
|
44
|
+
"nano banana": "gemini-3.1-flash-image-preview",
|
|
45
|
+
"3.1 flash": "gemini-3.1-flash-image-preview",
|
|
46
|
+
pro: "gemini-3-pro-image-preview",
|
|
47
|
+
"pro model": "gemini-3-pro-image-preview",
|
|
48
|
+
"gemini pro": "gemini-3-pro-image-preview",
|
|
49
|
+
"3 pro": "gemini-3-pro-image-preview",
|
|
50
|
+
"nano banana pro": "gemini-3-pro-image-preview",
|
|
51
|
+
imagen: "imagen-4.0-generate-001",
|
|
52
|
+
"imagen standard": "imagen-4.0-generate-001",
|
|
53
|
+
"imagen 4": "imagen-4.0-generate-001",
|
|
54
|
+
"imagen fast": "imagen-4.0-fast-generate-001",
|
|
55
|
+
"imagen 4 fast": "imagen-4.0-fast-generate-001",
|
|
56
|
+
"imagen ultra": "imagen-4.0-ultra-generate-001",
|
|
57
|
+
"imagen 4 ultra": "imagen-4.0-ultra-generate-001",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const ALL_ASPECT_RATIOS = [
|
|
61
|
+
...new Set([...GEMINI_ASPECT_RATIOS, ...IMAGEN_ASPECT_RATIOS]),
|
|
62
|
+
];
|
|
63
|
+
const ALL_SIZES = [...GEMINI_IMAGE_SIZES];
|
|
64
|
+
|
|
65
|
+
function trimQuotes(s: string): string {
|
|
66
|
+
return s.replace(/^['"`](.*)['"`]$/s, "$1").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseTweaks(raw: string): ParseOutcome {
|
|
70
|
+
const patch: TweakPatch = {};
|
|
71
|
+
const unrecognized: string[] = [];
|
|
72
|
+
const trimmed = raw.trim();
|
|
73
|
+
|
|
74
|
+
if (trimmed === "") {
|
|
75
|
+
return { patch, unrecognized, intent: "confirm" };
|
|
76
|
+
}
|
|
77
|
+
if (/^(y|yes|ok|go|confirm|proceed)$/i.test(trimmed)) {
|
|
78
|
+
return { patch, unrecognized, intent: "confirm" };
|
|
79
|
+
}
|
|
80
|
+
if (/^(n|no|cancel|abort|quit|exit)$/i.test(trimmed)) {
|
|
81
|
+
return { patch, unrecognized, intent: "cancel" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let rest = trimmed;
|
|
85
|
+
|
|
86
|
+
const extract = (pattern: RegExp, onMatch: (m: RegExpExecArray) => void): void => {
|
|
87
|
+
const match = pattern.exec(rest);
|
|
88
|
+
if (match) {
|
|
89
|
+
onMatch(match);
|
|
90
|
+
rest = (rest.slice(0, match.index) + rest.slice(match.index + match[0].length)).trim();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// prompt: <rest> / use prompt <rest> (greedy — takes rest of string unless another keyword follows)
|
|
95
|
+
extract(
|
|
96
|
+
/(?:^|\s)(?:use\s+prompt\s*[:=]?|prompt\s*[:=])\s*(.+?)(?=(?:\s+(?:model|size|image_size|aspect|ratio|images?|n=|count|negative|seed|save|person|verbatim|raw|as-?is|dont_allow|allow_adult|allow_all)\b)|$)/i,
|
|
97
|
+
(m) => {
|
|
98
|
+
patch.prompt = trimQuotes(m[1]);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
extract(/(?:^|\s)(?:negative(?:_prompt)?)\s*[:=]\s*(.+?)(?=$|\s+(?:model|size|aspect|ratio|images?|n=|count|seed|save|person|verbatim|raw)\b)/i, (m) => {
|
|
103
|
+
patch.negative_prompt = trimQuotes(m[1]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
extract(/(?:^|\s)seed\s*[:=]?\s*(-?\d+)/i, (m) => {
|
|
107
|
+
patch.seed = Number(m[1]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
extract(/(?:^|\s)save\s+to\s+(\S+)/i, (m) => {
|
|
111
|
+
patch.save_to = m[1];
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
extract(/(?:^|\s)(verbatim|as-?is|raw)\b/i, () => {
|
|
115
|
+
patch.verbatim = true;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
extract(/(?:^|\s)(\d+)\s*(?:images?|imgs?)\b/i, (m) => {
|
|
119
|
+
patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
|
|
120
|
+
});
|
|
121
|
+
extract(/(?:^|\s)(?:n|count)\s*[:=]\s*(\d+)/i, (m) => {
|
|
122
|
+
patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
|
|
123
|
+
});
|
|
124
|
+
extract(/(?:^|\s)give\s+me\s+(\d+)\b/i, (m) => {
|
|
125
|
+
patch.number_of_images = Math.max(1, Math.min(4, Number(m[1])));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
for (const size of ALL_SIZES) {
|
|
129
|
+
const re = new RegExp(`(?:^|\\s)${size}(?=\\s|$)`, "i");
|
|
130
|
+
const m = re.exec(rest);
|
|
131
|
+
if (m) {
|
|
132
|
+
patch.image_size = size;
|
|
133
|
+
rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const ratio of ALL_ASPECT_RATIOS) {
|
|
139
|
+
const re = new RegExp(`(?:^|\\s)${ratio.replace(":", "\\s*:\\s*")}(?=\\s|$)`);
|
|
140
|
+
const m = re.exec(rest);
|
|
141
|
+
if (m) {
|
|
142
|
+
patch.aspect_ratio = ratio;
|
|
143
|
+
rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const pg of PERSON_GENERATION_VALUES) {
|
|
149
|
+
const re = new RegExp(`(?:^|\\s)${pg}(?=\\s|$)`, "i");
|
|
150
|
+
const m = re.exec(rest);
|
|
151
|
+
if (m) {
|
|
152
|
+
patch.person_generation = pg;
|
|
153
|
+
rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Full model id exact match
|
|
159
|
+
for (const model of ALL_MODELS) {
|
|
160
|
+
const idx = rest.toLowerCase().indexOf(model.toLowerCase());
|
|
161
|
+
if (idx !== -1) {
|
|
162
|
+
patch.model = model;
|
|
163
|
+
rest = (rest.slice(0, idx) + rest.slice(idx + model.length)).trim();
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Model aliases (only if model not set yet) — check longest first so "nano banana pro" beats "pro"
|
|
169
|
+
if (!patch.model) {
|
|
170
|
+
const aliases = Object.keys(MODEL_ALIASES).sort((a, b) => b.length - a.length);
|
|
171
|
+
for (const alias of aliases) {
|
|
172
|
+
const re = new RegExp(`(?:^|\\s)${alias.replace(/\s+/g, "\\s+")}(?=\\s|$|[.,])`, "i");
|
|
173
|
+
const m = re.exec(rest);
|
|
174
|
+
if (m) {
|
|
175
|
+
patch.model = MODEL_ALIASES[alias];
|
|
176
|
+
rest = (rest.slice(0, m.index) + rest.slice(m.index + m[0].length)).trim();
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Split leftover into whitespace-separated tokens; non-filler ones are "unrecognized".
|
|
183
|
+
const leftover = rest.split(/\s+/).filter((t) => t.length > 0 && !/^(use|and|with|,)$/i.test(t));
|
|
184
|
+
if (leftover.length > 0) unrecognized.push(...leftover);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
patch,
|
|
188
|
+
unrecognized,
|
|
189
|
+
intent: Object.keys(patch).length > 0 ? "tweak" : "tweak",
|
|
190
|
+
};
|
|
191
|
+
}
|