Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions apps/desktop/electron/main/builtin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import type { PluginSkillDef } from "@pi-desktop/agent-runtime";
/** Bundled skill teaching the plugin-development loop. */
export const PLUGIN_DEV_SKILL_FILE = "plugin-development.md";
export const PLUGIN_DEV_SKILL_ID = "pi-desktop/plugin-development";
export const IMAGE_GENERATION_SKILL_ID = "pi-desktop/imagegen";
const IMAGE_GENERATION_SKILL_FILE = "image-generation.md";

/** electron-builder copies `resources/skills` to `<resources>/skills`. */
function resolveBuiltinSkillPath(fileName: string): string | null {
const moduleDir = typeof __dirname === "string" ? __dirname : import.meta.dirname;
const candidates = [
join(process.resourcesPath || "", "skills", fileName),
join(__dirname, "../../resources/skills", fileName),
join(__dirname, "../../../resources/skills", fileName),
join(moduleDir, "../../resources/skills", fileName),
join(moduleDir, "../../../resources/skills", fileName),
];
for (const candidate of candidates) {
if (candidate && existsSync(candidate)) return candidate;
Expand Down Expand Up @@ -85,18 +88,15 @@ export type BuiltinSkillInput = {
* fresh so a packaged update takes effect without a restart.
*/
export function builtinSkills(input: BuiltinSkillInput): PluginSkillDef[] {
if (!isPluginWorkspace(input.workspacePath, input.pluginPaths)) return [];
const raw = readBuiltinSkill(PLUGIN_DEV_SKILL_FILE);
if (!raw?.trim()) return [];
const parsed = parseSkillFrontmatter(raw);
if (!parsed.body) return [];
return [
{
id: PLUGIN_DEV_SKILL_ID,
name: parsed.name ?? "PI-Desktop plugin development",
description: parsed.description,
},
];
const ids = [IMAGE_GENERATION_SKILL_ID];
if (isPluginWorkspace(input.workspacePath, input.pluginPaths)) ids.push(PLUGIN_DEV_SKILL_ID);
return ids.flatMap((id) => {
const file = id === IMAGE_GENERATION_SKILL_ID ? IMAGE_GENERATION_SKILL_FILE : PLUGIN_DEV_SKILL_FILE;
const raw = readBuiltinSkill(file);
if (!raw?.trim()) return [];
const parsed = parseSkillFrontmatter(raw);
return parsed.body ? [{ id, name: parsed.name ?? id, description: parsed.description }] : [];
});
}

/**
Expand All @@ -106,14 +106,14 @@ export function builtinSkills(input: BuiltinSkillInput): PluginSkillDef[] {
export function loadBuiltinSkillBody(
id: string,
): { id: string; name: string; body: string } | null {
if (id !== PLUGIN_DEV_SKILL_ID) return null;
const raw = readBuiltinSkill(PLUGIN_DEV_SKILL_FILE);
if (id !== PLUGIN_DEV_SKILL_ID && id !== IMAGE_GENERATION_SKILL_ID) return null;
const raw = readBuiltinSkill(id === IMAGE_GENERATION_SKILL_ID ? IMAGE_GENERATION_SKILL_FILE : PLUGIN_DEV_SKILL_FILE);
if (!raw?.trim()) return null;
const parsed = parseSkillFrontmatter(raw);
if (!parsed.body) return null;
return {
id: PLUGIN_DEV_SKILL_ID,
name: parsed.name ?? "PI-Desktop plugin development",
id,
name: parsed.name ?? id,
body: parsed.body,
};
}
4 changes: 1 addition & 3 deletions apps/desktop/electron/main/runtime/provider-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,7 @@ export function createProviderCatalogRuntime({
return {
...(value as T),
infiniteProviderRetry: (value as T & { infiniteProviderRetry?: unknown })
.infiniteProviderRetry === true
? true
: undefined,
.infiniteProviderRetry === true,
defaultCommandShell: isCommandShellId(value.defaultCommandShell)
? value.defaultCommandShell
: defaultCommandShellForPlatform(process.platform),
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/electron/main/runtime/session-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ErrorCodes as SharedErrorCodes,
isActiveInProject,
isCommandShellCatalog,
isImageGenerationModel,
normalizeMode,
resolveBindingContextWindow,
trustedExtensionAgentKeyFromProviderId,
Expand Down Expand Up @@ -326,6 +327,11 @@ export function createSessionLaunchRuntime({
errorCode: ErrorCodes.MODEL_NOT_CONFIGURED,
});
}
if (isImageGenerationModel(settings.imageGeneration, provider.id, modelId)) {
throw Object.assign(new Error("The image model cannot be used for conversation; select a chat model"), {
errorCode: ErrorCodes.MODEL_NOT_CONFIGURED,
});
}
// The authenticated collection owns a vendor account's available model IDs
// and wire endpoint. models.dev owns metadata; one account can span multiple
// wire APIs and gateway catalogs.
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron/main/runtime/sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
subagentProviderLookupError,
} from "@pi-desktop/agent-runtime";
import { loadBuiltinSkillBody } from "../builtin-skills";
import { createImageGenerationTool } from "../services/image-generation-service";
import { registerPluginDevTools } from "../plugin-dev-tools";
import { resolveLocalFile } from "../browser-view";
import { modelConfigFromModelsDev } from "../models-dev-catalog";
Expand Down Expand Up @@ -469,6 +470,7 @@ export function createSidecarRuntime({
});
// Agent-driven work panel preview (D100): open a workspace HTML file in
// the embedded browser; live reload keeps it current through later edits.
s.setLocalTool("GenerateImages", createImageGenerationTool({ dataDir, getHost: () => runtimeState.host }));
s.setLocalTool("BrowserPreview", async ({ args, sessionId }) => {
const raw = String((args as { path?: unknown })?.path ?? "").trim();
if (!raw) {
Expand Down
114 changes: 114 additions & 0 deletions apps/desktop/electron/main/services/image-generation-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { randomUUID } from "node:crypto";
import { mkdir, realpath, writeFile } from "node:fs/promises";
import { isAbsolute, join, relative, resolve } from "node:path";
import { generateImageBatch } from "@pi-desktop/agent-runtime";
import {
imageGenerationPrompts,
parseImageGenerationBinding,
type AppSettings,
type ProviderPublic,
} from "@pi-desktop/shared";
import type { HostProcess } from "../host-process";
import type { LocalToolHandler } from "../agent-sidecar";
import { imageInputLoader } from "./image-inputs";

function failure(errorCode: string, content: string) {
return {
ok: false,
isError: true,
errorCode,
content: { kind: "image-generation-error", errorCode, message: content },
};
}

export function createImageGenerationTool(options: {
dataDir: string;
getHost: () => Pick<HostProcess, "call"> | null;
fetchImpl?: typeof fetch;
}): LocalToolHandler {
return async ({ sessionId, args, signal }) => {
imageGenerationPrompts(args);
const host = options.getHost();
if (!host) return failure("HOST_UNAVAILABLE", "Host unavailable.");
const settings = await host.call<AppSettings>("settings.get");
const binding = parseImageGenerationBinding(settings.imageGeneration);
if (!binding)
return failure(
"IMAGE_NOT_CONFIGURED",
"Configure an image generation model in Settings > Models > Image generation model before generating images. Do not substitute another model.",
);
const { provider } = await host.call<{ provider?: ProviderPublic }>("providers.get", {
id: binding.providerId,
});
if (
!provider?.enabled ||
!provider.baseUrl ||
!provider.models.some((model) => model.id === binding.modelId)
) {
return failure(
"IMAGE_MODEL_UNAVAILABLE",
"The configured image model is unavailable. Update Settings > Models > Image generation model.",
);
}
if (provider.authKind === "oauth")
return failure(
"IMAGE_AUTH_UNSUPPORTED",
"Image generation requires an API-key or no-auth service.",
);
const { value } = await host.call<{ value?: string }>("providers.getSecret", {
id: provider.id,
});
if (provider.authKind !== "none" && !value)
return failure("IMAGE_AUTH_FAILED", "The image provider needs an API key.");
const { path } = await host.call<{ path: string }>("session.getScratchPath", { sessionId });
const root = resolve(options.dataDir, "scratch");
const within = (base: string, target: string) => {
const rel = relative(base, target);
return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
};
if (typeof path !== "string" || !within(root, resolve(path)))
return failure("INVALID_ARGUMENT", "Invalid image output directory.");
await mkdir(path, { recursive: true });
const realRoot = await realpath(root);
const realDir = await realpath(path);
if (!within(realRoot, realDir))
return failure("INVALID_ARGUMENT", "Invalid image output directory.");
signal.throwIfAborted();
const { session } = await host.call<{ session?: { projectPath?: string } }>("session.get", {
id: sessionId,
});
if (!session) return failure("SESSION_NOT_FOUND", "The image session no longer exists.");
const results = await generateImageBatch({
input: args,
endpoint: {
baseUrl: provider.baseUrl,
modelId: binding.modelId,
apiKey: value,
headers: provider.headers,
},
signal,
fetchImpl: options.fetchImpl,
loadImages: imageInputLoader({
dataDir: options.dataDir,
scratchPath: realDir,
projectPath: session?.projectPath,
}),
save: async (image) => {
const target = join(realDir, `generated-${randomUUID()}.${image.extension}`);
await writeFile(target, image.bytes, { flag: "wx" });
return target;
},
});
const ok = results.some((result) => result.status === "succeeded");
return {
ok,
isError: !ok,
content: {
kind: "generated-images",
providerId: binding.providerId,
modelId: binding.modelId,
results,
},
};
};
}
88 changes: 88 additions & 0 deletions apps/desktop/electron/main/services/image-inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { open, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
import { generatedImageType, MAX_IMAGE_BYTES } from "@pi-desktop/agent-runtime";

/** Session/project roots are captured by the host, never supplied by the model. */
export function imageInputLoader(options: {
projectPath?: string;
scratchPath: string;
dataDir: string;
}) {
let loadedBytes = 0;
const cache = new Map<
string,
Promise<{ bytes: Uint8Array; mimeType: string; extension: string }>
>();
const read = async (ref: string) => {
const candidate = /^attachments[\\/][a-f0-9]{64}$/.test(ref)
? resolve(options.dataDir, ref)
: isAbsolute(ref)
? ref
: options.projectPath
? resolve(options.projectPath, ref)
: resolve(options.scratchPath, ref);
const path = await realpath(candidate);
const roots = await Promise.all(
[options.projectPath, options.scratchPath, resolve(options.dataDir, "attachments")]
.filter((root): root is string => !!root)
.map((root) =>
realpath(root).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
}),
),
);
if (
!roots.some((root) => {
if (!root) return false;
const rel = relative(root, path);
return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
})
)
throw Object.assign(new Error("Image input is outside the session and project roots"), {
errorCode: "IMAGE_INPUT_OUTSIDE_ROOT",
});
const file = await open(path, "r");
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > MAX_IMAGE_BYTES)
throw Object.assign(new Error("Image input is too large"), {
errorCode: "IMAGE_INPUT_INVALID",
});
// A bounded read still holds if another process grows the file after stat.
const bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_IMAGE_BYTES + 1));
let size = 0;
while (size < bytes.length) {
const read = await file.read(bytes, size, bytes.length - size, null);
if (!read.bytesRead) break;
size += read.bytesRead;
}
const data = bytes.subarray(0, size);
if (loadedBytes + size > 64 * 1024 * 1024)
throw Object.assign(new Error("Batch image inputs exceed 64 MB"), {
errorCode: "IMAGE_INPUT_TOO_LARGE",
});
loadedBytes += size;
return { bytes: data, ...generatedImageType(data) };
} finally {
await file.close();
}
};
return async (refs: string[]) => {
const images = await Promise.all(
refs.map((ref) => {
let image = cache.get(ref);
if (!image) {
image = read(ref);
cache.set(ref, image);
}
return image;
}),
);
if (images.reduce((size, image) => size + image.bytes.length, 0) > 32 * 1024 * 1024)
throw Object.assign(new Error("Image inputs exceed 32 MB"), {
errorCode: "IMAGE_INPUT_TOO_LARGE",
});
return images;
};
}
59 changes: 59 additions & 0 deletions apps/desktop/resources/skills/image-generation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
name: imagegen
description: Generate or edit raster images, illustrations, photos, banners, and project assets with the configured image model. Supports reference images, edits of earlier results, variants, and batches. Prefer existing code-native tools for SVG/CSS edits.
---

