@earendil-works/pi-radius-work 0.1.6 → 0.1.8
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 +3 -1
- package/extensions/radius-image-generation.ts +107 -44
- package/package.json +3 -2
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.
|
|
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 {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
withFileMutationQueue,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Type } from "typebox";
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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: {
|
|
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.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Radius Google Workspace and image generation extensions for Pi",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@earendil-works/pi-ai": "*",
|
|
23
23
|
"@earendil-works/pi-coding-agent": "*",
|
|
24
|
-
"@earendil-works/pi-tui": "*"
|
|
24
|
+
"@earendil-works/pi-tui": "*",
|
|
25
|
+
"typebox": "*"
|
|
25
26
|
},
|
|
26
27
|
"pi": {
|
|
27
28
|
"extensions": [
|