addToast(m, 'error')}
message={input}
onMessage={setInput}
onSubmit={handleSend}
diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx
index 22bbcc984..f9554c9a2 100644
--- a/core/http/react-ui/src/pages/Home.jsx
+++ b/core/http/react-ui/src/pages/Home.jsx
@@ -27,7 +27,7 @@ import { useResources } from '../hooks/useResources'
import { usePolling } from '../hooks/usePolling'
import { useOperations } from '../hooks/useOperations'
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
-import { readAttachmentText } from '../utils/pdf'
+import { readAttachmentText, renderPdfPages, PdfNoTextError } from '../utils/pdf'
import { greetingKey } from '../utils/greeting'
import {
listConversations, setActiveConversation, removeConversation,
@@ -221,7 +221,18 @@ export default function Home() {
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/')) {
try {
entry.textContent = await readAttachmentText(file)
- } catch {
+ } catch (err) {
+ // A scan has no text layer: send its pages as images for a vision model.
+ if (err instanceof PdfNoTextError) {
+ try {
+ const { pages, total } = await renderPdfPages(file)
+ setImageFiles(prev => [...prev, ...pages])
+ addToast(t('input.pdfScannedAsImages', { name: file.name, count: pages.length, total }), 'info')
+ continue
+ } catch {
+ // fall through to the read error below
+ }
+ }
addToast(t('input.pdfReadFailed', { name: file.name }), 'error')
continue
}
@@ -551,6 +562,7 @@ export default function Home() {
addToast(m, 'error')}
message={message}
onMessage={setMessage}
onSubmit={doSubmit}
diff --git a/core/http/react-ui/src/utils/pdf.js b/core/http/react-ui/src/utils/pdf.js
index 2996e50db..ee47e5ddf 100644
--- a/core/http/react-ui/src/utils/pdf.js
+++ b/core/http/react-ui/src/utils/pdf.js
@@ -13,6 +13,15 @@ async function loadPdfjs() {
return pdfjs
}
+// Thrown when a PDF parses but carries no text layer (a scan). Callers can
+// fall back to renderPdfPages and send the pages as images instead.
+export class PdfNoTextError extends Error {
+ constructor() {
+ super('PDF has no extractable text')
+ this.name = 'PdfNoTextError'
+ }
+}
+
// 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.
@@ -33,7 +42,7 @@ export async function extractPdfText(file) {
pages.push(text.trim())
}
const text = pages.filter(Boolean).join('\n\n')
- if (!text) throw new Error('PDF has no extractable text')
+ if (!text) throw new PdfNoTextError()
return text
} finally {
await doc.destroy()
@@ -46,3 +55,32 @@ export async function readAttachmentText(file) {
if (isPdf(file)) return extractPdfText(file)
return file.text().catch(() => '')
}
+
+// Pages of a scanned PDF as PNG attachments ({ name, type, base64 }), so a
+// vision model can read them. Renders at most maxPages pages; `total` tells the
+// caller how many the document has. Scale 1.5 keeps small print legible at a
+// size the vision encoders take.
+export const SCANNED_PDF_MAX_PAGES = 10
+export async function renderPdfPages(file, { maxPages = SCANNED_PDF_MAX_PAGES, scale = 1.5 } = {}) {
+ const pdfjs = await loadPdfjs()
+ const data = new Uint8Array(await file.arrayBuffer())
+ const doc = await pdfjs.getDocument({ data }).promise
+ try {
+ const stem = (file.name || 'document.pdf').replace(/\.pdf$/i, '')
+ const pages = []
+ const n = Math.min(doc.numPages, maxPages)
+ for (let i = 1; i <= n; i++) {
+ const page = await doc.getPage(i)
+ const viewport = page.getViewport({ scale })
+ const canvas = document.createElement('canvas')
+ canvas.width = Math.ceil(viewport.width)
+ canvas.height = Math.ceil(viewport.height)
+ await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise
+ const url = canvas.toDataURL('image/png')
+ pages.push({ name: `${stem}-page-${i}.png`, type: 'image/png', base64: url.split(',')[1] })
+ }
+ return { pages, total: doc.numPages }
+ } finally {
+ await doc.destroy()
+ }
+}
diff --git a/docs/content/features/chat.md b/docs/content/features/chat.md
index 4b2c96515..cb34c775b 100644
--- a/docs/content/features/chat.md
+++ b/docs/content/features/chat.md
@@ -46,6 +46,10 @@ The **MCP** chip opens the server and client tool lists. **Canvas** turns code b
Enter sends, Shift+Enter adds a line. Paste an image to attach it. Under the box, the line shows the speed while a reply streams and the token count of the chat.
+The buttons under the box attach an image, an audio file or a document. A PDF is sent as its text layer. A scanned PDF has none, so its first ten pages are attached as images instead, for a model that reads images; a toast says how many pages went along.
+
+The microphone button dictates: click it to record, click again to stop, and the recording goes to `/v1/audio/transcriptions` with the first installed transcription model ([audio to text]({{< relref "audio-to-text.md" >}})). The text lands in the box, so you can read and correct it before sending. Without a transcription model the button is not shown. Recording needs microphone access in the browser, which browsers only grant on `https://` or `localhost`.
+
## Conversations
Press `Ctrl+K` (`Cmd+K`) or **Chats** to open the list of conversations, grouped by day like **Jump back in** on Home. Type to search names and message text. Arrow keys move, `Enter` opens, `F2` renames and `Delete` removes. Each row also offers rename, duplicate, copy and export. Removing a chat hides it and shows an **Undo** toast; the chat is deleted for good when the toast goes away. The name in the header can be renamed with a click.