# Image generation

Use the desktop `GenerateImages` tool. The user selects its provider and model
under Settings → Models → Image generation model; this is independent of the chat
model. If ToolSearch is available and GenerateImages is not loaded, discover it
there first. Do not install an SDK, run an API script, ask for a key in chat, or
substitute the conversation model.

## Prepare the request

- Preserve the requested subject, composition, style, exact text, and constraints.
Add useful detail to a vague prompt without inventing additional deliverables.
- For a project asset, include its intended use and required framing. Prefer the
existing SVG/CSS asset system for changes to code-native icons or diagrams.
- For an edit, pass the source file paths as `images` (one to four per item).
Specify what changes and what must stay unchanged. For a follow-up edit, use
the previous result path. Inputs must be under the current project, session
scratch directory, or attachment store. Do not use remote URLs.
- Editing uses OpenAI Images multipart requests and depends on the selected
model supporting that endpoint. Do not silently replace an edit with a fresh
generation if it fails. Mask painting is not a desktop UI feature.

## Generate one image or a batch

Call `GenerateImages` with `items`, where each item has `prompt` and optional
`count` (default 1), plus optional `images` for edits. Use one item with `count` for variants of the same prompt;
use separate items for different assets. A batch permits at most 10 images
total. For a larger explicitly requested set, split it into bounded batches.
Generate only the quantity the user asked for; do not add unrequested variants.

