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
Binary file added .github/pr-assets/composer-plus.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
125 changes: 120 additions & 5 deletions apps/desktop/electron/main/composer-paste.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import {
copyFile,
mkdir,
realpath,
stat,
writeFile,
} from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { basename, extname, isAbsolute, join } from "node:path";
import type {
ComposerPasteFile,
ComposerPastedFile,
Expand Down Expand Up @@ -37,6 +44,19 @@ const MIME_EXTENSIONS: Record<string, string> = {
"application/zip": ".zip",
};

const MIME_BY_EXTENSION: Record<string, string> = Object.fromEntries(
Object.entries(MIME_EXTENSIONS).map(([mimeType, extension]) => [extension, mimeType]),
);

Object.assign(MIME_BY_EXTENSION, {
".avif": "image/avif",
".bmp": "image/bmp",
".heic": "image/heic",
".jpeg": "image/jpeg",
".tif": "image/tiff",
".tiff": "image/tiff",
});

function isImageMimeType(mimeType: string): boolean {
return mimeType.startsWith("image/");
}
Expand All @@ -63,6 +83,18 @@ function fileNameOf(name: unknown, mimeType: string, index: number): string {
return extname(candidate) ? candidate : `${candidate}${extension}`;
}

function mimeTypeForPath(path: string): string {
return MIME_BY_EXTENSION[extname(path).toLowerCase()] ?? "application/octet-stream";
}

function scratchPasteRoot(dataDir: string, sessionId: string): string {
return join(dataDir, "scratch", sessionId, "pasted");
}

function pastedOutputPath(root: string, name: string): string {
return join(root, `pasted-${randomUUID()}-${name}`);
}

/**
* Materialize renderer clipboard bytes in the session scratch directory.
* Names are reduced to leaf names and every output receives a unique prefix,
Expand Down Expand Up @@ -105,12 +137,11 @@ export async function saveComposerPasteFiles(
};
});

const root = join(dataDir, "scratch", sessionId, "pasted");
const root = scratchPasteRoot(dataDir, sessionId);
await mkdir(root, { recursive: true });
return Promise.all(
prepared.map(async ({ bytes, mimeType, name }) => {
const outputName = `pasted-${randomUUID()}-${name}`;
const path = join(root, outputName);
const path = pastedOutputPath(root, name);
await writeFile(path, bytes, { flag: "wx" });
return {
path,
Expand All @@ -122,3 +153,87 @@ export async function saveComposerPasteFiles(
}),
);
}

/**
* Copy native-picker selections into the owning session scratch directory.
* The renderer only receives the resulting safe paths; source paths never
* become prompt references, so arbitrary files remain inside the attachment
* roots enforced by the main-process prompt boundary.
*/
export async function importComposerFiles(
dataDir: string,
sessionId: string,
paths: string[],
): Promise<ComposerPastedFile[]> {
if (!SAFE_SESSION_ID.test(sessionId)) {
throw new Error("invalid session id");
}
if (!Array.isArray(paths) || paths.length === 0) return [];
if (paths.length > MAX_FILES) {
throw new Error(`too many imported files (maximum ${MAX_FILES})`);
}

const prepared: Array<{
source: string;
name: string;
mimeType: string;
size: number;
}> = [];
let totalBytes = 0;
for (const [index, rawPath] of paths.entries()) {
if (typeof rawPath !== "string" || !rawPath.trim()) {
throw new Error("import file path is invalid");
}
const requested = rawPath.trim();
if (!isAbsolute(requested)) {
throw new Error("import file path must be absolute");
}
let source: string;
try {
source = await realpath(requested);
} catch {
throw new Error("import file was not found");
}
let size: number;
try {
const info = await stat(source);
if (!info.isFile()) throw new Error("selected path is not a file");
size = info.size;
} catch (error) {
if (error instanceof Error && error.message === "selected path is not a file") {
throw error;
}
throw new Error("import file could not be read");
}
if (size > MAX_FILE_BYTES) {
throw new Error(`imported file is too large (maximum ${MAX_FILE_BYTES} bytes)`);
}
totalBytes += size;
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error(`imported files are too large (maximum ${MAX_TOTAL_BYTES} bytes)`);
}
const mimeType = mimeTypeForPath(source);
prepared.push({
source,
mimeType,
size,
name: fileNameOf(basename(source), mimeType, index),
});
}

const root = scratchPasteRoot(dataDir, sessionId);
await mkdir(root, { recursive: true });
return Promise.all(
prepared.map(async ({ source, mimeType, size, name }) => {
const path = pastedOutputPath(root, name);
await copyFile(source, path, fsConstants.COPYFILE_EXCL);
return {
path,
name,
kind: isImageFile(name, mimeType) ? "image" : "file",
mimeType,
size,
};
}),
);
}
39 changes: 38 additions & 1 deletion apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ import {
resolveRealOpenablePath,
} from "./fs-panel";
import { getWorkspaceFileIndex } from "./fs-index";
import { saveComposerPasteFiles } from "./composer-paste";
import {
importComposerFiles,
saveComposerPasteFiles,
} from "./composer-paste";
import { builtinComposerCommands, builtinPaletteItems } from "./builtin-commands";
import {
convertSession,
Expand Down Expand Up @@ -6548,6 +6551,40 @@ function registerIpc() {
return { paths: result.filePaths, canceled: false };
});

