fix: handle non text uploads more carefully

This commit is contained in:
Alex committed 2026-09-14 17:27:13 +01:00
1 parent 82691314ca
commit 3030aba39d
21 files changed
+729 -40

No files matched your search

+25
View File
@@ -28,6 +28,7 @@ import {
import { ActiveState, Doc } from '../models/misc';
import {
selectSelectedDocs,
selectSelectedModel,
selectSourceDocs,
selectToken,
setSelectedDocs,
@@ -47,6 +48,7 @@ import {
ToolsTrigger,
} from './message-input';
import { useArmedSend } from './message-input/armedSend';
import { cannotReadAttachment } from './message-input/attachmentReadability';
import { handleAbort } from '../conversation/conversationSlice';
import {
AUDIO_FILE_ACCEPT_ATTR,
@@ -345,6 +347,21 @@ export default function MessageInput({
const sourceDocs = useSelector(selectSourceDocs);
const token = useSelector(selectToken);
const attachments = useSelector(selectAttachments);
const selectedModel = useSelector(selectSelectedModel);
const unreadableAttachmentIds = useMemo(
() =>
new Set(
attachments
.filter((attachment) =>
cannotReadAttachment(
attachment,
selectedModel?.supported_attachment_types,
),
)
.map((attachment) => attachment.id),
),
[attachments, selectedModel],
);
const dispatch = useDispatch();
const store = useStore<RootState>();
@@ -453,6 +470,12 @@ export default function MessageInput({
...(Number.isFinite(tokenCount)
? { token_count: tokenCount }
: {}),
...(typeof payload.mime_type === 'string'
? { mimeType: payload.mime_type }
: {}),
...(typeof payload.extraction_status === 'string'
? { extractionStatus: payload.extraction_status }
: {}),
},
}),
);
@@ -1662,6 +1685,8 @@ export default function MessageInput({
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDropOn={handleDropOn}
unreadableIds={unreadableAttachmentIds}
modelName={selectedModel?.display_name}
/>
{sendArmed && sendReadiness.state === 'waiting' && (
@@ -13,6 +13,10 @@ type AttachmentChipListProps = {
onDragStart: (e: React.DragEvent, id: string) => void;
onDragOver: (e: React.DragEvent) => void;
onDropOn: (e: React.DragEvent, targetId: string) => void;
/** Completed attachments the selected model has no way to read. */
unreadableIds?: Set<string>;
/** Display name of the selected model, for the unreadable-file warning. */
modelName?: string;
};
export default function AttachmentChipList({
@@ -22,6 +26,8 @@ export default function AttachmentChipList({
onDragStart,
onDragOver,
onDropOn,
unreadableIds,
modelName,
}: AttachmentChipListProps) {
const { t } = useTranslation();
@@ -31,6 +37,11 @@ export default function AttachmentChipList({
const failures = attachments.filter(
(attachment) => attachment.status === 'failed' && attachment.errorMessage,
);
// Not a failure: the file is kept and still sends. It only warns that the
// model picked right now would receive nothing it can read.
const unreadable = modelName
? attachments.filter((attachment) => unreadableIds?.has(attachment.id))
: [];
return (
<>
@@ -141,6 +152,22 @@ export default function AttachmentChipList({
))}
</div>
)}
{unreadable.length > 0 && (
<div
className="flex flex-col gap-0.5 px-2 pb-1 text-xs text-[#B54708] sm:px-3 dark:text-[#FDB022]"
role="status"
>
{unreadable.map((attachment) => (
<span key={attachment.id}>
{t('conversation.attachments.unreadableByModel', {
name: attachment.fileName,
model: modelName,
})}
</span>
))}
</div>
)}
</>
);
}
@@ -0,0 +1,76 @@
import type { Attachment } from '../../upload/uploadSlice';
import { cannotReadAttachment } from './attachmentReadability';
const att = (over: Partial<Attachment> = {}): Attachment => ({
id: 'a1',
fileName: 'scan.pdf',
progress: 100,
status: 'completed',
taskId: 't1',
...over,
});
const PDF_AND_IMAGES = [
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
];
const IMAGES_ONLY = ['image/png', 'image/jpeg', 'image/webp'];
const TEXT_ONLY: string[] = [];
const scannedPdf = att({
mimeType: 'application/pdf',
extractionStatus: 'no_text',
token_count: 0,
});
const photo = att({
fileName: 'photo.webp',
mimeType: 'image/webp',
extractionStatus: 'ok',
token_count: 0,
});
describe('cannotReadAttachment', () => {
it('flags a scanned PDF on a text-only model', () => {
expect(cannotReadAttachment(scannedPdf, TEXT_ONLY)).toBe(true);
});
it('lets a model that reads PDFs natively take a scanned PDF', () => {
expect(cannotReadAttachment(scannedPdf, PDF_AND_IMAGES)).toBe(false);
});
it('lets an image-only model take a scanned PDF as page images', () => {
expect(cannotReadAttachment(scannedPdf, IMAGES_ONLY)).toBe(false);
});
it('flags an image on a text-only model', () => {
expect(cannotReadAttachment(photo, TEXT_ONLY)).toBe(true);
});
it('lets a vision model take the image', () => {
expect(cannotReadAttachment(photo, IMAGES_ONLY)).toBe(false);
});
it('does not flag a PDF whose text was extracted', () => {
const textPdf = att({
mimeType: 'application/pdf',
extractionStatus: 'ok',
token_count: 1200,
});
expect(cannotReadAttachment(textPdf, TEXT_ONLY)).toBe(false);
});
it('stays quiet when the model or the file type is unknown', () => {
expect(cannotReadAttachment(scannedPdf, undefined)).toBe(false);
expect(
cannotReadAttachment(att({ extractionStatus: 'no_text' }), TEXT_ONLY),
).toBe(false);
});
it('ignores attachments that have not finished processing', () => {
expect(
cannotReadAttachment({ ...scannedPdf, status: 'processing' }, TEXT_ONLY),
).toBe(false);
});
});
@@ -0,0 +1,33 @@
import type { Attachment } from '../../upload/uploadSlice';
/**
* Whether the selected model has no way to read a completed attachment.
*
* A file with extracted text reaches any model as text. A file without any
* (an image, or a scanned PDF stored with `extraction_status: 'no_text'`)
* only reaches a model that takes that format natively; a PDF also reaches a
* model that takes images, which is sent its pages as images. When the model
* or the file type is unknown, stay quiet rather than warn on a guess.
*/
export function cannotReadAttachment(
attachment: Attachment,
supportedTypes: string[] | undefined,
): boolean {
if (attachment.status !== 'completed') return false;
const mime = attachment.mimeType;
if (!mime || !supportedTypes) return false;
const isPdf = mime === 'application/pdf';
if (!isPdf && !mime.startsWith('image/')) return false;
const hasText =
attachment.extractionStatus !== 'no_text' &&
(attachment.token_count ?? 0) > 0;
if (hasText) return false;
if (supportedTypes.includes(mime)) return false;
if (isPdf && supportedTypes.some((type) => type.startsWith('image/'))) {
return false;
}
return true;
}
+2 -1
View File
@@ -1105,7 +1105,8 @@
"waitingToSend_other": "Will send when {{count}} files finish processing…",
"cancelQueuedSend": "Cancel",
"sendBlockedByFailed": "{{names}} couldn't be processed — remove to send",
"unsupportedType": "Not a supported file type ({{extension}})"
"unsupportedType": "Not a supported file type ({{extension}})",
"unreadableByModel": "{{model}} can't read {{name}} — switch to a model that supports images or PDFs"
},
"retry": "Retry",
"reasoning": "Reasoning",
+18
View File
@@ -401,6 +401,24 @@ describe('attachment race recovery', () => {
...overrides,
});
it('keeps the mime type and extraction status a completed attachment reports', () => {
let state = reducer(undefined, addAttachment(makeAttachment()));
state = reducer(
state,
sseEventReceived(
attEvent('attachment.completed', {
token_count: 0,
mime_type: 'application/pdf',
extraction_status: 'no_text',
}),
),
);
expect(state.attachments[0].status).toBe('completed');
expect(state.attachments[0].mimeType).toBe('application/pdf');
expect(state.attachments[0].extractionStatus).toBe('no_text');
});
it('drops attachment.completed silently when no row matches attachmentId', () => {
const state = reducer(
undefined,
+13
View File
@@ -46,6 +46,13 @@ export interface Attachment {
*/
attachmentId?: string;
token_count?: number;
/** Mime type the server stored (a converted TIFF/BMP reports ``image/png``). */
mimeType?: string;
/**
* ``metadata.extraction.status`` of a completed attachment: ``ok``, or
* ``no_text`` for a scanned PDF kept for models that read it natively.
*/
extractionStatus?: string;
/** Why a ``failed`` attachment failed, when known (server or client gate). */
errorMessage?: string;
}
@@ -299,6 +306,12 @@ export const uploadSlice = createSlice({
if (Number.isFinite(tokenCount)) {
attachment.token_count = tokenCount;
}
if (typeof payload.mime_type === 'string') {
attachment.mimeType = payload.mime_type;
}
if (typeof payload.extraction_status === 'string') {
attachment.extractionStatus = payload.extraction_status;
}
break;
}
case 'attachment.failed': {