diff --git a/.github/pr-assets/composer-plus.webp b/.github/pr-assets/composer-plus.webp new file mode 100644 index 000000000..32459d697 Binary files /dev/null and b/.github/pr-assets/composer-plus.webp differ diff --git a/apps/desktop/electron/main/composer-paste.ts b/apps/desktop/electron/main/composer-paste.ts index 0946214c7..4acd1b539 100644 --- a/apps/desktop/electron/main/composer-paste.ts +++ b/apps/desktop/electron/main/composer-paste.ts @@ -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, @@ -37,6 +44,19 @@ const MIME_EXTENSIONS: Record = { "application/zip": ".zip", }; +const MIME_BY_EXTENSION: Record = 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/"); } @@ -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, @@ -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, @@ -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 { + 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, + }; + }), + ); +} diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index e45e4f770..e46de0cb2 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -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, @@ -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 } = {}) => { diff --git a/apps/desktop/electron/main/models-dev-catalog.ts b/apps/desktop/electron/main/models-dev-catalog.ts index 5fb7f59ed..c315266bc 100644 --- a/apps/desktop/electron/main/models-dev-catalog.ts +++ b/apps/desktop/electron/main/models-dev-catalog.ts @@ -536,6 +536,18 @@ const KNOWN_PROVIDER_BASE_URLS: Record = { "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 = { diff --git a/apps/desktop/electron/main/prompt-attachments.ts b/apps/desktop/electron/main/prompt-attachments.ts index db378c9e8..061ee5773 100644 --- a/apps/desktop/electron/main/prompt-attachments.ts +++ b/apps/desktop/electron/main/prompt-attachments.ts @@ -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", @@ -59,8 +62,6 @@ const IMAGE_MIME_BY_EXTENSION: Record = { tiff: "image/tiff", webp: "image/webp", }; -export const MAX_INLINE_IMAGE_BYTES = 20 * 1024 * 1024; - type PromptPath = { absolute: string; root: "project" | "scratch" | "attachment"; diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index c6de8c725..5d62f4592 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -62,6 +62,9 @@ import { PlanApprovalBar } from "./PlanApprovalBar"; import { IconArrowUp, IconUndo2, + IconPlus, + IconFolder, + IconImage, IconShield, IconStop, IconChevronDown, @@ -654,6 +657,8 @@ export function Composer({ ); const [permissionOpen, setPermissionOpen] = useState(false); const permissionRef = useRef(null); + const [plusOpen, setPlusOpen] = useState(false); + const plusRef = useRef(null); const [modelThinkingOpen, setModelThinkingOpen] = useState(false); const [modelThinkingView, setModelThinkingView] = useState("root"); @@ -934,10 +939,27 @@ export function Composer({ useEffect(() => { if (!controlsBlocked) return; + setPlusOpen(false); setPermissionOpen(false); setModelThinkingOpen(false); }, [controlsBlocked]); + useEffect(() => { + if (!plusOpen) return; + const onPointer = (e: MouseEvent) => { + if (!plusRef.current?.contains(e.target as Node)) setPlusOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setPlusOpen(false); + }; + window.addEventListener("mousedown", onPointer); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onPointer); + window.removeEventListener("keydown", onKey); + }; + }, [plusOpen]); + useEffect(() => { // Relative autocomplete references belong to the workspace that produced // them. Session scratch references remain valid across project switches. @@ -1706,6 +1728,82 @@ export function Composer({ ...(token ? { token } : {}), })); + const pickAndAttach = async (kind: "files" | "photos") => { + setPlusOpen(false); + try { + const result = kind === "photos" ? await api.pickPhotos() : await api.pickFiles(); + if (result.canceled || !result.paths.length || inputBlocked) return; + + const editor = ref.current; + const sourceValue = editor ? readEditorValue(editor) : valueRef.current; + const { start: selectionStart, end: selectionEnd } = editor + ? editorSelectionRange(editor) + : { start: sourceValue.length, end: sourceValue.length }; + const sourceSessionId = activeSessionId; + const sourceDraftKey = draftKey; + const previousReferences = snapshotReferences(sourceSessionId ?? ""); + setPasting(true); + try { + // A picker action is real input, so a home draft gets a durable owner + // before native paths are copied into scratch. + const sessionId = sourceSessionId ?? (await materializeDraftSession()); + if (!sessionId) throw new Error("session unavailable"); + const imported = await api.importFiles(sessionId, result.paths); + const chips = imported.files.map((file) => { + const token = nextChipToken(); + return { + token, + reference: createFileReference(file.path, file.name, sessionId, { + kind: file.kind, + mimeType: file.mimeType, + token, + }), + }; + }); + if (!chips.length) return; + const inserted = chips.map((chip) => chip.token).join(""); + const nextText = + sourceValue.slice(0, selectionStart) + + inserted + + sourceValue.slice(selectionEnd); + const nextReferences = [ + ...previousReferences.map((reference) => + createFileReference(reference.path, reference.name, sessionId, reference), + ), + ...chips.map((chip) => chip.reference), + ]; + writeComposerDraft(sessionId, { + text: nextText, + fileReferences: [ + ...previousReferences, + ...chips.map((chip) => ({ + path: chip.reference.path, + name: chip.reference.name, + kind: chip.reference.kind, + ...(chip.reference.mimeType + ? { mimeType: chip.reference.mimeType } + : {}), + token: chip.token, + })), + ], + }); + const currentSessionId = useAppStore.getState().activeSessionId; + if (currentSessionId === sessionId) { + applyEditorDraft(nextText, nextReferences, selectionStart + inserted.length); + } else if (sourceDraftKey === HOME_DRAFT_KEY) { + deleteComposerDraft(HOME_DRAFT_KEY); + } + showToast(t("chat.filesAttached", { count: chips.length }), { + variant: "success", + }); + } finally { + setPasting(false); + } + } catch (e) { + showToast(e instanceof Error ? e.message : String(e), { variant: "error" }); + } + }; + const pasteClipboardFiles = async (event: ClipboardEvent) => { if (inputBlocked) return; const files = clipboardFiles(event.clipboardData); @@ -2106,6 +2204,45 @@ export function Composer({
+
+ + {plusOpen ? ( +
+ + +
+ ) : null} +