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
5 changes: 3 additions & 2 deletions src/routes/motd/motd.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
#### What's new?

- **Redesigned sidebar navigation** with improved accessibility and mobile experience.
- **Import & export** options in [Settings](/settings).
- **Added support for vision models** (e.g. `gemma3`, `gpt-4.1`).
- **Copy & paste images** directly into the prompt field for quick image attachments.

#### Previously, in Hollama

- **Import & export** options in [Settings](/settings).
- **Added support for vision models** (e.g. `gemma3`, `gpt-4.1`).
- **Added support for reasoning responses** using [`<think>`](https://ollama.com/library/deepseek-r1) and [`<thought>`](https://ollama.com/library/exaone-deep) tags.
- **KaTeX math notation** is now supported in model responses.
- **Session titles** can now be manually edited.
Expand Down
67 changes: 67 additions & 0 deletions src/routes/sessions/[id]/Prompt.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,72 @@
submit();
}

function handlePaste(event: ClipboardEvent) {
const clipboardData = event.clipboardData;
if (!clipboardData) return;

const items = Array.from(clipboardData.items);
const imageItems = items.filter((item) => item.type.startsWith('image/'));

if (imageItems.length === 0) return;

// Prevent default paste behavior when images are detected
event.preventDefault();

const allowedTypes = ['image/png', 'image/jpeg'];
const newAttachments: ImageAttachment[] = [];
let unsupportedFiles = false;

const imagePromises = imageItems.map((item, index) => {
return new Promise<void>((resolve) => {
if (!allowedTypes.includes(item.type)) {
unsupportedFiles = true;
resolve();
return;
}

const file = item.getAsFile();
if (!file) {
resolve();
return;
}

const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
if (dataUrl) {
// Generate a filename based on timestamp and index
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const extension = item.type === 'image/png' ? 'png' : 'jpg';
const filename = `pasted-image-${timestamp}-${index + 1}.${extension}`;

newAttachments.push({
type: 'image',
id: generateRandomId(),
name: filename,
dataUrl
});
}
resolve();
};
reader.onerror = () => {
console.error('Error reading pasted image');
resolve();
};
reader.readAsDataURL(file);
});
});

Promise.all(imagePromises).then(() => {
if (unsupportedFiles) {
toast.warning('Some images were ignored. Only PNG and JPEG images are supported.');
}
if (newAttachments.length > 0) {
attachments = [...attachments, ...newAttachments];
}
});
}