handle(
IPC.invoke.composerImportFiles,
async (input: { sessionId?: unknown; paths?: unknown } = {}) => {
if (!host) throw new Error("host unavailable");
const sessionId =
typeof input.sessionId === "string" ? input.sessionId.trim() : "";
if (!sessionId) {
throw Object.assign(new Error("session required"), {
errorCode: ErrorCodes.INVALID_ARGUMENT,
});
}
const session = (await host.call("session.get", { id: sessionId })) as {
session?: unknown;
};
if (!session.session) {
throw Object.assign(new Error("session not found"), {
errorCode: ErrorCodes.NOT_FOUND,
});
}
if (!Array.isArray(input.paths)) {
throw Object.assign(new Error("paths must be an array"), {
errorCode: ErrorCodes.INVALID_ARGUMENT,
});
}
return {
files: await importComposerFiles(
dataDir,
sessionId,
input.paths as string[],
),
};
},
);

handle(
IPC.invoke.composerPasteFiles,
async (input: { sessionId?: unknown; files?: unknown } = {}) => {
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/electron/main/models-dev-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,18 @@ const KNOWN_PROVIDER_BASE_URLS: Record<string, string[]> = {
"moonshotai-cn": ["https://api.moonshot.cn/v1"],
"siliconflow-cn": ["https://api.siliconflow.cn/v1"],
volcengine: ["https://ark.cn-beijing.volces.com/api/v3"],
// MiniMax exposes both an Anthropic endpoint (the published models.dev
// URL) and an OpenAI-compatible `/v1` endpoint. Treat the latter as the
// same provider so a custom OpenAI-style row still inherits M3's vision
// metadata instead of falling back to a text-only generic model.
"minimax-cn": [
"https://api.minimaxi.com/v1",
"https://api.minimaxi.com/anthropic/v1",
],
minimax: [
"https://api.minimax.io/v1",
"https://api.minimax.io/anthropic/v1",
],
};

const PROVIDER_ALIASES: Record<string, string[]> = {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/electron/main/prompt-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ import { isAbsolute, join, relative, resolve } from "node:path";
import {
ErrorCodes,
formatFileInsert,
MAX_INLINE_IMAGE_BYTES,
type AgentPromptAttachment,
type MessageAttachment,
} from "@pi-desktop/shared";

export { MAX_INLINE_IMAGE_BYTES } from "@pi-desktop/shared";

const IMAGE_EXTENSIONS = new Set([
"avif",
"bmp",
Expand Down Expand Up @@ -59,8 +62,6 @@ const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
tiff: "image/tiff",
webp: "image/webp",
};
export const MAX_INLINE_IMAGE_BYTES = 20 * 1024 * 1024;

type PromptPath = {
absolute: string;
root: "project" | "scratch" | "attachment";
Expand Down
Loading