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.
<aparte-composer placeholder="Ask about these files…">
<div class="aparte-composer-shell">
<!-- The pending files, above the field. Hidden while there are none. -->
<aparte-composer-attachments></aparte-composer-attachments>
<div class="aparte-composer-row">
<!-- Opens the file picker, and makes the whole composer a drop target. -->
<aparte-composer-add-attachment accept="image/*,.pdf,.md"></aparte-composer-add-attachment>
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>
<script>
// Two files already picked, standing in for what a reader would choose.
const composer = document.querySelector('aparte-composer');
const notes = new File(['# Launch notes'], 'launch-notes.md', { type: 'text/markdown' });
const canvas = Object.assign(document.createElement('canvas'), { width: 120, height: 80 });
const paint = canvas.getContext('2d');
paint.fillStyle = '#b07d33';
paint.fillRect(0, 0, 120, 80);
canvas.toBlob((blob) => composer.addAttachments([notes, new File([blob], 'cover.png', { type: 'image/png' })]));
</script>Show the paperclip
Section titled “Show the paperclip”<aparte-chat attachments></aparte-chat><AparteChat attachments /><AparteChat attachments /><AparteChat attachments /><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:
<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.
Read the files on send
Section titled “Read the files on send”document.querySelector('aparte-chat')?.addEventListener('aparte-send', (event) => {
const { content, files } = event.detail; // files: File[] | undefined
});<AparteChat attachments onMessageSent={(detail) => { if (detail.files) upload(detail.files); }} /><AparteChat attachments @message-sent="(detail) => { if (detail.files) upload(detail.files); }" /><AparteChat attachments on:messageSent={(e) => { if (e.detail.files) upload(e.detail.files); }} /><aparte-chat attachments (messageSent)="$event.files && upload($event.files)"></aparte-chat>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.
What the model receives
Section titled “What the model receives”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: nameline; - 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.
With your own loop
Section titled “With your own loop”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.
Open an image full size
Section titled “Open an image full size”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);
});<!-- attachments adds the file picker and the pending files to the composer the tag
composes. Opt-in: the files reach you on aparte-send, and reading them is yours. -->
<aparte-chat attachments center-empty placeholder="Attach a file and ask about it" style="height: 22rem"></aparte-chat>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.
Drag, drop and paste
Section titled “Drag, drop and paste”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.
Release memory and save the files
Section titled “Release memory and save the files”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).