@earendil-works/pi-radius-work 0.1.5 → 0.1.7

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 CHANGED
@@ -46,7 +46,9 @@ Set `GOOGLE_WORKSPACE_TOKENS_DIR` to use another token directory. Set `GOOGLE_WO
46
46
 
47
47
  ## Image generation
48
48
 
49
- The `image_generation` tool accepts a text prompt and optional base64-encoded input images. It can generate a new image or edit supplied images. The tool is only available while the current model uses the `radius` or `radius-dev` provider.
49
+ The `image_generation` tool accepts a text prompt, an optional output path, and optional base64-encoded input images. It can generate a new image or edit supplied images. Generated image bytes are written directly to disk instead of being added to the conversation context. Relative output paths are resolved from Pi's working directory; when no path is supplied, the tool creates `generated-image-<tool-call-id>.<extension>` there. If the gateway returns multiple images, the filenames receive numeric suffixes.
50
+
51
+ The tool result contains only the saved file paths and small metadata. The tool is only available while the current model uses the `radius` or `radius-dev` provider.
50
52
 
51
53
  The gateway defaults to `https://radius.pi.dev`. Development and API-key overrides use `PI_GATEWAY`, `PI_GATEWAY_API_KEY`, `PI_DEV_GATEWAY`, and `PI_DEV_GATEWAY_API_KEY`.
52
54
 
@@ -1,6 +1,10 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, extname, resolve } from "node:path";
1
3
  import { Type } from "@earendil-works/pi-ai";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { Box, Spacer, Text } from "@earendil-works/pi-tui";
4
+ import {
5
+ type ExtensionAPI,
6
+ withFileMutationQueue,
7
+ } from "@earendil-works/pi-coding-agent";
4
8
 
