@oai404iao/pi-codex-imagegen 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -61
- package/THIRD_PARTY_NOTICES.md +6 -28
- package/package.json +6 -10
- package/src/index.ts +1 -37
- package/LICENSES/Apache-2.0.txt +0 -201
- package/LICENSES/OpenAI-Codex-NOTICE.txt +0 -6
- package/provenance/openai-codex-eb9dceba-reserved-tools.json +0 -140
- package/src/background-image-generation.ts +0 -218
- package/src/background-image-jobs.ts +0 -116
- package/src/tools/image-generation/capture.ts +0 -42
- package/src/tools/image-generation/display.ts +0 -73
- package/src/tools/image-generation/output.ts +0 -29
- package/src/tools/image-generation/preview.ts +0 -79
- package/src/tools/image-generation/storage.ts +0 -185
- package/src/tools/image-generation/types.ts +0 -36
- package/src/tools/image-generation.ts +0 -210
- package/src/utils/images.ts +0 -73
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import type { Api, Model } 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 { relative } from "node:path";
|
|
5
|
-
import { supportsImageInput, type ModelLike } from "@oai404iao/pi-codex-runtime/internal/capabilities";
|
|
6
|
-
import { createBackgroundImageJobs, panelBranch } from "./background-image-jobs.js";
|
|
7
|
-
import { listResolvedModelProfiles } from "@oai404iao/pi-codex-runtime/internal/model-catalog/catalog";
|
|
8
|
-
import { loadModelSettings } from "@oai404iao/pi-codex-runtime/internal/model-catalog/runtime";
|
|
9
|
-
import { loadSettings } from "@oai404iao/pi-codex-runtime/internal/settings";
|
|
10
|
-
import { standaloneImageGeneration } from "./tools/image-generation.js";
|
|
11
|
-
import { buildGeneratedImageDisplayText } from "./tools/image-generation/storage.js";
|
|
12
|
-
import { IMAGE_SAVE_DISPLAY_MESSAGE_TYPE, type SavedGeneratedImage } from "./tools/image-generation/types.js";
|
|
13
|
-
import { projectRoot } from "./utils/images.js";
|
|
14
|
-
|
|
15
|
-
const IMAGE_GEN_ERROR_MESSAGE_TYPE = "codex-image-generation-error";
|
|
16
|
-
|
|
17
|
-
export interface ParsedImageGenCommand {
|
|
18
|
-
prompt: string;
|
|
19
|
-
imagePaths: string[];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface ImageGenerationErrorDetails {
|
|
23
|
-
message: string;
|
|
24
|
-
prompt?: string;
|
|
25
|
-
imageModel?: string;
|
|
26
|
-
referenceCount?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface ModelRegistryLike {
|
|
30
|
-
getAll?: () => unknown;
|
|
31
|
-
getAvailable?: () => unknown;
|
|
32
|
-
find?: (provider: string, id: string) => unknown;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function isModelLike(value: unknown): value is ModelLike {
|
|
36
|
-
if (!value || typeof value !== "object") return false;
|
|
37
|
-
const record = value as Record<string, unknown>;
|
|
38
|
-
return typeof record.provider === "string" && (typeof record.id === "string" || typeof record.name === "string");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function registryModels(registry: ModelRegistryLike | undefined): ModelLike[] {
|
|
42
|
-
if (!registry) return [];
|
|
43
|
-
for (const method of [registry.getAll, registry.getAvailable]) {
|
|
44
|
-
if (typeof method !== "function") continue;
|
|
45
|
-
try {
|
|
46
|
-
const value = method.call(registry);
|
|
47
|
-
if (Array.isArray(value)) return value.filter(isModelLike);
|
|
48
|
-
} catch {
|
|
49
|
-
// Try the next registry shape.
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return [];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function isConfiguredImageModel(model: ModelLike | undefined): boolean {
|
|
56
|
-
if (!model || !supportsImageInput(model)) return false;
|
|
57
|
-
const settings = loadModelSettings({ ...model, baseUrl: undefined });
|
|
58
|
-
return Boolean(settings.modelProfile?.effective.enabled && settings.imageGenerationImplementation === "standalone");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function selectCodexImageModel(currentModel: ModelLike | undefined, registry: ModelRegistryLike | undefined): ModelLike | undefined {
|
|
62
|
-
const profile = currentModel && loadModelSettings({ ...currentModel, baseUrl: undefined }).modelProfile;
|
|
63
|
-
if (profile?.removedHostedImageGeneration) {
|
|
64
|
-
throw new Error(`Hosted image generation was removed for ${profile.id}. Change tools.imageGeneration to standalone or false in models.json; no alternate model was selected.`);
|
|
65
|
-
}
|
|
66
|
-
if (isConfiguredImageModel(currentModel)) return currentModel;
|
|
67
|
-
const discovered = registryModels(registry).find(isConfiguredImageModel);
|
|
68
|
-
if (discovered) return discovered;
|
|
69
|
-
for (const profile of listResolvedModelProfiles()) {
|
|
70
|
-
if (!profile.effective.enabled || profile.effective.tools.imageGeneration !== "standalone") continue;
|
|
71
|
-
const slash = profile.id.indexOf("/");
|
|
72
|
-
const candidate = registry?.find?.(profile.id.slice(0, slash), profile.id.slice(slash + 1));
|
|
73
|
-
if (isModelLike(candidate) && isConfiguredImageModel(candidate)) return candidate;
|
|
74
|
-
}
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function tokenizeArgs(input: string): string[] {
|
|
79
|
-
const tokens: string[] = [];
|
|
80
|
-
let current = "";
|
|
81
|
-
let quote: '"' | "'" | undefined;
|
|
82
|
-
let escaped = false;
|
|
83
|
-
for (const ch of input) {
|
|
84
|
-
if (escaped) {
|
|
85
|
-
current += ch;
|
|
86
|
-
escaped = false;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (ch === "\\") {
|
|
90
|
-
escaped = true;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (quote) {
|
|
94
|
-
if (ch === quote) quote = undefined;
|
|
95
|
-
else current += ch;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (ch === '"' || ch === "'") {
|
|
99
|
-
quote = ch;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (/\s/.test(ch)) {
|
|
103
|
-
if (current) tokens.push(current);
|
|
104
|
-
current = "";
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
current += ch;
|
|
108
|
-
}
|
|
109
|
-
if (current) tokens.push(current);
|
|
110
|
-
return tokens;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function isSupportedImagePathToken(token: string): boolean {
|
|
114
|
-
const normalized = token.replace(/^file:\/\//, "").replace(/[),.;:]+$/, "");
|
|
115
|
-
if (/^https?:\/\//i.test(normalized) || normalized.startsWith("data:")) return false;
|
|
116
|
-
return /\.(?:png|jpe?g|webp|gif|bmp)$/i.test(normalized);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function parseImageGenCommandArgs(input: string): ParsedImageGenCommand {
|
|
120
|
-
const imagePaths: string[] = [];
|
|
121
|
-
const promptParts: string[] = [];
|
|
122
|
-
for (const token of tokenizeArgs(input.trim())) {
|
|
123
|
-
if (token.startsWith("@") && token.length > 1) imagePaths.push(token.slice(1));
|
|
124
|
-
else if (isSupportedImagePathToken(token)) imagePaths.push(token.replace(/^file:\/\//, "").replace(/[),.;:]+$/, ""));
|
|
125
|
-
else promptParts.push(token);
|
|
126
|
-
}
|
|
127
|
-
return { prompt: promptParts.join(" ").trim(), imagePaths };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function renderImageGenError(details: ImageGenerationErrorDetails, theme: Theme): Component {
|
|
131
|
-
return {
|
|
132
|
-
invalidate() {},
|
|
133
|
-
render(width: number): string[] {
|
|
134
|
-
const safeWidth = Math.max(1, width);
|
|
135
|
-
const message = details.message || "Image generation failed.";
|
|
136
|
-
const promptWidth = Math.max(16, safeWidth - 28);
|
|
137
|
-
const lines = [
|
|
138
|
-
`${theme.fg("error", "● ")}${theme.fg("text", theme.bold("Image Generation "))}${theme.fg("error", "failed")}`,
|
|
139
|
-
];
|
|
140
|
-
if (details.imageModel) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Model ")}${theme.fg("muted", details.imageModel)}`);
|
|
141
|
-
if (details.referenceCount && details.referenceCount > 0) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Refs ")}${theme.fg("muted", `${details.referenceCount}`)}`);
|
|
142
|
-
if (details.prompt) lines.push(`${panelBranch(theme, "├")}${theme.fg("text", "Prompt ")}${theme.fg("muted", truncateToWidth(details.prompt, promptWidth, "…"))}`);
|
|
143
|
-
lines.push(`${panelBranch(theme, "└")}${theme.fg("error", truncateToWidth(message, Math.max(16, safeWidth - 5), "…"))}`);
|
|
144
|
-
return lines.map((line) => truncateToWidth(line, safeWidth, ""));
|
|
145
|
-
},
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function runBackgroundImageGeneration(pi: ExtensionAPI, ctx: ExtensionCommandContext, parsed: ParsedImageGenCommand, signal: AbortSignal): Promise<void> {
|
|
150
|
-
signal.throwIfAborted();
|
|
151
|
-
const model = selectCodexImageModel(ctx.model as ModelLike | undefined, ctx.modelRegistry as ModelRegistryLike | undefined) as Model<Api> | undefined;
|
|
152
|
-
if (!model) throw new Error("No image-capable model with an enabled standalone model catalog profile is available.");
|
|
153
|
-
const settings = loadModelSettings({ ...model, baseUrl: undefined }, ctx.cwd);
|
|
154
|
-
const result = await standaloneImageGeneration({
|
|
155
|
-
prompt: parsed.prompt, referenced_image_paths: parsed.imagePaths,
|
|
156
|
-
}, {
|
|
157
|
-
cwd: ctx.cwd, model, modelRegistry: ctx.modelRegistry, sessionManager: ctx.sessionManager,
|
|
158
|
-
}, settings, signal, { callId: "standalone" });
|
|
159
|
-
signal.throwIfAborted();
|
|
160
|
-
const saved = result.details.saved;
|
|
161
|
-
const workspaceRoot = projectRoot(ctx.cwd);
|
|
162
|
-
const relativePath = relative(workspaceRoot, saved.path);
|
|
163
|
-
const latestAbsolutePath = saved.latestPath ?? saved.path;
|
|
164
|
-
const latestRelativePath = relative(workspaceRoot, latestAbsolutePath);
|
|
165
|
-
const savedImage: SavedGeneratedImage = {
|
|
166
|
-
absolutePath: saved.path,
|
|
167
|
-
relativePath: relativePath && !relativePath.startsWith("..") ? relativePath : saved.path,
|
|
168
|
-
latestAbsolutePath,
|
|
169
|
-
latestRelativePath: latestRelativePath && !latestRelativePath.startsWith("..") ? latestRelativePath : latestAbsolutePath,
|
|
170
|
-
responseId: result.details.requestId,
|
|
171
|
-
callId: "standalone", outputFormat: saved.format, imageModel: settings.imageModel,
|
|
172
|
-
revisedPrompt: parsed.prompt,
|
|
173
|
-
};
|
|
174
|
-
pi.sendMessage({
|
|
175
|
-
customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
176
|
-
content: [{ type: "text", text: buildGeneratedImageDisplayText(savedImage, { expanded: false }) }],
|
|
177
|
-
display: true, details: { savedImages: [savedImage] },
|
|
178
|
-
}, { triggerTurn: false });
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function registerBackgroundImageGenerationCommand(pi: ExtensionAPI): void {
|
|
182
|
-
const jobs = createBackgroundImageJobs();
|
|
183
|
-
pi.on("session_start", (_event, ctx) => jobs.reset(ctx));
|
|
184
|
-
pi.on("session_shutdown", (_event, ctx) => jobs.reset(ctx));
|
|
185
|
-
pi.registerMessageRenderer<ImageGenerationErrorDetails>(IMAGE_GEN_ERROR_MESSAGE_TYPE, (message, _options, theme) => {
|
|
186
|
-
const rawContent = typeof message.content === "string" ? message.content
|
|
187
|
-
: message.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
188
|
-
return renderImageGenError({
|
|
189
|
-
message: message.details?.message ?? rawContent.replace(/^Image generation failed:\s*/i, "") ?? "Image generation failed.",
|
|
190
|
-
prompt: message.details?.prompt, imageModel: message.details?.imageModel, referenceCount: message.details?.referenceCount,
|
|
191
|
-
}, theme);
|
|
192
|
-
});
|
|
193
|
-
pi.registerCommand("image-gen", {
|
|
194
|
-
description: "Generate or edit an image in the background with the standalone Images API. Usage: /image-gen prompt text [@reference.png]",
|
|
195
|
-
handler: async (args, ctx) => {
|
|
196
|
-
const parsed = parseImageGenCommandArgs(args);
|
|
197
|
-
if (!parsed.prompt) {
|
|
198
|
-
ctx.ui.notify("Usage: /image-gen prompt text [@reference.png]", "warning");
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const settings = loadSettings(ctx.cwd);
|
|
202
|
-
const job = jobs.start(ctx, parsed, settings.imageModel);
|
|
203
|
-
ctx.ui.notify(`Queued image generation with ${settings.imageModel}${parsed.imagePaths.length ? ` (${parsed.imagePaths.length} reference image${parsed.imagePaths.length === 1 ? "" : "s"})` : ""}.`, "info");
|
|
204
|
-
void runBackgroundImageGeneration(pi, ctx, parsed, job.signal)
|
|
205
|
-
.catch((error) => {
|
|
206
|
-
if (!job.isCurrent()) return;
|
|
207
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
-
pi.sendMessage({
|
|
209
|
-
customType: IMAGE_GEN_ERROR_MESSAGE_TYPE,
|
|
210
|
-
content: `Image generation failed: ${message}`, display: true,
|
|
211
|
-
details: { message, prompt: parsed.prompt, imageModel: settings.imageModel, referenceCount: parsed.imagePaths.length } satisfies ImageGenerationErrorDetails,
|
|
212
|
-
}, { triggerTurn: false });
|
|
213
|
-
})
|
|
214
|
-
.finally(() => job.finish())
|
|
215
|
-
.catch(() => {}); // Closed UI/API failures must not escape a detached job.
|
|
216
|
-
},
|
|
217
|
-
});
|
|
218
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { makeCachedImagePreview, renderImageGenerationMessage } from "./preview.js";
|
|
3
|
-
import { buildGeneratedImageDisplayText } from "./storage.js";
|
|
4
|
-
import {
|
|
5
|
-
IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
6
|
-
type CachedImagePreview,
|
|
7
|
-
type ImageDisplayMessageDetails,
|
|
8
|
-
type PendingActivity,
|
|
9
|
-
type SavedGeneratedImage,
|
|
10
|
-
} from "./types.js";
|
|
11
|
-
|
|
12
|
-
export function createImageDisplay(pi: ExtensionAPI) {
|
|
13
|
-
const pendingActivities: PendingActivity[] = [];
|
|
14
|
-
const imagePreviewCache = new Map<string, CachedImagePreview>();
|
|
15
|
-
let pendingFlushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
16
|
-
let generation = 0;
|
|
17
|
-
|
|
18
|
-
const cancelFlush = () => {
|
|
19
|
-
if (pendingFlushTimer !== undefined) clearTimeout(pendingFlushTimer);
|
|
20
|
-
pendingFlushTimer = undefined;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const flush = () => {
|
|
24
|
-
cancelFlush();
|
|
25
|
-
const activities = pendingActivities.splice(0, pendingActivities.length);
|
|
26
|
-
for (const activity of activities) {
|
|
27
|
-
imagePreviewCache.set(activity.savedImage.absolutePath, makeCachedImagePreview(activity.imageData.data, activity.imageData.mimeType));
|
|
28
|
-
pi.sendMessage(
|
|
29
|
-
{
|
|
30
|
-
customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
31
|
-
content: [{ type: "text", text: buildGeneratedImageDisplayText(activity.savedImage, { expanded: false }) }],
|
|
32
|
-
display: true,
|
|
33
|
-
details: { savedImages: [activity.savedImage] } satisfies ImageDisplayMessageDetails,
|
|
34
|
-
},
|
|
35
|
-
{ triggerTurn: false },
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
flush,
|
|
42
|
-
scheduleFlush() {
|
|
43
|
-
if (pendingFlushTimer !== undefined || pendingActivities.length === 0) return;
|
|
44
|
-
pendingFlushTimer = setTimeout(flush, 0);
|
|
45
|
-
},
|
|
46
|
-
clear() {
|
|
47
|
-
generation++;
|
|
48
|
-
cancelFlush();
|
|
49
|
-
pendingActivities.length = 0;
|
|
50
|
-
imagePreviewCache.clear();
|
|
51
|
-
},
|
|
52
|
-
/** Ignore completion callbacks belonging to a replaced/closed session. */
|
|
53
|
-
captureSink() {
|
|
54
|
-
const requestGeneration = generation;
|
|
55
|
-
return (savedImage: SavedGeneratedImage, imageData: { data: string; mimeType: string }) => {
|
|
56
|
-
if (requestGeneration !== generation) return;
|
|
57
|
-
pendingActivities.push({ kind: "image", savedImage, imageData });
|
|
58
|
-
};
|
|
59
|
-
},
|
|
60
|
-
registerRenderer() {
|
|
61
|
-
pi.registerMessageRenderer<ImageDisplayMessageDetails>(IMAGE_SAVE_DISPLAY_MESSAGE_TYPE, (message, options, theme) => {
|
|
62
|
-
const savedImage = message.details?.savedImages?.[0];
|
|
63
|
-
const textContent = typeof message.content === "string"
|
|
64
|
-
? message.content
|
|
65
|
-
: message.content
|
|
66
|
-
.filter((item) => item.type === "text")
|
|
67
|
-
.map((item) => item.text)
|
|
68
|
-
.join("\n");
|
|
69
|
-
return renderImageGenerationMessage(savedImage, textContent, options, theme, imagePreviewCache);
|
|
70
|
-
});
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import type { SavedImageInfo } from "../../utils/images.js";
|
|
2
|
-
|
|
3
|
-
export const imageGenerationOutputSchema = {
|
|
4
|
-
type: "object",
|
|
5
|
-
additionalProperties: false,
|
|
6
|
-
required: ["path", "image"],
|
|
7
|
-
properties: {
|
|
8
|
-
path: { type: "string" },
|
|
9
|
-
latestPath: { type: "string" },
|
|
10
|
-
image: {
|
|
11
|
-
type: "object",
|
|
12
|
-
additionalProperties: false,
|
|
13
|
-
required: ["type", "data", "mimeType"],
|
|
14
|
-
properties: {
|
|
15
|
-
type: { type: "string", const: "image" },
|
|
16
|
-
data: { type: "string", description: "Base64 image data. Use image(result.image) to show it to the model." },
|
|
17
|
-
mimeType: { type: "string" },
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export function imageGenerationOutput(saved: SavedImageInfo, base64: string) {
|
|
24
|
-
return {
|
|
25
|
-
path: saved.path,
|
|
26
|
-
...(saved.latestPath ? { latestPath: saved.latestPath } : {}),
|
|
27
|
-
image: { type: "image" as const, data: base64, mimeType: saved.mimeType },
|
|
28
|
-
};
|
|
29
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { Container, Image, Spacer, Text, getCapabilities, getImageDimensions } from "@earendil-works/pi-tui";
|
|
2
|
-
import { glyphs, treeGlyph } from "@oai404iao/pi-codex-runtime/internal/glyphs";
|
|
3
|
-
import { themeBold, themeFg } from "@oai404iao/pi-codex-runtime/internal/utils/theme";
|
|
4
|
-
import { type CachedImagePreview, type SavedGeneratedImage } from "./types.js";
|
|
5
|
-
|
|
6
|
-
function getNodeFsSync(): { readFileSync(path: string): Buffer } | null {
|
|
7
|
-
if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
|
|
8
|
-
return null;
|
|
9
|
-
}
|
|
10
|
-
const builtinProcess = process as typeof process & { getBuiltinModule?: (specifier: string) => unknown };
|
|
11
|
-
if (typeof builtinProcess.getBuiltinModule !== "function") {
|
|
12
|
-
return null;
|
|
13
|
-
}
|
|
14
|
-
try {
|
|
15
|
-
const module = builtinProcess.getBuiltinModule("node:fs") as { readFileSync?: (path: string) => Buffer } | undefined;
|
|
16
|
-
return typeof module?.readFileSync === "function" ? { readFileSync: module.readFileSync } : null;
|
|
17
|
-
} catch {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function makeCachedImagePreview(data: string, mimeType: string, bytes?: number): CachedImagePreview {
|
|
23
|
-
const dimensions = getImageDimensions(data, mimeType) ?? undefined;
|
|
24
|
-
return { data, mimeType, bytes: bytes ?? Buffer.from(data, "base64").byteLength, widthPx: dimensions?.widthPx, heightPx: dimensions?.heightPx };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function loadCachedImagePreview(savedImage: SavedGeneratedImage, imagePreviewCache: Map<string, CachedImagePreview>): CachedImagePreview | undefined {
|
|
28
|
-
const cached = imagePreviewCache.get(savedImage.absolutePath);
|
|
29
|
-
if (cached) return cached;
|
|
30
|
-
const fs = getNodeFsSync();
|
|
31
|
-
if (!fs) return undefined;
|
|
32
|
-
try {
|
|
33
|
-
const buffer = fs.readFileSync(savedImage.absolutePath);
|
|
34
|
-
const data = buffer.toString("base64");
|
|
35
|
-
const mimeType = `image/${savedImage.outputFormat}`;
|
|
36
|
-
const preview = makeCachedImagePreview(data, mimeType, buffer.byteLength);
|
|
37
|
-
imagePreviewCache.set(savedImage.absolutePath, preview);
|
|
38
|
-
return preview;
|
|
39
|
-
} catch {
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function formatImageBytes(bytes: number | undefined): string | undefined {
|
|
45
|
-
if (!Number.isFinite(bytes) || !bytes) return undefined;
|
|
46
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
47
|
-
if (bytes < 1024 * 1024) return `${Math.round(bytes / 102.4) / 10}K`;
|
|
48
|
-
return `${Math.round(bytes / (1024 * 102.4)) / 10}M`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function shouldRenderInlineImage(): { ok: boolean; reason?: string } {
|
|
52
|
-
if (process.env.TMUX) return { ok: false, reason: "inline preview disabled in tmux to avoid overlay/stale image artifacts" };
|
|
53
|
-
const protocol = getCapabilities().images;
|
|
54
|
-
if (!protocol) return { ok: false, reason: "terminal image protocol unavailable" };
|
|
55
|
-
return { ok: true };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function renderImageGenerationMessage(savedImage: SavedGeneratedImage | undefined, messageContent: unknown, options: any, theme: any, imagePreviewCache: Map<string, CachedImagePreview>): Container {
|
|
59
|
-
const container = new Container();
|
|
60
|
-
const preview = savedImage ? loadCachedImagePreview(savedImage, imagePreviewCache) : undefined;
|
|
61
|
-
const type = savedImage?.outputFormat?.toUpperCase() ?? preview?.mimeType?.replace(/^image\//, "").toUpperCase() ?? "IMAGE";
|
|
62
|
-
const dimensions = preview?.widthPx && preview?.heightPx ? `${preview.widthPx}x${preview.heightPx}` : undefined;
|
|
63
|
-
const size = formatImageBytes(preview?.bytes);
|
|
64
|
-
const imageModel = savedImage?.imageModel ? `model ${savedImage.imageModel}` : undefined;
|
|
65
|
-
const meta = [imageModel, type, dimensions, size].filter(Boolean).join(glyphs().dot);
|
|
66
|
-
const label = `${themeFg(theme, "accent", glyphs().bullet)}${themeFg(theme, "text", themeBold(theme, "Image Generation "))}`;
|
|
67
|
-
const pathText = savedImage?.relativePath ?? (typeof messageContent === "string" ? messageContent : "generated image");
|
|
68
|
-
const lines = [`${label}${themeFg(theme, "accent", pathText)}${meta ? themeFg(theme, "dim", `${glyphs().dot}${meta}`) : ""}`];
|
|
69
|
-
if (savedImage?.latestRelativePath) lines.push(`${themeFg(theme, "muted", ` ${treeGlyph("├")}`)}${themeFg(theme, "text", "Latest ")}${themeFg(theme, "accent", savedImage.latestRelativePath)}`);
|
|
70
|
-
if (options?.expanded && savedImage?.revisedPrompt) lines.push(`${themeFg(theme, "muted", ` ${treeGlyph("├")}`)}${themeFg(theme, "text", "Prompt ")}${themeFg(theme, "dim", savedImage.revisedPrompt)}`);
|
|
71
|
-
const inline = shouldRenderInlineImage();
|
|
72
|
-
if (!inline.ok) lines.push(`${themeFg(theme, "muted", ` ${treeGlyph("└")}`)}${themeFg(theme, "warning", inline.reason ?? "inline preview unavailable")}`);
|
|
73
|
-
container.addChild(new Text(lines.join("\n"), 0, 0));
|
|
74
|
-
if (savedImage && preview && inline.ok) {
|
|
75
|
-
container.addChild(new Spacer(1));
|
|
76
|
-
container.addChild(new Image(preview.data, preview.mimeType, { fallbackColor: (text) => themeFg(theme, "dim", text) }, { maxWidthCells: 72, maxHeightCells: options?.expanded ? 24 : 14, filename: savedImage.relativePath }));
|
|
77
|
-
}
|
|
78
|
-
return container;
|
|
79
|
-
}
|