diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac6ef106..2c239b747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ ### Added +- Attach images to an AI chat message by dropping them anywhere on the chat panel: an outline over the composer shows where they go, or why they cannot be attached while a reply is running or four images are already attached. SVG images are drawn as PNGs when dropped, pasted, or chosen. Documents dropped on the chat still open in a new tab. - Share only the selected layers with MCP clients: **Share only the selection** under **Settings → MCP → Local server**, or `OPENPENCIL_MCP_SCOPE=selection` for a server you start yourself, limits clients to `get_selection`, `get_node`, `get_page_tree`, `describe`, and `export_image` on the selected layers and what they hold, enforced by the server for MCP sessions, stdio clients, and `/rpc` alike. - Set up AI in a few steps with guided setup. On first launch OpenPencil asks what AI should help with and what you already use: a coding agent on this computer (Claude Code, Codex, Gemini CLI, or Pi, in the desktop app), an API account (Anthropic, OpenAI, Google AI, OpenRouter, DeepSeek, Z.ai, or MiniMax), or a local or company server such as Ollama or LM Studio. Sign in to OpenRouter from the browser or the desktop app instead of pasting a key. In the desktop app, setup checks whether the agent, its OpenPencil adapter, OpenPencil's MCP server, and Pi's Harness companion are installed and match the app, installs or updates them with one click through npm, and links a [setup guide](https://openpencil.dev/programmable/coding-agents) or copies a prompt that asks the agent to set itself up. Setup tests each connection, proposes a model for each role that you can change, and keeps anything configured by hand. Skip it to start designing, or run it again from **Settings → AI & agents**. - Print the installed version of OpenPencil's MCP server and Harness companion with `openpencil-mcp-http --version`, `openpencil-mcp --version`, and `openpencil-harness --version`. The desktop app asks it to check that they match the app on every platform, including npm `.cmd` shims on Windows and version managers such as Volta and mise. @@ -122,6 +123,7 @@ ### Fixed +- Show tables in AI chat replies at the chat's text size and weight, with a light header and copy as their only action, and task lists with a checkbox in place of the bullet. Tooltips, the copy menu, and the confirmation before opening a link follow the app's style, and links no longer load each site's favicon. - Keep an opened `.fig` file saved until it is edited. Laying out its first page, which recomputes auto-layout sizes and positions, marked it unsaved, so closing it asked to save changes nobody made. - Fill one axis of a grid cell: a grid child set to fill its width or its height no longer fills both, and an auto-layout child of a grid keeps its own size unless it fills, as in Figma. Design JSX renders and exports `w="fill"` and `h="fill"` in grids, and HTML and Tailwind export leave out the size a child fills, so it stretches in the browser too. - Resize auto-layout frames that fill across their parent with it: one with a fixed size on that axis kept its old size. A filling child still counts toward a hugging parent's size, as in Figma. diff --git a/packages/vue/src/i18n/locales/de/ai.json b/packages/vue/src/i18n/locales/de/ai.json index 83ddecef9..bbea6a013 100644 --- a/packages/vue/src/i18n/locales/de/ai.json +++ b/packages/vue/src/i18n/locales/de/ai.json @@ -21,6 +21,9 @@ "sendMessage": "Nachricht senden", "attachImages": "Bilder anhängen", "removeImageAttachment": "Bild {name} entfernen", + "dropImagesToAttach": "Bilder zum Anhängen ablegen", + "dropImagesAfterReply": "Bilder nach der Antwort anhängen", + "dropImagesLimit": "Bis zu {count} Bilder pro Nachricht", "viewAttachment": "Anhang {name} anzeigen", "attachmentPreview": "Anhangsvorschau", "closeAttachmentPreview": "Anhangsvorschau schließen", diff --git a/packages/vue/src/i18n/locales/es/ai.json b/packages/vue/src/i18n/locales/es/ai.json index 2cf2072ee..33f92c162 100644 --- a/packages/vue/src/i18n/locales/es/ai.json +++ b/packages/vue/src/i18n/locales/es/ai.json @@ -21,6 +21,9 @@ "sendMessage": "Enviar mensaje", "attachImages": "Adjuntar imágenes", "removeImageAttachment": "Quitar imagen {name}", + "dropImagesToAttach": "Suelta imágenes para adjuntarlas", + "dropImagesAfterReply": "Adjunta imágenes después de la respuesta", + "dropImagesLimit": "Hasta {count} imágenes por mensaje", "viewAttachment": "Ver adjunto {name}", "attachmentPreview": "Vista previa del adjunto", "closeAttachmentPreview": "Cerrar vista previa del adjunto", diff --git a/packages/vue/src/i18n/locales/fr/ai.json b/packages/vue/src/i18n/locales/fr/ai.json index a25b0b2d8..f49ea9367 100644 --- a/packages/vue/src/i18n/locales/fr/ai.json +++ b/packages/vue/src/i18n/locales/fr/ai.json @@ -21,6 +21,9 @@ "sendMessage": "Envoyer le message", "attachImages": "Joindre des images", "removeImageAttachment": "Retirer l’image {name}", + "dropImagesToAttach": "Déposez des images pour les joindre", + "dropImagesAfterReply": "Joignez des images après la réponse", + "dropImagesLimit": "Jusqu’à {count} images par message", "viewAttachment": "Afficher la pièce jointe {name}", "attachmentPreview": "Aperçu de la pièce jointe", "closeAttachmentPreview": "Fermer l’aperçu de la pièce jointe", diff --git a/packages/vue/src/i18n/locales/it/ai.json b/packages/vue/src/i18n/locales/it/ai.json index 09fba4881..5ad8bcb12 100644 --- a/packages/vue/src/i18n/locales/it/ai.json +++ b/packages/vue/src/i18n/locales/it/ai.json @@ -21,6 +21,9 @@ "sendMessage": "Invia messaggio", "attachImages": "Allega immagini", "removeImageAttachment": "Rimuovi immagine {name}", + "dropImagesToAttach": "Rilascia immagini per allegarle", + "dropImagesAfterReply": "Allega immagini dopo la risposta", + "dropImagesLimit": "Fino a {count} immagini per messaggio", "viewAttachment": "Visualizza allegato {name}", "attachmentPreview": "Anteprima allegato", "closeAttachmentPreview": "Chiudi anteprima allegato", diff --git a/packages/vue/src/i18n/locales/ja/ai.json b/packages/vue/src/i18n/locales/ja/ai.json index bc18a013a..934995474 100644 --- a/packages/vue/src/i18n/locales/ja/ai.json +++ b/packages/vue/src/i18n/locales/ja/ai.json @@ -21,6 +21,9 @@ "sendMessage": "メッセージを送信", "attachImages": "画像を添付", "removeImageAttachment": "画像 {name} を削除", + "dropImagesToAttach": "ドロップして画像を添付", + "dropImagesAfterReply": "返信が終わってから画像を添付", + "dropImagesLimit": "1 件のメッセージに画像は {count} 枚まで", "viewAttachment": "添付ファイル {name} を表示", "attachmentPreview": "添付ファイルのプレビュー", "closeAttachmentPreview": "添付ファイルのプレビューを閉じる", diff --git a/packages/vue/src/i18n/locales/pl/ai.json b/packages/vue/src/i18n/locales/pl/ai.json index 5d35ab5c7..b7555ee3f 100644 --- a/packages/vue/src/i18n/locales/pl/ai.json +++ b/packages/vue/src/i18n/locales/pl/ai.json @@ -21,6 +21,9 @@ "sendMessage": "Wyślij wiadomość", "attachImages": "Dołącz obrazy", "removeImageAttachment": "Usuń obraz {name}", + "dropImagesToAttach": "Upuść obrazy, aby je dołączyć", + "dropImagesAfterReply": "Dołącz obrazy po odpowiedzi", + "dropImagesLimit": "Do {count} obrazów na wiadomość", "viewAttachment": "Wyświetl załącznik {name}", "attachmentPreview": "Podgląd załącznika", "closeAttachmentPreview": "Zamknij podgląd załącznika", diff --git a/packages/vue/src/i18n/locales/ru/ai.json b/packages/vue/src/i18n/locales/ru/ai.json index 86fa8e811..dab278279 100644 --- a/packages/vue/src/i18n/locales/ru/ai.json +++ b/packages/vue/src/i18n/locales/ru/ai.json @@ -21,6 +21,9 @@ "sendMessage": "Отправить сообщение", "attachImages": "Прикрепить изображения", "removeImageAttachment": "Удалить изображение {name}", + "dropImagesToAttach": "Перетащите изображения, чтобы прикрепить", + "dropImagesAfterReply": "Прикрепите изображения после ответа", + "dropImagesLimit": "До {count} изображений в сообщении", "viewAttachment": "Просмотреть вложение {name}", "attachmentPreview": "Предпросмотр вложения", "closeAttachmentPreview": "Закрыть предпросмотр вложения", diff --git a/packages/vue/src/i18n/locales/zh-cn/ai.json b/packages/vue/src/i18n/locales/zh-cn/ai.json index e52f5564c..abefe1092 100644 --- a/packages/vue/src/i18n/locales/zh-cn/ai.json +++ b/packages/vue/src/i18n/locales/zh-cn/ai.json @@ -21,6 +21,9 @@ "sendMessage": "发送消息", "attachImages": "附加图片", "removeImageAttachment": "移除图片 {name}", + "dropImagesToAttach": "拖放图片以添加附件", + "dropImagesAfterReply": "回复完成后再添加图片", + "dropImagesLimit": "每条消息最多 {count} 张图片", "viewAttachment": "查看附件 {name}", "attachmentPreview": "附件预览", "closeAttachmentPreview": "关闭附件预览", diff --git a/packages/vue/src/i18n/messages/ai.ts b/packages/vue/src/i18n/messages/ai.ts index a0107bc1b..267ddf527 100644 --- a/packages/vue/src/i18n/messages/ai.ts +++ b/packages/vue/src/i18n/messages/ai.ts @@ -68,6 +68,9 @@ export const aiMessageDefaults = { sendMessage: 'Send message', attachImages: 'Attach images', removeImageAttachment: params('Remove image {name}'), + dropImagesToAttach: 'Drop images to attach', + dropImagesAfterReply: 'Attach images after the reply', + dropImagesLimit: params('Up to {count} images per message'), viewAttachment: params('View attachment {name}'), attachmentPreview: 'Attachment preview', closeAttachmentPreview: 'Close attachment preview', diff --git a/src/app/ai/attachment/image/prepare.ts b/src/app/ai/attachment/image/prepare.ts index 2295228ee..2267d1cff 100644 --- a/src/app/ai/attachment/image/prepare.ts +++ b/src/app/ai/attachment/image/prepare.ts @@ -58,6 +58,50 @@ function canvasToBlob( }) } +/** Whether a file is an SVG image, which some systems report without a media type. */ +export function isSVGFile(file: File): boolean { + return ( + file.type === 'image/svg+xml' || (file.type === '' && file.name.toLowerCase().endsWith('.svg')) + ) +} + +/** Whether a file can be attached as an image, as it is or after drawing an SVG as PNG. */ +export function isAttachableImageFile(file: File): boolean { + return file.type.startsWith('image/') || isSVGFile(file) +} + +/** The square an SVG without its own size is drawn into, in pixels. */ +const SVG_FALLBACK_EDGE = 512 + +/** + * An SVG drawn as a PNG, since vision models take raster images. Its longer edge is drawn at the + * attachment size, so small icons stay sharp; an `` never runs the SVG's scripts. + */ +export async function rasterizeSVGAttachment( + file: File, + maxEdge = IMAGE_ATTACHMENT_MAX_EDGE +): Promise { + if (file.size > MAX_IMAGE_FILE_BYTES) throw new Error('Images must be 20 MB or smaller.') + const sourceURL = createImagePreviewURL(new Blob([file], { type: 'image/svg+xml' })) + try { + const image = await loadImage(sourceURL) + const naturalWidth = image.naturalWidth || SVG_FALLBACK_EDGE + const naturalHeight = image.naturalHeight || SVG_FALLBACK_EDGE + const scale = maxEdge / Math.max(naturalWidth, naturalHeight) + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(naturalWidth * scale)) + canvas.height = Math.max(1, Math.round(naturalHeight * scale)) + const context = canvas.getContext('2d') + if (!context) throw new Error('Could not prepare the image.') + context.drawImage(image, 0, 0, canvas.width, canvas.height) + const blob = await canvasToBlob(canvas, 'image/png') + const name = file.name.replace(/\.svg$/i, '') + '.png' + return new File([blob], name, { type: 'image/png' }) + } finally { + revokeImagePreviewURL(sourceURL) + } +} + export async function prepareImageAttachment( file: File, maxEdge = IMAGE_ATTACHMENT_MAX_EDGE diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index db3f9ff21..5e14ede32 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -23,6 +23,7 @@ import ChatInput from '@/components/chat/ChatInput.vue' import ChatRunLocation from '@/components/chat/ChatRunLocation.vue' import ChatTranscript from '@/components/chat/ChatTranscript.vue' import FollowAgentsToggle from '@/components/chat/FollowAgentsToggle.vue' +import { useImageDrop } from '@/components/chat/input/useImageDrop' import ProviderSetup from '@/components/chat/ProviderSetup.vue' const { isConfigured, ensureChat, history, chatFailure, clearChatFailure } = useAIChat() @@ -54,9 +55,16 @@ const submission = useChatSubmission({ openSetup: () => openAISetup() }) -const chatInput = useTemplateRef<{ restoreDraft: (submission: ChatSubmission) => void }>( - 'chatInput' -) +const chatInput = useTemplateRef<{ + restoreDraft: (submission: ChatSubmission) => void + dropFiles: (files: File[]) => void +}>('chatInput') +const panel = useTemplateRef('panel') +// Images dropped anywhere on the chat go to the composer, when there is one to take them. +const { dragging: draggingImages } = useImageDrop(panel, { + enabled: () => chatInput.value !== null, + onImages: (files) => chatInput.value?.dropFiles(files) +}) let viewGeneration = 0 /** Puts an unsent message back only in the conversation it was written in. */ @@ -188,7 +196,11 @@ function handleStop() { + + diff --git a/src/components/chat/ChatInput.vue b/src/components/chat/ChatInput.vue index 98cd147db..51c56fae3 100644 --- a/src/components/chat/ChatInput.vue +++ b/src/components/chat/ChatInput.vue @@ -15,6 +15,7 @@ import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue' import ChatThinkingSelect from '@/components/chat/ChatThinkingSelect.vue' import { useAttachmentDrafts } from '@/components/chat/input/useAttachments' import IconButton from '@/components/ui/button/IconButton.vue' +import AppDropOverlay from '@/components/ui/feedback/AppDropOverlay.vue' import ChatComposer from './ChatComposer.vue' @@ -22,9 +23,15 @@ const { providerID, providerDef, modelID, customModelID } = useAIChat() const { editor, selectedIds } = useSelectionState() const { ai } = useI18n() -const { status, disabled = false } = defineProps<{ +const { + status, + disabled = false, + dragging = false +} = defineProps<{ status: 'ready' | 'submitted' | 'streaming' | 'error' disabled?: boolean + /** Whether images are being dragged over the chat, which shows where they will go. */ + dragging?: boolean }>() const emit = defineEmits<{ @@ -44,6 +51,7 @@ const { canToggleSelection: canAddSelection, selectionActive: selectionContextActive, openImageDialog, + addFiles, removeImage, removeNode: removeReferencedNode, toggleSelection: toggleCurrentSelection, @@ -59,9 +67,29 @@ function restoreDraft(submission: ChatSubmission): void { if (composer.value?.restoreDraft(submission.displayText)) restoreSubmission(submission) else for (const image of submission.images) revokeImagePreviewURL(image.previewURL) } -defineExpose({ restoreDraft }) const isStreaming = computed(() => disabled || status === 'streaming' || status === 'submitted') + +/** Why images dropped now would not be attached, or null when they would. */ +const dropRefusal = computed(() => { + if (isStreaming.value) return ai.value.dropImagesAfterReply + if (images.value.length >= MAX_IMAGE_ATTACHMENTS) { + return ai.value.dropImagesLimit({ count: MAX_IMAGE_ATTACHMENTS }) + } + return null +}) + +/** Attaches images dropped on the chat, unless they cannot be attached right now. */ +function dropFiles(files: File[]): void { + if (dropRefusal.value) { + emit('error', dropRefusal.value) + return + } + void addFiles(files) +} + +defineExpose({ restoreDraft, dropFiles }) + const isAgentProvider = computed( () => providerID.value.startsWith('acp:') || providerID.value === 'harness:pi' ) @@ -145,6 +173,14 @@ const selectedProfileName = computed( +