Files
LocalAI/core/http/react-ui/e2e/chat-pdf-attachment.spec.js
T
Stefan Walcz 92500b4389 feat(ui): dictate in the chat composer and read scanned PDFs as images (#12756)
Two gaps in the chat composer:

- The microphone button only opened a file dialog for audio files; there
  was no way to speak a message. Add a dictation button: the first click
  records from the microphone, the second stops and sends the recording
  to /v1/audio/transcriptions with the first installed transcription
  model; the text is appended to the message box for review. The button
  is hidden without a transcription model or without a microphone API.
  The audio attach button keeps its function and gets the music icon.

- A PDF without a text layer (a scan) was rejected with a toast. Render
  its first ten pages to PNG with pdf.js and attach them as images, so a
  vision model can read them; a toast says how many pages went along.
  PDFs that cannot be parsed at all are still rejected.

Strings for all eight locales, docs in features/chat.md, Playwright
specs for both (with negative cases: no transcription model, failed
transcription, unparsable PDF).

Assisted-by: Claude Code:claude-opus-5-5

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-10-18 21:52:59 +02:00

134 lines
5.6 KiB
JavaScript

import { test, expect } from './coverage-fixtures.js'
// Single-page PDF with a real text layer, built byte by byte so the xref
// offsets are valid and the spec needs no binary fixture.
function buildPdf(text, { stream: rawStream } = {}) {
const stream = rawStream ?? `BT /F1 18 Tf 20 100 Td (${text}) Tj ET`
const objs = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
]
let out = '%PDF-1.4\n'
const offsets = []
objs.forEach((body, i) => {
offsets.push(out.length)
out += `${i + 1} 0 obj\n${body}\nendobj\n`
})
const xref = out.length
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`
for (const o of offsets) out += `${String(o).padStart(10, '0')} 00000 n \n`
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
return Buffer.from(out, 'latin1')
}
async function openChat(page) {
await page.route('**/api/models/capabilities', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }),
})
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
}
test.describe('Chat - PDF attachments', () => {
test('sends the extracted text layer, not the raw PDF bytes', async ({ page }) => {
let requestBody = ''
await page.route('**/v1/chat/completions', (route) => {
requestBody = route.request().postData() || ''
route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'stop' } }) })
})
await openChat(page)
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'report.pdf',
mimeType: 'application/pdf',
buffer: buildPdf('Quarterly revenue grew 42 percent'),
})
await expect(page.locator('.home-file-tag', { hasText: 'report.pdf' })).toBeVisible()
await page.getByTestId('chat-input').fill('Summarize')
await page.getByTestId('chat-send').click()
await expect.poll(() => requestBody).toContain('Quarterly revenue grew 42 percent')
expect(requestBody).toContain('File: report.pdf')
expect(requestBody).not.toContain('%PDF')
})
test('rejects a PDF that cannot be parsed instead of attaching garbage', async ({ page }) => {
await openChat(page)
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'broken.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 this is not a real document'),
})
await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.home-file-tag', { hasText: 'broken.pdf' })).toHaveCount(0)
})
})
// A page that only paints a filled rectangle: what a scan looks like to pdf.js
// (an image, no text layer).
const scannedPdf = () => buildPdf('', { stream: '0 0 1 rg 20 20 200 120 re f' })
test.describe('Chat - scanned PDF attachments', () => {
test('sends the pages of a PDF without text layer as images', async ({ page }) => {
let requestBody = ''
await page.route('**/v1/chat/completions', (route) => {
requestBody = route.request().postData() || ''
route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'stop' } }) })
})
await openChat(page)
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'scan.pdf', mimeType: 'application/pdf', buffer: scannedPdf(),
})
await expect(page.getByText('scan.pdf has no text layer')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.home-file-tag', { hasText: 'scan-page-1.png' })).toBeVisible()
await page.getByTestId('chat-input').fill('What does this say?')
await page.getByTestId('chat-send').click()
await expect.poll(() => requestBody).toContain('data:image/png;base64,')
expect(requestBody).not.toContain('%PDF')
})
test('still rejects a PDF that cannot be parsed at all', async ({ page }) => {
await openChat(page)
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'broken.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4 this is not a real document'),
})
await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.home-file-tag', { hasText: 'broken' })).toHaveCount(0)
})
})
test.describe('Home - PDF attachments', () => {
test('attaches a PDF that has a text layer', async ({ page }) => {
await page.goto('/app')
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'notes.pdf',
mimeType: 'application/pdf',
buffer: buildPdf('Meeting notes for Tuesday'),
})
await expect(page.locator('.home-file-tag', { hasText: 'notes.pdf' })).toBeVisible({ timeout: 10_000 })
})
test('rejects a PDF that cannot be parsed', async ({ page }) => {
await page.goto('/app')
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'broken.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 this is not a real document'),
})
await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.home-file-tag')).toHaveCount(0)
})
})