@oai404iao/pi-codex-imagegen 0.1.0-alpha.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/LICENSE +28 -0
- package/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/OpenAI-Codex-NOTICE.txt +6 -0
- package/README.md +32 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/package.json +80 -0
- package/provenance/openai-codex-eb9dceba-reserved-tools.json +140 -0
- package/src/background-image-generation.ts +476 -0
- package/src/background-image-jobs.ts +116 -0
- package/src/index.ts +33 -0
- package/src/tools/image-generation/capture.ts +42 -0
- package/src/tools/image-generation/display.ts +73 -0
- package/src/tools/image-generation/preview.ts +79 -0
- package/src/tools/image-generation/storage.ts +185 -0
- package/src/tools/image-generation/types.ts +36 -0
- package/src/tools/image-generation.ts +284 -0
- package/src/utils/images.ts +73 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { extname, isAbsolute, relative, resolve } from "node:path";
|
|
6
|
+
import { supportsImageInput, type ModelLike } from "@oai404iao/pi-codex-runtime/internal/capabilities";
|
|
7
|
+
import {
|
|
8
|
+
buildCodexJsonHeaders,
|
|
9
|
+
hasCodexRequestAuth,
|
|
10
|
+
} from "@oai404iao/pi-codex-runtime/internal/codex-http";
|
|
11
|
+
import { createBackgroundImageJobs, panelBranch } from "./background-image-jobs.js";
|
|
12
|
+
import { listResolvedModelProfiles } from "@oai404iao/pi-codex-runtime/internal/model-catalog/catalog";
|
|
13
|
+
import { loadModelSettings } from "@oai404iao/pi-codex-runtime/internal/model-catalog/runtime";
|
|
14
|
+
import { setProviderGeneratedHeader } from "@oai404iao/pi-codex-runtime/internal/provider-headers";
|
|
15
|
+
import { loadSettings } from "@oai404iao/pi-codex-runtime/internal/settings";
|
|
16
|
+
import { standaloneImageGeneration } from "./tools/image-generation.js";
|
|
17
|
+
import {
|
|
18
|
+
buildGeneratedImageDisplayText,
|
|
19
|
+
saveOpenAICodexGeneratedImage,
|
|
20
|
+
} from "./tools/image-generation/storage.js";
|
|
21
|
+
import { IMAGE_SAVE_DISPLAY_MESSAGE_TYPE, type SavedGeneratedImage } from "./tools/image-generation/types.js";
|
|
22
|
+
import { projectRoot } from "./utils/images.js";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
25
|
+
const BACKGROUND_IMAGE_INSTRUCTIONS = "Generate or edit images with the hosted image_generation tool. Use the user's prompt and any provided reference images. Return the image_generation_call result.";
|
|
26
|
+
const IMAGE_GEN_ERROR_MESSAGE_TYPE = "codex-image-generation-error";
|
|
27
|
+
|
|
28
|
+
export interface ParsedImageGenCommand {
|
|
29
|
+
prompt: string;
|
|
30
|
+
imagePaths: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ReferenceImage {
|
|
34
|
+
path: string;
|
|
35
|
+
mimeType: string;
|
|
36
|
+
base64: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CodexImageResult {
|
|
40
|
+
id: string;
|
|
41
|
+
result: string;
|
|
42
|
+
outputFormat?: string;
|
|
43
|
+
revisedPrompt?: string;
|
|
44
|
+
imageModel?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ImageGenerationErrorDetails {
|
|
48
|
+
message: string;
|
|
49
|
+
prompt?: string;
|
|
50
|
+
imageModel?: string;
|
|
51
|
+
referenceCount?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ModelRegistryLike {
|
|
55
|
+
getAll?: () => unknown;
|
|
56
|
+
getAvailable?: () => unknown;
|
|
57
|
+
find?: (provider: string, id: string) => unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
61
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isModelLike(value: unknown): value is ModelLike {
|
|
65
|
+
return isRecord(value) && typeof value.provider === "string" && (typeof value.id === "string" || typeof value.name === "string");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function registryModels(registry: ModelRegistryLike | undefined): ModelLike[] {
|
|
69
|
+
if (!registry) return [];
|
|
70
|
+
for (const method of [registry.getAll, registry.getAvailable]) {
|
|
71
|
+
if (typeof method !== "function") continue;
|
|
72
|
+
try {
|
|
73
|
+
const value = method.call(registry);
|
|
74
|
+
if (Array.isArray(value)) return value.filter(isModelLike);
|
|
75
|
+
} catch {
|
|
76
|
+
// Try the next registry shape.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isConfiguredImageModel(model: ModelLike | undefined): boolean {
|
|
83
|
+
if (!model || !supportsImageInput(model)) return false;
|
|
84
|
+
const settings = loadModelSettings(model);
|
|
85
|
+
return Boolean(
|
|
86
|
+
settings.modelProfile?.effective.enabled
|
|
87
|
+
&& settings.imageGenerationImplementation,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function selectCodexImageModel(currentModel: ModelLike | undefined, registry: ModelRegistryLike | undefined): ModelLike | undefined {
|
|
92
|
+
if (isConfiguredImageModel(currentModel)) return currentModel;
|
|
93
|
+
const discovered = registryModels(registry).find(isConfiguredImageModel);
|
|
94
|
+
if (discovered) return discovered;
|
|
95
|
+
for (const profile of listResolvedModelProfiles()) {
|
|
96
|
+
if (!profile.effective.enabled || !profile.effective.tools.imageGeneration) continue;
|
|
97
|
+
const slash = profile.id.indexOf("/");
|
|
98
|
+
const candidate = registry?.find?.(
|
|
99
|
+
profile.id.slice(0, slash),
|
|
100
|
+
profile.id.slice(slash + 1),
|
|
101
|
+
);
|
|
102
|
+
if (isModelLike(candidate) && isConfiguredImageModel(candidate)) return candidate;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tokenizeArgs(input: string): string[] {
|
|
108
|
+
const tokens: string[] = [];
|
|
109
|
+
let current = "";
|
|
110
|
+
let quote: '"' | "'" | undefined;
|
|
111
|
+
let escaped = false;
|
|
112
|
+
for (const ch of input) {
|
|
113
|
+
if (escaped) {
|
|
114
|
+
current += ch;
|
|
115
|
+
escaped = false;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (ch === "\\") {
|
|
119
|
+
escaped = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (quote) {
|
|
123
|
+
if (ch === quote) quote = undefined;
|
|
124
|
+
else current += ch;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === '"' || ch === "'") {
|
|
128
|
+
quote = ch;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (/\s/.test(ch)) {
|
|
132
|
+
if (current) tokens.push(current);
|
|
133
|
+
current = "";
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
current += ch;
|
|
137
|
+
}
|
|
138
|
+
if (current) tokens.push(current);
|
|
139
|
+
return tokens;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isSupportedImagePathToken(token: string): boolean {
|
|
143
|
+
const normalized = token.replace(/^file:\/\//, "").replace(/[),.;:]+$/, "");
|
|
144
|
+
if (/^https?:\/\//i.test(normalized) || normalized.startsWith("data:")) return false;
|
|
145
|
+
return /\.(?:png|jpe?g|webp)$/i.test(normalized);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function parseImageGenCommandArgs(input: string): ParsedImageGenCommand {
|
|
149
|
+
const imagePaths: string[] = [];
|
|
150
|
+
const promptParts: string[] = [];
|
|
151
|
+
for (const token of tokenizeArgs(input.trim())) {
|
|
152
|
+
if (token.startsWith("@") && token.length > 1) imagePaths.push(token.slice(1));
|
|
153
|
+
else if (isSupportedImagePathToken(token)) imagePaths.push(token.replace(/^file:\/\//, "").replace(/[),.;:]+$/, ""));
|
|
154
|
+
else promptParts.push(token);
|
|
155
|
+
}
|
|
156
|
+
return { prompt: promptParts.join(" ").trim(), imagePaths };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function mimeTypeForPath(path: string): string {
|
|
160
|
+
const ext = extname(path).toLowerCase();
|
|
161
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
162
|
+
if (ext === ".webp") return "image/webp";
|
|
163
|
+
if (ext === ".png") return "image/png";
|
|
164
|
+
throw new Error(`Unsupported reference image type: ${path}. Use PNG, JPEG, or WebP.`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function loadReferenceImage(cwd: string, rawPath: string): Promise<ReferenceImage> {
|
|
168
|
+
const path = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
169
|
+
const buffer = await readFile(path);
|
|
170
|
+
return { path, mimeType: mimeTypeForPath(path), base64: buffer.toString("base64") };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveCodexUrl(baseUrl: string | undefined, options?: { apiKeyMode?: boolean }): string {
|
|
174
|
+
const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : DEFAULT_CODEX_BASE_URL;
|
|
175
|
+
const normalized = raw.replace(/\/+$/, "");
|
|
176
|
+
if (options?.apiKeyMode) {
|
|
177
|
+
if (normalized.endsWith("/responses")) return normalized;
|
|
178
|
+
return `${normalized}/responses`;
|
|
179
|
+
}
|
|
180
|
+
if (normalized.endsWith("/codex/responses")) return normalized;
|
|
181
|
+
if (normalized.endsWith("/codex")) return `${normalized}/responses`;
|
|
182
|
+
return `${normalized}/codex/responses`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildHeaders(
|
|
186
|
+
model: Model<Api>,
|
|
187
|
+
auth: { apiKey?: string; headers?: ProviderHeaders },
|
|
188
|
+
options?: { apiKeyMode?: boolean },
|
|
189
|
+
): Headers {
|
|
190
|
+
const headers = buildCodexJsonHeaders({
|
|
191
|
+
modelHeaders: model.headers,
|
|
192
|
+
auth,
|
|
193
|
+
apiKeyMode: options?.apiKeyMode ?? false,
|
|
194
|
+
});
|
|
195
|
+
setProviderGeneratedHeader(headers, "OpenAI-Beta", "responses=experimental");
|
|
196
|
+
setProviderGeneratedHeader(headers, "accept", "text/event-stream");
|
|
197
|
+
return headers;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function buildBackgroundImageRequest(options: {
|
|
201
|
+
prompt: string;
|
|
202
|
+
referenceImages: ReferenceImage[];
|
|
203
|
+
responsesModel: string;
|
|
204
|
+
imageModel: string;
|
|
205
|
+
}): Record<string, unknown> {
|
|
206
|
+
const content: Array<Record<string, unknown>> = [
|
|
207
|
+
{ type: "input_text", text: options.referenceImages.length > 0 ? `Edit the provided image(s): ${options.prompt}` : options.prompt },
|
|
208
|
+
...options.referenceImages.map((image) => ({
|
|
209
|
+
type: "input_image",
|
|
210
|
+
detail: "auto",
|
|
211
|
+
image_url: `data:${image.mimeType};base64,${image.base64}`,
|
|
212
|
+
})),
|
|
213
|
+
];
|
|
214
|
+
return {
|
|
215
|
+
model: options.responsesModel,
|
|
216
|
+
store: false,
|
|
217
|
+
stream: true,
|
|
218
|
+
instructions: BACKGROUND_IMAGE_INSTRUCTIONS,
|
|
219
|
+
input: [{ role: "user", content }],
|
|
220
|
+
tools: [{
|
|
221
|
+
type: "image_generation",
|
|
222
|
+
model: options.imageModel,
|
|
223
|
+
output_format: "png",
|
|
224
|
+
action: options.referenceImages.length > 0 ? "edit" : "generate",
|
|
225
|
+
}],
|
|
226
|
+
tool_choice: { type: "image_generation" },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function* parseSseEvents(response: Response, signal: AbortSignal): AsyncIterable<Record<string, unknown>> {
|
|
231
|
+
if (!response.body) return;
|
|
232
|
+
const reader = response.body.getReader();
|
|
233
|
+
const decoder = new TextDecoder();
|
|
234
|
+
let buffer = "";
|
|
235
|
+
const cancel = () => { void reader.cancel(signal.reason).catch(() => {}); };
|
|
236
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
237
|
+
if (signal.aborted) cancel();
|
|
238
|
+
try {
|
|
239
|
+
while (true) {
|
|
240
|
+
signal.throwIfAborted();
|
|
241
|
+
const { done, value } = await reader.read();
|
|
242
|
+
signal.throwIfAborted();
|
|
243
|
+
if (done) break;
|
|
244
|
+
buffer += decoder.decode(value, { stream: true });
|
|
245
|
+
let boundary: number;
|
|
246
|
+
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
|
|
247
|
+
const raw = buffer.slice(0, boundary);
|
|
248
|
+
buffer = buffer.slice(boundary + 2);
|
|
249
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
250
|
+
if (!line.startsWith("data:")) continue;
|
|
251
|
+
const data = line.slice(5).trim();
|
|
252
|
+
if (!data || data === "[DONE]") continue;
|
|
253
|
+
yield JSON.parse(data) as Record<string, unknown>;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
signal.removeEventListener("abort", cancel);
|
|
259
|
+
reader.releaseLock();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function collectImageResult(results: CodexImageResult[], item: unknown, fallbackImageModel: string): void {
|
|
264
|
+
if (!item || typeof item !== "object") return;
|
|
265
|
+
const candidate = item as Record<string, unknown>;
|
|
266
|
+
if (candidate.type !== "image_generation_call" || typeof candidate.result !== "string") return;
|
|
267
|
+
const id = typeof candidate.id === "string" ? candidate.id : `ig_${results.length}`;
|
|
268
|
+
if (results.some((result) => result.id === id)) return;
|
|
269
|
+
results.push({
|
|
270
|
+
id,
|
|
271
|
+
result: candidate.result,
|
|
272
|
+
outputFormat: typeof candidate.output_format === "string" ? candidate.output_format : "png",
|
|
273
|
+
revisedPrompt: typeof candidate.revised_prompt === "string" ? candidate.revised_prompt : undefined,
|
|
274
|
+
imageModel: typeof candidate.model === "string" ? candidate.model : fallbackImageModel,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function collectResponseText(value: unknown, out: string[]): void {
|
|
279
|
+
if (!value || typeof value !== "object") return;
|
|
280
|
+
const item = value as Record<string, unknown>;
|
|
281
|
+
for (const key of ["text", "refusal", "summary_text", "message"] as const) {
|
|
282
|
+
const text = item[key];
|
|
283
|
+
if (typeof text === "string" && text.trim()) out.push(text.trim());
|
|
284
|
+
}
|
|
285
|
+
for (const key of ["content", "output", "summary"] as const) {
|
|
286
|
+
const child = item[key];
|
|
287
|
+
if (Array.isArray(child)) for (const part of child) collectResponseText(part, out);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function summarizeNonImageResponse(response: Record<string, unknown> | undefined): string {
|
|
292
|
+
if (!response) return "No image was returned by Codex.";
|
|
293
|
+
const status = typeof response.status === "string" ? response.status : undefined;
|
|
294
|
+
const error = response.error && typeof response.error === "object" ? response.error as Record<string, unknown> : undefined;
|
|
295
|
+
const errorMessage = typeof error?.message === "string" ? error.message : undefined;
|
|
296
|
+
const texts: string[] = [];
|
|
297
|
+
collectResponseText(response, texts);
|
|
298
|
+
const text = [...new Set(texts)].join(" ").replace(/\s+/g, " ").trim();
|
|
299
|
+
const details = [status && status !== "completed" ? `status ${status}` : undefined, errorMessage, text].filter(Boolean).join(" · ");
|
|
300
|
+
return details ? `No image was returned by Codex: ${details}` : "No image was returned by Codex.";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function renderImageGenError(details: ImageGenerationErrorDetails, theme: Theme): Component {
|
|
304
|
+
return {
|
|
305
|
+
invalidate() {},
|
|
306
|
+
render(width: number): string[] {
|
|
307
|
+
const safeWidth = Math.max(1, width);
|
|
308
|
+
const message = details.message || "Image generation failed.";
|
|
309
|
+
const promptWidth = Math.max(16, safeWidth - 28);
|
|
310
|
+
const lines = [
|
|
311
|
+
`${theme.fg("error", "● ")}${theme.fg("text", theme.bold("Image Generation "))}${theme.fg("error", "failed")}`,
|
|
312
|
+
];
|
|
313
|
+
if (details.imageModel) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Model ")}${theme.fg("muted", details.imageModel)}`);
|
|
314
|
+
if (details.referenceCount && details.referenceCount > 0) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Refs ")}${theme.fg("muted", `${details.referenceCount}`)}`);
|
|
315
|
+
if (details.prompt) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Prompt ")}${theme.fg("muted", truncateToWidth(details.prompt, promptWidth, "…"))}`);
|
|
316
|
+
lines.push(`${panelBranch(theme, "└")}${theme.fg("error", truncateToWidth(message, Math.max(16, safeWidth - 5), "…"))}`);
|
|
317
|
+
return lines.map((line) => truncateToWidth(line, safeWidth, ""));
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function runBackgroundImageGeneration(pi: ExtensionAPI, ctx: ExtensionCommandContext, parsed: ParsedImageGenCommand, signal: AbortSignal): Promise<void> {
|
|
323
|
+
signal.throwIfAborted();
|
|
324
|
+
const model = selectCodexImageModel(ctx.model as ModelLike | undefined, ctx.modelRegistry as ModelRegistryLike | undefined) as Model<Api> | undefined;
|
|
325
|
+
if (!model) throw new Error("No image-capable model with an enabled model catalog profile is available.");
|
|
326
|
+
const settings = loadModelSettings(model as ModelLike, ctx.cwd);
|
|
327
|
+
if (!settings.enabled) throw new Error("pi-codex-minimal-tools is disabled.");
|
|
328
|
+
if (settings.imageGenerationImplementation === "standalone") {
|
|
329
|
+
const result = await standaloneImageGeneration({
|
|
330
|
+
prompt: parsed.prompt,
|
|
331
|
+
referenced_image_paths: parsed.imagePaths,
|
|
332
|
+
}, {
|
|
333
|
+
cwd: ctx.cwd,
|
|
334
|
+
model,
|
|
335
|
+
modelRegistry: ctx.modelRegistry,
|
|
336
|
+
}, settings, signal, { callId: "standalone" });
|
|
337
|
+
signal.throwIfAborted();
|
|
338
|
+
const saved = result.details.saved;
|
|
339
|
+
const workspaceRoot = projectRoot(ctx.cwd);
|
|
340
|
+
const relativePath = relative(workspaceRoot, saved.path);
|
|
341
|
+
const latestAbsolutePath = saved.latestPath ?? saved.path;
|
|
342
|
+
const latestRelativePath = relative(workspaceRoot, latestAbsolutePath);
|
|
343
|
+
const savedImage: SavedGeneratedImage = {
|
|
344
|
+
absolutePath: saved.path,
|
|
345
|
+
relativePath: relativePath && !relativePath.startsWith("..") ? relativePath : saved.path,
|
|
346
|
+
latestAbsolutePath,
|
|
347
|
+
latestRelativePath: latestRelativePath && !latestRelativePath.startsWith("..")
|
|
348
|
+
? latestRelativePath
|
|
349
|
+
: latestAbsolutePath,
|
|
350
|
+
responseId: undefined,
|
|
351
|
+
callId: "standalone",
|
|
352
|
+
outputFormat: saved.format,
|
|
353
|
+
imageModel: settings.imageModel,
|
|
354
|
+
revisedPrompt: parsed.prompt,
|
|
355
|
+
};
|
|
356
|
+
pi.sendMessage({
|
|
357
|
+
customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
358
|
+
content: [{ type: "text", text: buildGeneratedImageDisplayText(savedImage, { expanded: false }) }],
|
|
359
|
+
display: true,
|
|
360
|
+
details: { savedImages: [savedImage] },
|
|
361
|
+
}, { triggerTurn: false });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
365
|
+
signal.throwIfAborted();
|
|
366
|
+
if (!auth.ok) throw new Error(auth.error);
|
|
367
|
+
if (!hasCodexRequestAuth({
|
|
368
|
+
modelHeaders: model.headers,
|
|
369
|
+
auth: { apiKey: auth.apiKey, headers: auth.headers },
|
|
370
|
+
})) {
|
|
371
|
+
throw new Error(`No request authentication is configured for ${model.provider}.`);
|
|
372
|
+
}
|
|
373
|
+
const referenceImages = await Promise.all(parsed.imagePaths.map((path) => loadReferenceImage(ctx.cwd, path)));
|
|
374
|
+
signal.throwIfAborted();
|
|
375
|
+
const body = buildBackgroundImageRequest({
|
|
376
|
+
prompt: parsed.prompt,
|
|
377
|
+
referenceImages,
|
|
378
|
+
responsesModel: model.id,
|
|
379
|
+
imageModel: settings.imageModel,
|
|
380
|
+
});
|
|
381
|
+
const response = await fetch(resolveCodexUrl(model.baseUrl, { apiKeyMode: settings.apiKeyMode }), {
|
|
382
|
+
signal,
|
|
383
|
+
method: "POST",
|
|
384
|
+
headers: buildHeaders(model, {
|
|
385
|
+
apiKey: auth.apiKey,
|
|
386
|
+
headers: auth.headers,
|
|
387
|
+
}, { apiKeyMode: settings.apiKeyMode }),
|
|
388
|
+
body: JSON.stringify(body),
|
|
389
|
+
});
|
|
390
|
+
signal.throwIfAborted();
|
|
391
|
+
if (!response.ok) throw new Error(`Codex image generation failed: ${response.status} ${await response.text()}`);
|
|
392
|
+
const results: CodexImageResult[] = [];
|
|
393
|
+
let responseId: string | undefined;
|
|
394
|
+
let lastResponse: Record<string, unknown> | undefined;
|
|
395
|
+
for await (const event of parseSseEvents(response, signal)) {
|
|
396
|
+
if (event.type === "response.created" && event.response && typeof event.response === "object") {
|
|
397
|
+
lastResponse = event.response as Record<string, unknown>;
|
|
398
|
+
const id = (event.response as { id?: unknown }).id;
|
|
399
|
+
if (typeof id === "string") responseId = id;
|
|
400
|
+
}
|
|
401
|
+
if (event.type === "response.output_item.done") collectImageResult(results, event.item, settings.imageModel);
|
|
402
|
+
if ((event.type === "response.completed" || event.type === "response.done") && event.response && typeof event.response === "object") {
|
|
403
|
+
lastResponse = event.response as Record<string, unknown>;
|
|
404
|
+
const responseOutput = (event.response as { output?: unknown }).output;
|
|
405
|
+
if (Array.isArray(responseOutput)) for (const item of responseOutput) collectImageResult(results, item, settings.imageModel);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (results.length === 0) throw new Error(summarizeNonImageResponse(lastResponse));
|
|
409
|
+
const savedImages = [];
|
|
410
|
+
for (const result of results) {
|
|
411
|
+
signal.throwIfAborted();
|
|
412
|
+
savedImages.push(await saveOpenAICodexGeneratedImage(ctx.cwd, {
|
|
413
|
+
responseId,
|
|
414
|
+
callId: result.id,
|
|
415
|
+
result: result.result,
|
|
416
|
+
outputFormat: result.outputFormat,
|
|
417
|
+
imageModel: result.imageModel,
|
|
418
|
+
revisedPrompt: result.revisedPrompt ?? parsed.prompt,
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
signal.throwIfAborted();
|
|
422
|
+
pi.sendMessage({
|
|
423
|
+
customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
424
|
+
content: [{ type: "text", text: buildGeneratedImageDisplayText(savedImages[0], { expanded: false }) }],
|
|
425
|
+
display: true,
|
|
426
|
+
details: { savedImages },
|
|
427
|
+
}, { triggerTurn: false });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function registerBackgroundImageGenerationCommand(pi: ExtensionAPI): void {
|
|
431
|
+
const jobs = createBackgroundImageJobs();
|
|
432
|
+
pi.on("session_start", (_event, ctx) => jobs.reset(ctx));
|
|
433
|
+
pi.on("session_shutdown", (_event, ctx) => jobs.reset(ctx));
|
|
434
|
+
pi.registerMessageRenderer<ImageGenerationErrorDetails>(IMAGE_GEN_ERROR_MESSAGE_TYPE, (message, _options, theme) => {
|
|
435
|
+
const rawContent = typeof message.content === "string"
|
|
436
|
+
? message.content
|
|
437
|
+
: message.content
|
|
438
|
+
.filter((item) => item.type === "text")
|
|
439
|
+
.map((item) => item.text)
|
|
440
|
+
.join("\n");
|
|
441
|
+
const details: ImageGenerationErrorDetails = {
|
|
442
|
+
message: message.details?.message ?? rawContent.replace(/^Image generation failed:\s*/i, "") ?? "Image generation failed.",
|
|
443
|
+
prompt: message.details?.prompt,
|
|
444
|
+
imageModel: message.details?.imageModel,
|
|
445
|
+
referenceCount: message.details?.referenceCount,
|
|
446
|
+
};
|
|
447
|
+
return renderImageGenError(details, theme);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
pi.registerCommand("image-gen", {
|
|
451
|
+
description: "Generate or edit an image in the background with the current model catalog. Usage: /image-gen prompt text [@reference.png]",
|
|
452
|
+
handler: async (args, ctx) => {
|
|
453
|
+
const parsed = parseImageGenCommandArgs(args);
|
|
454
|
+
if (!parsed.prompt) {
|
|
455
|
+
ctx.ui.notify("Usage: /image-gen prompt text [@reference.png]", "warning");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const settings = loadSettings(ctx.cwd);
|
|
459
|
+
const job = jobs.start(ctx, parsed, settings.imageModel);
|
|
460
|
+
ctx.ui.notify(`Queued image generation with ${settings.imageModel}${parsed.imagePaths.length ? ` (${parsed.imagePaths.length} reference image${parsed.imagePaths.length === 1 ? "" : "s"})` : ""}.`, "info");
|
|
461
|
+
void runBackgroundImageGeneration(pi, ctx, parsed, job.signal)
|
|
462
|
+
.catch((error) => {
|
|
463
|
+
if (!job.isCurrent()) return;
|
|
464
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
465
|
+
pi.sendMessage({
|
|
466
|
+
customType: IMAGE_GEN_ERROR_MESSAGE_TYPE,
|
|
467
|
+
content: `Image generation failed: ${message}`,
|
|
468
|
+
display: true,
|
|
469
|
+
details: { message, prompt: parsed.prompt, imageModel: settings.imageModel, referenceCount: parsed.imagePaths.length } satisfies ImageGenerationErrorDetails,
|
|
470
|
+
}, { triggerTurn: false });
|
|
471
|
+
})
|
|
472
|
+
.finally(() => job.finish())
|
|
473
|
+
.catch(() => {}); // Closed UI/API failures must not escape a detached job.
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { frameGlyphs, glyphs, treeGlyph } from "@oai404iao/pi-codex-runtime/internal/glyphs";
|
|
4
|
+
|
|
5
|
+
const STATUS_KEY = "codex-image-gen";
|
|
6
|
+
const PADDING = 1;
|
|
7
|
+
|
|
8
|
+
interface Job {
|
|
9
|
+
id: string;
|
|
10
|
+
startedAt: number;
|
|
11
|
+
prompt: string;
|
|
12
|
+
referenceCount: number;
|
|
13
|
+
imageModel: string;
|
|
14
|
+
controller: AbortController;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function padAnsi(text: string, width: number): string {
|
|
18
|
+
const truncated = truncateToWidth(text, width, "");
|
|
19
|
+
return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function panelFrame(lines: string[], width: number, theme: Theme): string[] {
|
|
23
|
+
const safeWidth = Math.max(1, width);
|
|
24
|
+
if (safeWidth < 8) return lines.map(line => truncateToWidth(line, safeWidth, ""));
|
|
25
|
+
const inner = Math.max(1, width - 2);
|
|
26
|
+
const contentWidth = Math.max(1, width - 2 - PADDING * 2);
|
|
27
|
+
const border = (text: string) => theme.fg("borderAccent", text);
|
|
28
|
+
const frame = frameGlyphs();
|
|
29
|
+
return [
|
|
30
|
+
`${border(frame.tl)}${border(frame.h.repeat(inner))}${border(frame.tr)}`,
|
|
31
|
+
...lines.map(line => `${border(frame.v)}${" ".repeat(PADDING)}${padAnsi(line, contentWidth)}${" ".repeat(PADDING)}${border(frame.v)}`),
|
|
32
|
+
`${border(frame.bl)}${border(frame.h.repeat(inner))}${border(frame.br)}`,
|
|
33
|
+
].map(line => truncateToWidth(line, safeWidth, ""));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function panelBranch(theme: Theme, branch: "├" | "└" | "│"): string {
|
|
37
|
+
return theme.fg("muted", treeGlyph(branch));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderJobs(active: Map<string, Job>, theme: Theme, width: number): string[] {
|
|
41
|
+
const jobs = [...active.values()].sort((a, b) => a.startedAt - b.startedAt);
|
|
42
|
+
if (!jobs.length) return [];
|
|
43
|
+
const elapsed = Math.max(0, Math.round((Date.now() - jobs[0]!.startedAt) / 1000));
|
|
44
|
+
const imageModels = [...new Set(jobs.map(job => job.imageModel))];
|
|
45
|
+
const modelText = imageModels.length === 1 ? imageModels[0] : `${imageModels.length} models`;
|
|
46
|
+
const refs = jobs.reduce((total, job) => total + job.referenceCount, 0);
|
|
47
|
+
const refText = refs > 0 ? ` · ${refs} ref${refs === 1 ? "" : "s"}` : "";
|
|
48
|
+
const lines = [`${theme.fg("customMessageLabel", theme.bold("Image Generation"))} ${theme.fg("muted", `${jobs.length} running · ${modelText}${refText} · ${elapsed}s`)}`];
|
|
49
|
+
const dot = theme.fg("dim", glyphs().dot);
|
|
50
|
+
const shown = jobs.slice(0, 4);
|
|
51
|
+
for (const [index, job] of shown.entries()) {
|
|
52
|
+
const age = Math.max(0, Math.round((Date.now() - job.startedAt) / 1000));
|
|
53
|
+
const isLast = index === shown.length - 1 && jobs.length <= shown.length;
|
|
54
|
+
const references = job.referenceCount > 0 ? `${dot}${theme.fg("dim", `${job.referenceCount} ref${job.referenceCount === 1 ? "" : "s"}`)}` : "";
|
|
55
|
+
const promptWidth = Math.max(16, width - 36);
|
|
56
|
+
lines.push(`${panelBranch(theme, isLast ? "└" : "├")}${theme.fg("accent", glyphs().bullet.trim())} ${theme.fg("accent", truncateToWidth(job.prompt, promptWidth, glyphs().ellipsis))}${dot}${theme.fg("muted", job.imageModel)}${references}${dot}${theme.fg("dim", `${age}s`)}`);
|
|
57
|
+
}
|
|
58
|
+
if (jobs.length > shown.length) lines.push(`${panelBranch(theme, "└")}${theme.fg("muted", `${glyphs().ellipsis} ${jobs.length - shown.length} more`)}`);
|
|
59
|
+
return panelFrame(lines, width, theme);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createBackgroundImageJobs() {
|
|
63
|
+
const jobs = new Map<string, Job>();
|
|
64
|
+
let generation = 0;
|
|
65
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
66
|
+
let statusContext: ExtensionContext | undefined;
|
|
67
|
+
const stopTimer = () => {
|
|
68
|
+
if (timer !== undefined) clearInterval(timer);
|
|
69
|
+
timer = undefined;
|
|
70
|
+
};
|
|
71
|
+
const update = (ctx: ExtensionContext) => {
|
|
72
|
+
statusContext = ctx;
|
|
73
|
+
ctx.ui?.setStatus(STATUS_KEY, jobs.size ? `image-gen ${jobs.size}` : undefined);
|
|
74
|
+
ctx.ui?.setWidget(STATUS_KEY, jobs.size ? (_tui: unknown, theme: Theme): Component => ({
|
|
75
|
+
invalidate() {},
|
|
76
|
+
render: width => renderJobs(jobs, theme, width),
|
|
77
|
+
}) : undefined, { placement: "aboveEditor" });
|
|
78
|
+
if (!jobs.size) stopTimer();
|
|
79
|
+
else if (timer === undefined) {
|
|
80
|
+
timer = setInterval(() => { if (statusContext) update(statusContext); }, 1000);
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
start(ctx: ExtensionContext, parsed: { prompt: string; imagePaths: string[] }, imageModel: string) {
|
|
86
|
+
const epoch = generation;
|
|
87
|
+
const job: Job = {
|
|
88
|
+
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
89
|
+
startedAt: Date.now(), prompt: parsed.prompt, referenceCount: parsed.imagePaths.length,
|
|
90
|
+
imageModel, controller: new AbortController(),
|
|
91
|
+
};
|
|
92
|
+
jobs.set(job.id, job);
|
|
93
|
+
update(ctx);
|
|
94
|
+
const isCurrent = () => epoch === generation && !job.controller.signal.aborted;
|
|
95
|
+
return {
|
|
96
|
+
signal: job.controller.signal,
|
|
97
|
+
isCurrent,
|
|
98
|
+
finish() {
|
|
99
|
+
if (!isCurrent()) return;
|
|
100
|
+
jobs.delete(job.id);
|
|
101
|
+
update(ctx);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
reset(ctx?: ExtensionContext) {
|
|
106
|
+
generation++;
|
|
107
|
+
for (const job of jobs.values()) job.controller.abort(new Error("Image generation session ended"));
|
|
108
|
+
jobs.clear();
|
|
109
|
+
stopTimer();
|
|
110
|
+
const current = ctx ?? statusContext;
|
|
111
|
+
statusContext = undefined;
|
|
112
|
+
current?.ui?.setStatus(STATUS_KEY, undefined);
|
|
113
|
+
current?.ui?.setWidget(STATUS_KEY, undefined);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { addPackageTool, ensureCodexServices } from "@oai404iao/pi-codex-runtime";
|
|
3
|
+
import { currentCodexTurn } from "@oai404iao/pi-codex-runtime/internal/codex-wire-identity";
|
|
4
|
+
import { loadModelSettings } from "@oai404iao/pi-codex-runtime/internal/model-catalog/runtime";
|
|
5
|
+
import { loadSettings } from "@oai404iao/pi-codex-runtime/internal/settings";
|
|
6
|
+
import { registerBackgroundImageGenerationCommand } from "./background-image-generation.js";
|
|
7
|
+
import { createImageGenerationToolDefinition } from "./tools/image-generation.js";
|
|
8
|
+
import { createImageCapture } from "./tools/image-generation/capture.js";
|
|
9
|
+
import { createImageDisplay } from "./tools/image-generation/display.js";
|
|
10
|
+
|
|
11
|
+
export default function codexImagegen(pi: ExtensionAPI): void {
|
|
12
|
+
const broker = ensureCodexServices(pi);
|
|
13
|
+
if (!broker.claim("package:imagegen")) return;
|
|
14
|
+
const display = createImageDisplay(pi);
|
|
15
|
+
broker.addPresentation("image_generation", {
|
|
16
|
+
clear: display.clear,
|
|
17
|
+
flush: display.flush,
|
|
18
|
+
scheduleFlush: display.scheduleFlush,
|
|
19
|
+
registerRenderers: display.registerRenderer,
|
|
20
|
+
streamEffects() {
|
|
21
|
+
const sink = display.captureSink();
|
|
22
|
+
return { createEventObserver: context => createImageCapture(context, sink) };
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
addPackageTool(broker, "image_generation", () => {
|
|
26
|
+
pi.registerTool(createImageGenerationToolDefinition({
|
|
27
|
+
loadSettings: (cwd, model) => loadModelSettings(model, cwd),
|
|
28
|
+
getCurrentTurnId: sessionId => currentCodexTurn(sessionId)?.turnId,
|
|
29
|
+
hasProviderRuntime: () => broker.coreEnabled,
|
|
30
|
+
}) as never);
|
|
31
|
+
});
|
|
32
|
+
if (loadSettings().enabled) registerBackgroundImageGenerationCommand(pi);
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ProviderEventContext } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/stream-effects";
|
|
2
|
+
import type { StreamEventShape } from "@oai404iao/pi-codex-runtime/internal/providers/openai-codex/types";
|
|
3
|
+
import { loadSettings } from "@oai404iao/pi-codex-runtime/internal/settings";
|
|
4
|
+
import { normalizeImageOutputFormat, saveOpenAICodexGeneratedImage } from "./storage.js";
|
|
5
|
+
import type { SavedGeneratedImage } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export function createImageCapture(
|
|
8
|
+
context: ProviderEventContext,
|
|
9
|
+
onImageSaved: (image: SavedGeneratedImage, data: { data: string; mimeType: string }) => void,
|
|
10
|
+
saveImage = saveOpenAICodexGeneratedImage,
|
|
11
|
+
) {
|
|
12
|
+
let responseId: string | undefined;
|
|
13
|
+
return async (event: StreamEventShape): Promise<void> => {
|
|
14
|
+
if (event.type === "response.created" && event.response?.id) responseId = event.response.id;
|
|
15
|
+
if (context.signal?.aborted) return;
|
|
16
|
+
if (event.type !== "response.output_item.done" || event.item?.type !== "image_generation_call") return;
|
|
17
|
+
const callId = typeof event.item.id === "string" ? event.item.id : undefined;
|
|
18
|
+
const result = typeof event.item.result === "string" ? event.item.result : undefined;
|
|
19
|
+
if (!callId || !result) return;
|
|
20
|
+
try {
|
|
21
|
+
const outputFormat = typeof event.item.output_format === "string" ? event.item.output_format : undefined;
|
|
22
|
+
const normalizedOutputFormat = normalizeImageOutputFormat(outputFormat);
|
|
23
|
+
const settings = loadSettings(context.cwd);
|
|
24
|
+
const imageModel = typeof event.item.model === "string" ? event.item.model : settings.imageModel;
|
|
25
|
+
const saved = await saveImage(context.cwd, {
|
|
26
|
+
responseId,
|
|
27
|
+
callId,
|
|
28
|
+
result,
|
|
29
|
+
outputFormat: normalizedOutputFormat,
|
|
30
|
+
imageModel,
|
|
31
|
+
revisedPrompt:
|
|
32
|
+
typeof event.item.revised_prompt === "string" ? event.item.revised_prompt : context.requestPrompt,
|
|
33
|
+
});
|
|
34
|
+
if (!context.signal?.aborted) {
|
|
35
|
+
onImageSaved(saved, { data: result, mimeType: `image/${normalizedOutputFormat}` });
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Persistence is best-effort. Raw diagnostics here would corrupt the
|
|
39
|
+
// active TUI; failures must not discard the provider response/replay.
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|