Skip to content

Let people attach files and send them to the model

Show the paperclip, read the files when the message is sent, choose what reaches the model, and open an image full size.

On this page
Open
Files waiting in the composer, before the message is sent.
html
<aparte-chat attachments></aparte-chat>

The chat has no paperclip until you set attachments: set it when something reads the files — AparteClient does, and so does a loop of yours that reads them on send. It adds the button and the row of files waiting to go. In a composer you write yourself, place the two elements where you want them, and choose what the file dialog offers:

html
<aparte-composer>
  <aparte-composer-attachments></aparte-composer-attachments>
  <div class="aparte-composer-row">
    <aparte-composer-add-attachment accept="image/*,.pdf"></aparte-composer-add-attachment>
    <aparte-composer-input></aparte-composer-input>
    <aparte-composer-send></aparte-composer-send>
  </div>
</aparte-composer>

accept and multiple="false" shape the file dialog only: a drop or a paste can still add other types and more files, and each pick adds to the files already waiting. To hold a limit, watch aparte-composer-change and call removeAttachment, below.

ts
document.querySelector('aparte-chat')?.addEventListener('aparte-send', (event) => {
  const { content, files } = event.detail;   // files: File[] | undefined
});

The files leave with the message, on aparte-send. React, Vue and Angular hand you its detail; Svelte’s on:messageSent hands you an event, as the DOM does, hence e.detail. To follow the files while they wait — to enable a button, say — listen for aparte-composer-change, whose detail.state.attachments is the current list. The composer element also takes them from code: addAttachments(files), removeAttachment(file), clearAttachments(), and attachments to read them.

ts
import { AparteClient } from '@aparte/core';

new AparteClient({
  rawFileInject: 'all',   // or 'images-only', or 'none'
  fileInjectFilter: (file) => !/(^|\.)env$|\.(pem|key)$/i.test(file.name),
}).start();

AparteClient puts the files into the request itself:

  • an image goes as an image part, for a model that reads images;
  • a text file — Markdown, JSON, CSV, source code, a log, an .env — is read in the browser and goes in full, under a // File: name line;
  • anything else, a PDF or a spreadsheet, does not go: the file stays on screen and on the send event for an upload of yours, and the console says so once per file name.

'images-only' sends the images alone, and 'none' sends no file, for an app that uploads them or retrieves what it needs from them itself. fileInjectFilter is called for each file the mode lets through — every file, in 'all' — and false keeps that one out: the example keeps out .env, .pem and .key files, and yours can test whatever your users might attach by mistake. The files go with that one request: on the next turns, a retry included, the history carries the message’s text, not its files, so keep what the model needs in your app — an upload, a retrieval step. Whether the model reads an image depends on the provider and the model.

ts
import { filesToAttachments } from '@aparte/core';

const chat = document.querySelector('aparte-chat');

chat?.addEventListener('aparte-send', (event) => {
  const { content, files } = event.detail;
  chat.viewport?.appendMessage({
    id: crypto.randomUUID(), role: 'user', content, timestamp: Date.now(),
    ...(files?.length ? { attachments: filesToAttachments(files) } : {}),
  });
});

This is for plain HTML without AparteClient: the client and the framework packages already add the person’s message with its files, so do not append it again there. filesToAttachments turns the sent files into the attachments a message shows. Call appendMessage on the message list, chat.viewport, not on the chat itself.

ts
import { aparteGlobalConfig } from '@aparte/core';

declare function openLightbox(url: string, name: string): void;

aparteGlobalConfig.setHostHandlers({ attachmentPreview: true });

document.addEventListener('aparte-attachment-preview', (event) => {
  openLightbox(event.detail.url, event.detail.name);
});
Open
Files on a sent message: an image tile and a file chip.

Once you say you open them, an image becomes a button, by click, Enter or Space, that sends aparte-attachment-preview with its url and name; until then it is a picture, with no pointer and no tab stop. Core draws no lightbox: the view is yours. A chat with its own configuration takes setHostHandlers on that configuration.

A file dropped anywhere on the composer attaches. While a file is dragged over it, the composer carries aparte-is-dragover, and its outline follows your theme. Drag and drop comes with the paperclip button, so a composer without the button takes no drop. A disabled composer takes no drop and greys the button; a composer streaming a reply still takes files. An image pasted into the input attaches too, on the same condition: a composer with the button takes it, one without takes nothing.

ts
import { revokeAttachmentUrls, type AparteMessage } from '@aparte/core';

function forget(messages: AparteMessage[]): void {
  for (const message of messages) revokeAttachmentUrls(message.attachments);
}

Each attachment’s url keeps its file in memory for as long as the page lives. When attachments leave the screen for good, revokeAttachmentUrls lets the memory go; calling it twice is harmless, and the message list’s clearAll() does it for the messages it drops. The file itself stays on the attachment as blob: your storage adapter saves it as an AparteAttachmentRow, with its conversation and message ids, and builds a new url when it loads the message (Save conversations).