mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-02 19:14:38 -04:00
fix(react-ui): extract text from PDF attachments in chat and home (#12374)
The React UI read every non-media attachment with file.text(). For a PDF that decodes the binary bytes as UTF-8, so the model received raw "%PDF ... stream ... endobj" noise instead of the document. The legacy Alpine UI ran pdf.js; that step was not ported when the React UI replaced it, but both file pickers still advertise .pdf. Add a shared readAttachmentText helper that routes PDFs through pdfjs-dist and reads other files as before. pdf.js and its worker load on first use, so the main bundle does not grow. A PDF that cannot be parsed or has no text layer (scanned, encrypted, damaged) is rejected with a toast instead of being attached as an empty or garbage file. Cover the chat and home paths with Playwright specs that build a real PDF in the test. Assisted-by: Claude Code:claude-sonnet-5-5 [playwright] [eslint] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
4d0317db8d
commit
bd6863af81
8 files changed
+437
-6
No files matched your search
@@ -0,0 +1,97 @@
|
||||
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) {
|
||||
const stream = `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]').setInputFiles({
|
||||
name: 'report.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: buildPdf('Quarterly revenue grew 42 percent'),
|
||||
})
|
||||
await expect(page.locator('.chat-file-name', { hasText: 'report.pdf' })).toBeVisible()
|
||||
|
||||
await page.locator('.chat-input').fill('Summarize')
|
||||
await page.locator('.chat-send-btn').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]').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('.chat-file-name', { hasText: 'broken.pdf' })).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)
|
||||
})
|
||||
})
|
||||
Generated
+271
@@ -29,6 +29,7 @@
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.6",
|
||||
"marked": "^15.0.7",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^17.0.6",
|
||||
@@ -1021,6 +1022,256 @@
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -5219,6 +5470,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/node-readable-to-web-readable-stream": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.54",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||
@@ -5752,6 +6010,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.6.205",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
|
||||
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.96",
|
||||
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.6",
|
||||
"marked": "^15.0.7",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^17.0.6",
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
"copied": "Copied to clipboard",
|
||||
"copyFailed": "Could not copy to clipboard",
|
||||
"chatCopied": "Chat copied to clipboard",
|
||||
"forked": "Created a new chat"
|
||||
"forked": "Created a new chat",
|
||||
"pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged."
|
||||
},
|
||||
"menu": {
|
||||
"trigger": "Chats",
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"enterToSend": "Enter to send",
|
||||
"selectModelFirst": "Select a model first",
|
||||
"sendMessage": "Send message",
|
||||
"selectModelToast": "Please select a model first"
|
||||
"selectModelToast": "Please select a model first",
|
||||
"pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged."
|
||||
},
|
||||
"quickLinks": {
|
||||
"manageByChat": "Manage by chat",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/arti
|
||||
import CanvasPanel from '../components/CanvasPanel'
|
||||
import Toggle from '../components/Toggle'
|
||||
import { fileToBase64, modelsApi, mcpApi } from '../utils/api'
|
||||
import { readAttachmentText } from '../utils/pdf'
|
||||
import { CAP_CHAT } from '../utils/capabilities'
|
||||
import { useMCPClient } from '../hooks/useMCPClient'
|
||||
import MCPAppFrame from '../components/MCPAppFrame'
|
||||
@@ -842,13 +843,18 @@ export default function Chat() {
|
||||
const base64 = await fileToBase64(file)
|
||||
const entry = { name: file.name, type: file.type, base64 }
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) {
|
||||
entry.textContent = await file.text().catch(() => '')
|
||||
try {
|
||||
entry.textContent = await readAttachmentText(file)
|
||||
} catch {
|
||||
addToast(t('toasts.pdfReadFailed', { name: file.name }), 'error')
|
||||
continue
|
||||
}
|
||||
}
|
||||
newFiles.push(entry)
|
||||
}
|
||||
setFiles(prev => [...prev, ...newFiles])
|
||||
e.target.value = ''
|
||||
}, [])
|
||||
}, [addToast, t])
|
||||
|
||||
const handlePaste = useCallback(async (e) => {
|
||||
const items = e.clipboardData?.items
|
||||
|
||||
@@ -12,6 +12,7 @@ import HomeConnect from '../components/HomeConnect'
|
||||
import { useResources } from '../hooks/useResources'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
|
||||
import { readAttachmentText } from '../utils/pdf'
|
||||
import { API_CONFIG } from '../utils/config'
|
||||
import { greetingKey } from '../utils/greeting'
|
||||
import StatusPill from '../components/StatusPill'
|
||||
@@ -158,12 +159,17 @@ export default function Home() {
|
||||
const base64 = await fileToBase64(file)
|
||||
const entry = { name: file.name, type: file.type, base64 }
|
||||
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/')) {
|
||||
entry.textContent = await file.text().catch(() => '')
|
||||
try {
|
||||
entry.textContent = await readAttachmentText(file)
|
||||
} catch {
|
||||
addToast(t('input.pdfReadFailed', { name: file.name }), 'error')
|
||||
continue
|
||||
}
|
||||
}
|
||||
newFiles.push(entry)
|
||||
}
|
||||
setter(prev => [...prev, ...newFiles])
|
||||
}, [])
|
||||
}, [addToast, t])
|
||||
|
||||
const removeFile = useCallback((file) => {
|
||||
const removeFn = (prev) => prev.filter(f => f !== file)
|
||||
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
export function isPdf(file) {
|
||||
return file?.type === 'application/pdf' || /\.pdf$/i.test(file?.name || '')
|
||||
}
|
||||
|
||||
// pdf.js and its worker are loaded on first use so the main bundle does not
|
||||
// pay for them when nobody attaches a PDF.
|
||||
async function loadPdfjs() {
|
||||
const [pdfjs, worker] = await Promise.all([
|
||||
import('pdfjs-dist'),
|
||||
import('pdfjs-dist/build/pdf.worker.min.mjs?url'),
|
||||
])
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = worker.default
|
||||
return pdfjs
|
||||
}
|
||||
|
||||
// Returns the text layer of every page, one block per page. Throws when the
|
||||
// file cannot be parsed or has no text layer (scanned PDFs): sending an empty
|
||||
// attachment to the model would look like success and silently lose the file.
|
||||
export async function extractPdfText(file) {
|
||||
const pdfjs = await loadPdfjs()
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
const doc = await pdfjs.getDocument({ data }).promise
|
||||
try {
|
||||
const pages = []
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i)
|
||||
const content = await page.getTextContent()
|
||||
let text = ''
|
||||
for (const item of content.items) {
|
||||
text += item.str
|
||||
text += item.hasEOL ? '\n' : ''
|
||||
}
|
||||
pages.push(text.trim())
|
||||
}
|
||||
const text = pages.filter(Boolean).join('\n\n')
|
||||
if (!text) throw new Error('PDF has no extractable text')
|
||||
return text
|
||||
} finally {
|
||||
await doc.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Text of an attached non-media file. PDFs go through pdf.js; everything else
|
||||
// is read as UTF-8.
|
||||
export async function readAttachmentText(file) {
|
||||
if (isPdf(file)) return extractPdfText(file)
|
||||
return file.text().catch(() => '')
|
||||
}
|
||||
Reference in new issue
Block a user