function handleSelectKnowledge(fieldId: string, knowledgeId: string) {
attachments = attachments.map((a) =>
a.type === 'knowledge' && a.fieldId === fieldId
Expand Down Expand Up @@ -255,6 +321,7 @@
bind:this={editor.promptTextarea}
bind:value={editor.prompt}
onkeydown={handleKeyDown}
onpaste={handlePaste}
></textarea>
</Field>
{/if}
Expand Down
149 changes: 149 additions & 0 deletions tests/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,4 +553,153 @@ test.describe('Attachments', () => {
await page.locator('.prompt-editor').getByTestId('attachment-image-preview').count()
).toBe(0);
});

test('can paste an image from clipboard', async ({ page }) => {
// ESM-compatible path resolution for test image
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const testImagePath = path.resolve(__dirname, 'docs.test.ts-snapshots', 'motd.png');

await page.goto('/');
await page.getByRole('tab', { name: 'Sessions' }).click();

await page.getByTestId('new-session').click();
await chooseModel(page, MOCK_API_TAGS_RESPONSE.models[0].name);
const promptTextarea = page.locator('.prompt-editor__textarea');

// Focus the textarea
await promptTextarea.focus();

// Read the test image file and create a clipboard data transfer
const fs = await import('fs');
const imageBuffer = fs.readFileSync(testImagePath);
const imageBase64 = imageBuffer.toString('base64');
const dataUrl = `data:image/png;base64,${imageBase64}`;

// Simulate pasting an image by dispatching a paste event with clipboard data
await page.evaluate((dataUrl) => {
const textarea = document.querySelector('.prompt-editor__textarea') as HTMLTextAreaElement;
if (!textarea) throw new Error('Textarea not found');

// Create a mock clipboard event with image data
const clipboardData = new DataTransfer();

// Convert base64 to blob
const byteCharacters = atob(dataUrl.split(',')[1]);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'image/png' });

// Create a file from the blob
const file = new File([blob], 'pasted-image.png', { type: 'image/png' });
clipboardData.items.add(file);

// Create and dispatch paste event
const pasteEvent = new ClipboardEvent('paste', {
clipboardData: clipboardData,
bubbles: true,
cancelable: true
});

textarea.dispatchEvent(pasteEvent);
}, dataUrl);

// Wait for the image to be processed and appear in attachments
await expect(
page.locator('.prompt-editor').getByTestId('attachment-image-preview')
).toBeVisible();

// Check that the filename contains "pasted-image" and has proper extension
const attachmentName = page.locator('.prompt-editor').getByTestId('attachment-image-name');
await expect(attachmentName).toBeVisible();
const nameText = await attachmentName.textContent();
expect(nameText).toMatch(/^pasted-image-.*\.png$/);

// Verify the image can be deleted
await page.getByTestId('attachment-delete').click();
await expect(
page.locator('.prompt-editor').getByTestId('attachment-image-preview')
).not.toBeVisible();

// Test pasting multiple images
await promptTextarea.focus();

// Paste the same image twice by dispatching two paste events
for (let i = 0; i < 2; i++) {
await page.evaluate((dataUrl) => {
const textarea = document.querySelector('.prompt-editor__textarea') as HTMLTextAreaElement;
if (!textarea) throw new Error('Textarea not found');

const clipboardData = new DataTransfer();
const byteCharacters = atob(dataUrl.split(',')[1]);
const byteNumbers = new Array(byteCharacters.length);
for (let j = 0; j < byteCharacters.length; j++) {
byteNumbers[j] = byteCharacters.charCodeAt(j);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'image/png' });
const file = new File([blob], 'pasted-image.png', { type: 'image/png' });
clipboardData.items.add(file);

const pasteEvent = new ClipboardEvent('paste', {
clipboardData: clipboardData,
bubbles: true,
cancelable: true
});

textarea.dispatchEvent(pasteEvent);
}, dataUrl);

// Small delay between pastes
await page.waitForTimeout(100);
}

// Verify both images are attached
await expect(
page.locator('.prompt-editor').getByTestId('attachment-image-preview')
).toHaveCount(2);

// Intercept outgoing request to verify images are sent
let requestPayload:
| { messages: { role: string; content: string; images?: string[] }[] }
| undefined = undefined;
await page.route('**/chat', async (route, request) => {
const postData = request.postData();
if (postData) requestPayload = JSON.parse(postData);
const responseBody = [
JSON.stringify({
message: { role: 'assistant', content: 'I can see the pasted images' }
}),
''
].join('\n');
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: responseBody
});
});

await promptTextarea.fill('Describe these pasted images');
await page.getByText('Run').click();

// Assert payload contains both pasted images
if (!requestPayload) throw new Error('No request payload captured');
const lastUserMsg = (
requestPayload as { messages: { role: string; content: string; images?: string[] }[] }
).messages
.filter((m) => m.role === 'user')
.at(-1);
expect(lastUserMsg).toBeTruthy();
expect(Array.isArray(lastUserMsg?.images)).toBe(true);
expect(lastUserMsg?.images?.length).toBe(2);
expect(lastUserMsg?.content).toContain('Describe these pasted images');

// Assert attachments UI is cleared after submission
expect(
await page.locator('.prompt-editor').getByTestId('attachment-image-preview').count()
).toBe(0);
});
});