Example: two cover variants and one distinct icon:

```json
{"items":[{"prompt":"Editorial cover: a ceramic cup on a quiet desk, warm daylight, no text","count":2},{"prompt":"A small raster illustration of a green leaf on a white background","count":1}]}
```

Generation may incur cost. Do not retry failed or timed-out items automatically,
including after cancellation: the provider may already have processed them.
Report partial success and wait for a user request before retrying. If no image
model is configured, direct the user to Settings → Models; do not select one silently.

## Deliver the result

Results are ordered and contain a status and, for successes, a local image path.
Show the successful images with Markdown image links and report failed items.
The desktop also renders their previews directly from the tool result.

Use available image inspection tools to check the result when possible. Do not
claim to have visually inspected an image if you only received its file path.
For project deliverables, copy the selected image into the requested project
location using existing file/shell tools and update the consuming reference.
Use a new filename unless replacement was requested. Preview-only images may
remain in session storage. Report the final project paths, the prompt used,
and any requested images that did not complete.
2 changes: 2 additions & 0 deletions apps/desktop/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
} from "@pi-desktop/shared";
import {
initialThinkingLevelForBinding,
isImageGenerationModel,
modelIdsMatch,
normalizeLargePasteThreshold,
stripInlineComposerFileReferenceTokens,
Expand Down Expand Up @@ -391,6 +392,7 @@ export function Composer({
: !!provider &&
provider.enabled &&
!!modelId &&
!isImageGenerationModel(settings?.imageGeneration, provider.id, modelId) &&
(provider.hasSecret || provider.authKind === "none");
const enterToSend = settings?.enterToSend ?? true;
const hasDraftContent = Boolean(value.trim() || activeFileReferences.length);
Expand Down
Loading
Loading