5
9
  type GatewayImageGenerationConfig = {
6
10
  endpoint: string;
@@ -25,12 +29,19 @@ type GatewayProvider = {
25
29
 
26
30
  type ImageGenerationToolInput = {
27
31
  prompt: string;
32
+ output_path?: string;
28
33
  input_images?: Array<{
29
34
  mediaType: string;
30
35
  data: string;
31
36
  }>;
32
37
  };
33
38
 
39
+ type SavedImage = {
40
+ path: string;
41
+ mediaType: string;
42
+ bytes: number;
43
+ };
44
+
34
45
  type GatewayImageGenerationResponse = {
35
46
  kind: "radius#image_generation";
36
47
  model: string;
@@ -49,7 +60,6 @@ const DEV_GATEWAY_PROVIDER_ID = "radius-dev";
49
60
  const GATEWAY_API_KEY_ENV = "PI_GATEWAY_API_KEY";
50
61
  const DEV_GATEWAY_API_KEY_ENV = "PI_DEV_GATEWAY_API_KEY";
51
62
  const IMAGE_GENERATION_TOOL_NAME = "image_generation";
52
- const IMAGE_GENERATION_MESSAGE_TYPE = "radius-image-generation";
53
63
 
54
64
  export default async function (pi: ExtensionAPI) {
55
65
  const providers: GatewayProvider[] = [
@@ -73,36 +83,22 @@ export default async function (pi: ExtensionAPI) {
73
83
  });
74
84
  }
75
85
 
76
- pi.registerMessageRenderer(IMAGE_GENERATION_MESSAGE_TYPE, (message, _options, theme) => {
77
- const details = message.details as GatewayImageGenerationResponse | undefined;
78
- const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
79
- box.addChild(
80
- new Text(theme.fg("customMessageLabel", "\x1b[1m[image_generation]\x1b[22m"), 0, 0),
81
- );
82
- box.addChild(new Spacer(1));
83
- box.addChild(new Text(theme.fg("customMessageText", message.content as string), 0, 0));
84
- if (details?.images?.[0]) {
85
- const first = details.images[0];
86
- const match = /^data:[^;]+;base64,(.*)$/s.exec(first.dataUrl);
87
- if (match?.[1]) {
88
- box.addChild(new Spacer(1));
89
- box.addChild(
90
- new Text(theme.fg("customMessageText", `[image attached: ${first.mediaType}]`), 0, 0),
91
- );
92
- }
93
- }
94
- return box;
95
- });
96
-
97
86
  pi.registerTool({
98
87
  name: IMAGE_GENERATION_TOOL_NAME,
99
88
  label: "Image Generation",
100
89
  description:
101
- "Generate or edit an image through Radius using Gemini 2.5 Flash Image via OpenRouter.",
102
- promptSnippet: "Generate or edit an image through Radius",
90
+ "Generate or edit an image through Radius using Gemini 2.5 Flash Image via OpenRouter. Generated images are written to disk and the tool returns their paths.",
91
+ promptSnippet: "Generate or edit an image through Radius and save it to disk",
103
92
  parameters: Type.Object(
104
93
  {
105
94
  prompt: Type.String({ description: "Image generation prompt" }),
95
+ output_path: Type.Optional(
96
+ Type.String({
97
+ minLength: 1,
98
+ description:
99
+ "File path for the generated image, relative to the working directory unless absolute. For multiple images, a numeric suffix is added. Defaults to generated-image-<tool-call-id>.<extension>.",
100
+ }),
101
+ ),
106
102
  input_images: Type.Optional(
107
103
  Type.Array(
108
104
  Type.Object(
@@ -118,7 +114,7 @@ export default async function (pi: ExtensionAPI) {
118
114
  { additionalProperties: false },
119
115
  ),
120
116
  async execute(
121
- _toolCallId: string,
117
+ toolCallId: string,
122
118
  params: ImageGenerationToolInput,
123
119
  signal: AbortSignal | undefined,
124
120
  _onUpdate: unknown,
@@ -147,28 +143,95 @@ export default async function (pi: ExtensionAPI) {
147
143
  throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
148
144
  }
149
145
  const payload = (await response.json()) as GatewayImageGenerationResponse;
150
- const summary = `Generated ${payload.images.length} image(s) with ${payload.model}.`;
151
- pi.sendMessage({
152
- customType: IMAGE_GENERATION_MESSAGE_TYPE,
153
- content: summary,
154
- display: true,
155
- details: payload,
156
- });
157
- const content: Array<any> = [{ type: "text", text: JSON.stringify(payload, null, 2) }];
158
- for (const image of payload.images) {
159
- const match = /^data:[^;]+;base64,(.*)$/s.exec(image.dataUrl);
160
- if (match?.[1]) {
161
- content.push({ type: "image", data: match[1], mimeType: image.mediaType });
162
- }
163
- }
146
+ const images = await saveGeneratedImages(payload, params.output_path, toolCallId, ctx.cwd);
147
+ const summary = [
148
+ `Generated ${images.length} ${images.length === 1 ? "image" : "images"} with ${payload.model}.`,
149
+ ...images.map((image) => `Saved ${image.mediaType} (${image.bytes} bytes) to ${image.path}`),
150
+ ].join("\n");
164
151
  return {
165
- content,
166
- details: { provider: provider.id, ...payload },
152
+ content: [{ type: "text", text: summary }],
153
+ details: {
154
+ provider: provider.id,
155
+ model: payload.model,
156
+ images,
157
+ },
167
158
  };
168
159
  },
169
160
  });
170
161
  }
171
162
 
163
+ async function saveGeneratedImages(
164
+ payload: GatewayImageGenerationResponse,
165
+ requestedPath: string | undefined,
166
+ toolCallId: string,
167
+ cwd: string,
168
+ ): Promise<SavedImage[]> {
169
+ if (payload.images.length === 0) {
170
+ throw new Error("Radius returned no generated images");
171
+ }
172
+
173
+ const basePath = requestedPath
174
+ ? resolve(cwd, requestedPath.replace(/^@/u, ""))
175
+ : undefined;
176
+ const safeToolCallId = toolCallId.replace(/[^a-zA-Z0-9_-]/gu, "-").slice(0, 80) || Date.now();
177
+ const savedImages: SavedImage[] = [];
178
+
179
+ for (const [index, image] of payload.images.entries()) {
180
+ const data = decodeImageDataUrl(image.dataUrl);
181
+ const path = basePath
182
+ ? addImageIndex(basePath, index, payload.images.length)
183
+ : resolve(
184
+ cwd,
185
+ `generated-image-${safeToolCallId}${payload.images.length > 1 ? `-${index + 1}` : ""}${extensionForMediaType(image.mediaType)}`,
186
+ );
187
+
188
+ await withFileMutationQueue(path, async () => {
189
+ await mkdir(dirname(path), { recursive: true });
190
+ await writeFile(path, data);
191
+ });
192
+ savedImages.push({ path, mediaType: image.mediaType, bytes: data.length });
193
+ }
194
+
195
+ return savedImages;
196
+ }
197
+
198
+ function decodeImageDataUrl(dataUrl: string): Buffer {
199
+ const match = /^data:[^;,]+;base64,(.*)$/su.exec(dataUrl);
200
+ if (!match?.[1]) {
201
+ throw new Error("Radius returned an invalid image data URL");
202
+ }
203
+ return Buffer.from(match[1], "base64");
204
+ }
205
+
206
+ function addImageIndex(path: string, index: number, total: number): string {
207
+ if (total === 1) {
208
+ return path;
209
+ }
210
+ const extension = extname(path);
211
+ const stem = extension ? path.slice(0, -extension.length) : path;
212
+ return `${stem}-${index + 1}${extension}`;
213
+ }
214
+
215
+ function extensionForMediaType(mediaType: string): string {
216
+ switch (mediaType.toLowerCase()) {
217
+ case "image/avif":
218
+ return ".avif";
219
+ case "image/gif":
220
+ return ".gif";
221
+ case "image/jpeg":
222
+ case "image/jpg":
223
+ return ".jpg";
224
+ case "image/png":
225
+ return ".png";
226
+ case "image/svg+xml":
227
+ return ".svg";
228
+ case "image/webp":
229
+ return ".webp";
230
+ default:
231
+ return ".img";
232
+ }
233
+ }
234
+
172
235
  function selectProvider(providers: GatewayProvider[], ctx: any): GatewayProvider {
173
236
  const provider = providers.find((candidate) => candidate.id === ctx.model?.provider);
174
237
  if (!provider) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@earendil-works/pi-radius-work",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Radius Google Workspace and image generation extensions for Pi",
5
5
  "keywords": [
6
6
  "pi-package"