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>
This commit is contained in:
Stefan Walcz authored and GitHub committed 2026-10-18 21:52:59 +02:00
1 parent d501447d73
commit 92500b4389
24 files changed
+365 -25

No files matched your search

@@ -0,0 +1,74 @@
import { test, expect } from './coverage-fixtures.js'
import { chatOf, mockChat, openChat, MODELS } from './chat-fixtures.js'
// Dictation in the composer: record from the microphone, send the recording to
// /v1/audio/transcriptions with the installed transcription model, put the text
// into the message box. The browser has no microphone here, so getUserMedia and
// MediaRecorder are replaced with stand-ins that hand back a few bytes.
const WHISPER = { id: 'whisper-1', capabilities: ['FLAG_TRANSCRIPT'] }
async function fakeMicrophone(page) {
await page.addInitScript(() => {
navigator.mediaDevices.getUserMedia = async () => ({ getTracks: () => [{ stop() { window.__micStopped = true } }] })
window.MediaRecorder = class {
constructor() { this.state = 'inactive'; this.mimeType = 'audio/webm' }
start() { this.state = 'recording' }
stop() {
this.state = 'inactive'
this.ondataavailable?.({ data: new Blob(['voice'], { type: 'audio/webm' }) })
this.onstop?.()
}
}
})
}
const input = (page) => page.getByTestId('chat-input')
test.describe('Chat dictation', () => {
test('records, transcribes with the transcription model and appends the text', async ({ page }) => {
await fakeMicrophone(page)
await mockChat(page, { models: [...MODELS, WHISPER] })
let form = ''
await page.route('**/v1/audio/transcriptions', (route) => {
form = route.request().postDataBuffer()?.toString('latin1') || ''
route.fulfill({ json: { text: ' Wie spät ist es? ' } })
})
await openChat(page, [chatOf('c1', 'Dictate', 'qwen3-8b', [])])
await input(page).fill('Frage:')
const btn = page.getByTestId('chat-dictate')
await btn.click()
await expect(btn).toHaveAttribute('data-state', 'recording')
await expect(btn).toHaveAttribute('aria-pressed', 'true')
await btn.click()
await expect(input(page)).toHaveValue('Frage: Wie spät ist es?')
await expect(btn).toHaveAttribute('data-state', 'idle')
expect(form).toContain('name="model"\r\n\r\nwhisper-1')
expect(form).toContain('filename="dictation.webm"')
expect(await page.evaluate(() => window.__micStopped)).toBe(true)
})
test('shows an error and keeps the message when transcription fails', async ({ page }) => {
await fakeMicrophone(page)
await mockChat(page, { models: [...MODELS, WHISPER] })
await page.route('**/v1/audio/transcriptions', (route) =>
route.fulfill({ status: 500, json: { error: { message: 'backend down' } } }))
await openChat(page, [chatOf('c1', 'Dictate', 'qwen3-8b', [])])
await input(page).fill('bleibt')
await page.getByTestId('chat-dictate').click()
await page.getByTestId('chat-dictate').click()
await expect(page.getByText(/Transcription failed/)).toBeVisible()
await expect(input(page)).toHaveValue('bleibt')
})
test('is not offered without a transcription model', async ({ page }) => {
await fakeMicrophone(page)
await mockChat(page)
await openChat(page, [chatOf('c1', 'Dictate', 'qwen3-8b', [])])
await expect(page.getByRole('button', { name: 'Attach audio' })).toBeVisible()
await expect(page.getByTestId('chat-dictate')).toHaveCount(0)
})
})
@@ -2,8 +2,8 @@ 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`
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 >>',
@@ -73,6 +73,42 @@ test.describe('Chat - PDF attachments', () => {
})
})
// 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')
@@ -103,7 +103,8 @@
"toasts": {
"selectModel": "Bitte wählen Sie ein Modell",
"copied": "In die Zwischenablage kopiert",
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen"
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen",
"pdfScannedAsImages": "{{name}} hat keine Textebene (Scan). {{count}} von {{total}} Seiten als Bilder für ein Bildmodell angehängt."
},
"menu": {
"trigger": "Chats",
@@ -35,7 +35,14 @@
"enterToSend": "Enter zum Senden",
"selectModelFirst": "Bitte zuerst ein Modell auswählen",
"sendMessage": "Nachricht senden",
"selectModelToast": "Bitte zuerst ein Modell auswählen"
"selectModelToast": "Bitte zuerst ein Modell auswählen",
"dictate": "Diktieren (Sprache zu Text)",
"dictateStop": "Aufnahme beenden",
"dictateBusy": "Wird umgewandelt…",
"dictateEmpty": "Keine Sprache erkannt.",
"dictateFailed": "Umwandlung fehlgeschlagen: {{error}}",
"dictateNoMic": "Mikrofon nicht verfügbar: {{error}}",
"pdfScannedAsImages": "{{name}} hat keine Textebene (Scan). {{count}} von {{total}} Seiten als Bilder für ein Bildmodell angehängt."
},
"quickLinks": {
"manageByChat": "Per Chat verwalten",
@@ -155,7 +155,8 @@
"copyFailed": "Could not copy to clipboard",
"chatCopied": "Chat copied to clipboard",
"forked": "Created a new chat",
"pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged."
"pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged.",
"pdfScannedAsImages": "{{name}} has no text layer (scan). Attached {{count}} of {{total}} pages as images for a vision model."
},
"menu": {
"trigger": "Chats",
@@ -44,7 +44,14 @@
"enterHint": "Enter to send, Shift+Enter for a new line",
"send": "Send",
"removeFile": "Remove {{name}}",
"stop": "Stop"
"stop": "Stop",
"dictate": "Dictate (speech to text)",
"dictateStop": "Stop recording",
"dictateBusy": "Transcribing…",
"dictateEmpty": "No speech recognised.",
"dictateFailed": "Transcription failed: {{error}}",
"dictateNoMic": "Microphone not available: {{error}}",
"pdfScannedAsImages": "{{name}} has no text layer (scan). Attached {{count}} of {{total}} pages as images for a vision model."
},
"quickLinks": {
"manageByChat": "Manage by chat",
@@ -103,7 +103,8 @@
"toasts": {
"selectModel": "Por favor selecciona un modelo",
"copied": "Copiado al portapapeles",
"copyFailed": "No se pudo copiar al portapapeles"
"copyFailed": "No se pudo copiar al portapapeles",
"pdfScannedAsImages": "{{name}} no tiene capa de texto (escaneo). Se adjuntaron {{count}} de {{total}} páginas como imágenes para un modelo de visión."
},
"menu": {
"trigger": "Chats",
@@ -35,7 +35,14 @@
"enterToSend": "Enter para enviar",
"selectModelFirst": "Selecciona un modelo primero",
"sendMessage": "Enviar mensaje",
"selectModelToast": "Por favor selecciona un modelo primero"
"selectModelToast": "Por favor selecciona un modelo primero",
"dictate": "Dictar (voz a texto)",
"dictateStop": "Detener grabación",
"dictateBusy": "Transcribiendo…",
"dictateEmpty": "No se reconoció ninguna voz.",
"dictateFailed": "Error en la transcripción: {{error}}",
"dictateNoMic": "Micrófono no disponible: {{error}}",
"pdfScannedAsImages": "{{name}} no tiene capa de texto (escaneo). Se adjuntaron {{count}} de {{total}} páginas como imágenes para un modelo de visión."
},
"quickLinks": {
"manageByChat": "Administrar por chat",
@@ -103,7 +103,8 @@
"toasts": {
"selectModel": "Silahkan pilih model",
"copied": "Berhasil disalin ke papan klip",
"copyFailed": "Gagal menyalin ke papan klip"
"copyFailed": "Gagal menyalin ke papan klip",
"pdfScannedAsImages": "{{name}} tidak memiliki lapisan teks (pindaian). {{count}} dari {{total}} halaman dilampirkan sebagai gambar untuk model visi."
},
"menu": {
"trigger": "Obrolan",
@@ -35,7 +35,14 @@
"enterToSend": "Enter untuk mengirim",
"selectModelFirst": "Pilih model terlebih dahulu",
"sendMessage": "Kirim pesan",
"selectModelToast": "Silahkan pilih model terlebih dahulu"
"selectModelToast": "Silahkan pilih model terlebih dahulu",
"dictate": "Dikte (suara ke teks)",
"dictateStop": "Hentikan rekaman",
"dictateBusy": "Mentranskripsi…",
"dictateEmpty": "Tidak ada ucapan yang dikenali.",
"dictateFailed": "Transkripsi gagal: {{error}}",
"dictateNoMic": "Mikrofon tidak tersedia: {{error}}",
"pdfScannedAsImages": "{{name}} tidak memiliki lapisan teks (pindaian). {{count}} dari {{total}} halaman dilampirkan sebagai gambar untuk model visi."
},
"quickLinks": {
"manageByChat": "Kelola melalui obrolan",
@@ -103,7 +103,8 @@
"toasts": {
"selectModel": "Seleziona un modello",
"copied": "Copiato negli appunti",
"copyFailed": "Impossibile copiare negli appunti"
"copyFailed": "Impossibile copiare negli appunti",
"pdfScannedAsImages": "{{name}} non ha un livello di testo (scansione). Allegate {{count}} di {{total}} pagine come immagini per un modello di visione."
},
"menu": {
"trigger": "Chat",
@@ -35,7 +35,14 @@
"enterToSend": "Invio per inviare",
"selectModelFirst": "Seleziona prima un modello",
"sendMessage": "Invia messaggio",
"selectModelToast": "Seleziona prima un modello"
"selectModelToast": "Seleziona prima un modello",
"dictate": "Detta (voce in testo)",
"dictateStop": "Interrompi registrazione",
"dictateBusy": "Trascrizione…",
"dictateEmpty": "Nessun parlato riconosciuto.",
"dictateFailed": "Trascrizione non riuscita: {{error}}",
"dictateNoMic": "Microfono non disponibile: {{error}}",
"pdfScannedAsImages": "{{name}} non ha un livello di testo (scansione). Allegate {{count}} di {{total}} pagine come immagini per un modello di visione."
},
"quickLinks": {
"manageByChat": "Gestisci via chat",
@@ -103,7 +103,8 @@
"toasts": {
"selectModel": "모델을 선택해 주세요",
"copied": "클립보드에 복사되었습니다",
"copyFailed": "클립보드에 복사할 수 없습니다"
"copyFailed": "클립보드에 복사할 수 없습니다",
"pdfScannedAsImages": "{{name}}에 텍스트 레이어가 없습니다(스캔). 비전 모델용으로 {{total}}페이지 중 {{count}}페이지를 이미지로 첨부했습니다."
},
"menu": {
"trigger": "채팅",
@@ -35,7 +35,14 @@
"enterToSend": "Enter로 전송",
"selectModelFirst": "먼저 모델을 선택하세요",
"sendMessage": "메시지 보내기",
"selectModelToast": "먼저 모델을 선택해 주세요"
"selectModelToast": "먼저 모델을 선택해 주세요",
"dictate": "받아쓰기 (음성을 텍스트로)",
"dictateStop": "녹음 중지",
"dictateBusy": "변환 중…",
"dictateEmpty": "인식된 음성이 없습니다.",
"dictateFailed": "변환 실패: {{error}}",
"dictateNoMic": "마이크를 사용할 수 없습니다: {{error}}",
"pdfScannedAsImages": "{{name}}에 텍스트 레이어가 없습니다(스캔). 비전 모델용으로 {{total}}페이지 중 {{count}}페이지를 이미지로 첨부했습니다."
},
"quickLinks": {
"manageByChat": "채팅으로 관리",
@@ -107,7 +107,8 @@
"copied": "Copiado para a área de transferência",
"copyFailed": "Não foi possível copiar para a área de transferência",
"chatCopied": "Chat copiado para a área de transferência",
"forked": "Novo chat criado"
"forked": "Novo chat criado",
"pdfScannedAsImages": "{{name}} não tem camada de texto (digitalização). {{count}} de {{total}} páginas anexadas como imagens para um modelo de visão."
},
"menu": {
"trigger": "Chats",
@@ -35,7 +35,14 @@
"enterToSend": "Enter para enviar",
"selectModelFirst": "Selecione um modelo primeiro",
"sendMessage": "Enviar mensagem",
"selectModelToast": "Selecione um modelo primeiro"
"selectModelToast": "Selecione um modelo primeiro",
"dictate": "Ditar (voz para texto)",
"dictateStop": "Parar gravação",
"dictateBusy": "Transcrevendo…",
"dictateEmpty": "Nenhuma fala reconhecida.",
"dictateFailed": "Falha na transcrição: {{error}}",
"dictateNoMic": "Microfone indisponível: {{error}}",
"pdfScannedAsImages": "{{name}} não tem camada de texto (digitalização). {{count}} de {{total}} páginas anexadas como imagens para um modelo de visão."
},
"quickLinks": {
"manageByChat": "Gerenciar por chat",
@@ -103,7 +103,8 @@
"toasts": {
"selectModel": "请选择一个模型",
"copied": "已复制到剪贴板",
"copyFailed": "无法复制到剪贴板"
"copyFailed": "无法复制到剪贴板",
"pdfScannedAsImages": "{{name}} 没有文本层(扫描件)。已将 {{total}} 页中的 {{count}} 页作为图片附加,供视觉模型使用。"
},
"menu": {
"trigger": "聊天",
@@ -35,7 +35,14 @@
"enterToSend": "按回车发送",
"selectModelFirst": "请先选择一个模型",
"sendMessage": "发送消息",
"selectModelToast": "请先选择一个模型"
"selectModelToast": "请先选择一个模型",
"dictate": "听写(语音转文字)",
"dictateStop": "停止录音",
"dictateBusy": "正在转写…",
"dictateEmpty": "未识别到语音。",
"dictateFailed": "转写失败:{{error}}",
"dictateNoMic": "麦克风不可用:{{error}}",
"pdfScannedAsImages": "{{name}} 没有文本层(扫描件)。已将 {{total}} 页中的 {{count}} 页作为图片附加,供视觉模型使用。"
},
"quickLinks": {
"manageByChat": "通过聊天管理",
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { useModels } from '../../hooks/useModels'
import { CAP_TRANSCRIPT } from '../../utils/capabilities'
import { audioApi } from '../../utils/api'
// Dictation for the composer: 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 handed to onText; the
// user reads and sends it like any typed message. Without a transcription
// model, or without a microphone API in the browser, the button is not shown.
export default function DictateButton({ onText, onError }) {
const { t } = useTranslation('home')
const { models } = useModels(CAP_TRANSCRIPT)
const [state, setState] = useState('idle') // idle | recording | transcribing
const recorder = useRef(null)
const chunks = useRef([])
const model = models[0]?.id
// Release the microphone when the composer goes away mid-recording.
useEffect(() => () => {
const r = recorder.current
if (r && r.state !== 'inactive') {
r.onstop = null
r.stop()
}
r?.stream?.getTracks().forEach(tr => tr.stop())
}, [])
const transcribe = useCallback(async (blob) => {
setState('transcribing')
try {
const fd = new FormData()
fd.append('file', blob, `dictation.${(blob.type.split('/')[1] || 'webm').split(';')[0]}`)
fd.append('model', model)
const data = await audioApi.transcribe(fd)
const text = (data?.text || '').trim()
if (text) onText(text)
else onError?.(t('input.dictateEmpty'))
} catch (err) {
onError?.(t('input.dictateFailed', { error: err?.message || String(err) }))
} finally {
setState('idle')
}
}, [model, onText, onError, t])
const toggle = useCallback(async () => {
if (state === 'recording') {
recorder.current?.stop()
return
}
if (state !== 'idle') return
let stream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
onError?.(t('input.dictateNoMic', { error: err?.message || String(err) }))
return
}
const r = new MediaRecorder(stream)
chunks.current = []
r.ondataavailable = (e) => { if (e.data?.size) chunks.current.push(e.data) }
r.onstop = () => {
stream.getTracks().forEach(tr => tr.stop())
const blob = new Blob(chunks.current, { type: r.mimeType || 'audio/webm' })
if (blob.size) transcribe(blob)
else setState('idle')
}
recorder.current = r
r.start()
setState('recording')
}, [state, transcribe, onError, t])
if (!model || typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
return null
}
const label = state === 'recording' ? t('input.dictateStop') : state === 'transcribing' ? t('input.dictateBusy') : t('input.dictate')
return (
<button
type="button"
className="home-attach-btn"
data-testid="chat-dictate"
data-state={state}
aria-pressed={state === 'recording'}
disabled={state === 'transcribing'}
onClick={toggle}
title={label}
aria-label={label}
>
<Icon name={state === 'recording' ? 'stop' : state === 'transcribing' ? 'spinner' : 'mic'} />
</button>
)
}
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { availableActions, filterActions, SLASH_GROUPS } from './homeActions'
import Icon from '../Icon'
import DictateButton from './DictateButton'
// The Enter glyph is not in the kit sprite. A return arrow in a darker cell
// teaches the shortcut next to the label.
@@ -31,6 +32,7 @@ function Marked({ text, upto }) {
// slash { actions, groups, label, desc, groupLabel } for its own menu
// streaming swaps Send for a Stop button that calls onStop
// onPaste, onKeyDownExtra (return true to take the key), fileAccept, rows,
// onDictateError (message for a failed dictation, e.g. a toast),
// testIds and class names for the pieces tests and the thread reach for
export default function HomeComposer({
message, onMessage, onSubmit, canSend, sending, sendTitle, textareaRef,
@@ -38,7 +40,7 @@ export default function HomeComposer({
placeholder, slashContext, onRunAction, slash,
streaming = false, onStop, stopTitle, onPaste, onKeyDownExtra,
fileAccept = '.txt,.md,.pdf', rows = 3, strictEnter = false, testId = 'home-composer',
textareaTestId, textareaClass = '', sendId, sendTestId, hint,
textareaTestId, textareaClass = '', sendId, sendTestId, hint, onDictateError,
}) {
const { t } = useTranslation('home')
const imageRef = useRef(null)
@@ -204,11 +206,15 @@ export default function HomeComposer({
<Icon name="image" />
</button>
<button type="button" className="home-attach-btn" onClick={() => audioRef.current?.click()} title={t('input.attachAudio')} aria-label={t('input.attachAudio')}>
<Icon name="mic" />
<Icon name="music" />
</button>
<button type="button" className="home-attach-btn" onClick={() => fileRef.current?.click()} title={t('input.attachFile')} aria-label={t('input.attachFile')}>
<Icon name="file" />
</button>
<DictateButton
onText={(text) => onMessage(message.trim() ? `${message.trimEnd()} ${text}` : text)}
onError={onDictateError}
/>
</div>
<span className="home-input-hint">{hint || t('input.enterHint')}</span>
{streaming ? (
+14 -2
View File
@@ -9,7 +9,7 @@ import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/arti
import CanvasPanel from '../components/CanvasPanel'
// eslint-disable-next-line no-unused-vars
import { fileToBase64, modelsApi, mcpApi } from '../utils/api'
import { readAttachmentText } from '../utils/pdf'
import { readAttachmentText, renderPdfPages, PdfNoTextError } from '../utils/pdf'
import { CAP_CHAT } from '../utils/capabilities'
import { useMCPClient } from '../hooks/useMCPClient'
// eslint-disable-next-line no-unused-vars
@@ -715,7 +715,18 @@ export default function Chat() {
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) {
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)
newFiles.push(...pages)
addToast(t('toasts.pdfScannedAsImages', { name: file.name, count: pages.length, total }), 'info')
continue
} catch {
// fall through to the read error below
}
}
addToast(t('toasts.pdfReadFailed', { name: file.name }), 'error')
continue
}
@@ -1217,6 +1228,7 @@ export default function Chat() {
<div className="cx-dock">
<div className="cx-dock__in">
<HomeComposer
onDictateError={(m) => addToast(m, 'error')}
message={input}
onMessage={setInput}
onSubmit={handleSend}
+14 -2
View File
@@ -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() {
<div style={staggerStyle(1)}>
<HomeComposer
onDictateError={(m) => addToast(m, 'error')}
message={message}
onMessage={setMessage}
onSubmit={doSubmit}
+39 -1
View File
@@ -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()
}
}
+4
View File
@@ -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.