mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(chat): enable reasoning multimodal attachments (#3458)
This commit is contained in:
@@ -22,15 +22,36 @@ export default function AttachmentPreview({
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="relative flex items-center gap-2 rounded-lg border border-stone-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 px-2 py-1.5 text-xs text-stone-700 dark:text-neutral-300 max-w-[180px]">
|
||||
<img
|
||||
src={attachment.dataUri}
|
||||
alt={attachment.file.name}
|
||||
className="w-8 h-8 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
{attachment.kind === 'image' ? (
|
||||
<img
|
||||
src={attachment.previewUri ?? attachment.dataUri}
|
||||
alt={attachment.file.name}
|
||||
className="w-8 h-8 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden
|
||||
className="w-8 h-8 rounded border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 flex items-center justify-center flex-shrink-0 text-stone-500 dark:text-neutral-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M14 2v6h6M8 13h8M8 17h5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate font-medium leading-tight">{attachment.file.name}</span>
|
||||
<span className="text-stone-400 dark:text-neutral-500 leading-tight">
|
||||
{formatFileSize(attachment.file.size)}
|
||||
{formatFileSize(attachment.payloadSizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -28,10 +28,8 @@ export interface ChatComposerProps {
|
||||
maxAttachments: number;
|
||||
allowedMimeTypes: readonly string[];
|
||||
/**
|
||||
* Whether chat image attachments are available. When `false`, the attach
|
||||
* button, hidden file input, and preview strip are not rendered, so the
|
||||
* (currently backend-unsupported) attachment flow can't be triggered.
|
||||
* Gated by `CHAT_ATTACHMENTS_ENABLED` at the call site (issue #3205).
|
||||
* Whether chat multimodal attachments are available. When `false`, the
|
||||
* attach button, hidden file input, and preview strip are not rendered.
|
||||
*/
|
||||
attachmentsEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
|
||||
const blob = new Blob([new Uint8Array(512)], { type: 'image/png' });
|
||||
return {
|
||||
id: 'att-1',
|
||||
kind: 'image',
|
||||
file: new File([blob], 'test.png', { type: 'image/png' }),
|
||||
dataUri: 'data:image/png;base64,abc',
|
||||
mimeType: 'image/png',
|
||||
originalSizeBytes: 512,
|
||||
payloadSizeBytes: 512,
|
||||
compressed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +39,21 @@ describe('AttachmentPreview', () => {
|
||||
expect(img.src).toBe('data:image/png;base64,xyz');
|
||||
});
|
||||
|
||||
it('renders a document icon for non-image files', () => {
|
||||
const file = new File([new Uint8Array(128)], 'doc.pdf', { type: 'application/pdf' });
|
||||
const att = makeAttachment({
|
||||
kind: 'file',
|
||||
file,
|
||||
dataUri: 'data:application/pdf;base64,abc',
|
||||
mimeType: 'application/pdf',
|
||||
originalSizeBytes: 128,
|
||||
payloadSizeBytes: 128,
|
||||
});
|
||||
render(<AttachmentPreview attachments={[att]} onRemove={vi.fn()} />);
|
||||
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByAltText('doc.pdf')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRemove with the attachment id when × is clicked', () => {
|
||||
const onRemove = vi.fn();
|
||||
const att = makeAttachment({ id: 'att-42' });
|
||||
|
||||
@@ -12,9 +12,13 @@ function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
|
||||
const blob = new Blob([new Uint8Array(256)], { type: 'image/png' });
|
||||
return {
|
||||
id: 'att-1',
|
||||
kind: 'image',
|
||||
file: new File([blob], 'photo.png', { type: 'image/png' }),
|
||||
dataUri: 'data:image/png;base64,abc',
|
||||
mimeType: 'image/png',
|
||||
originalSizeBytes: 256,
|
||||
payloadSizeBytes: 256,
|
||||
compressed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ALLOWED_ATTACHMENT_MIME_TYPES,
|
||||
type Attachment,
|
||||
ATTACHMENT_MAX_FILE_SIZE_BYTES,
|
||||
ATTACHMENT_MAX_IMAGES,
|
||||
ATTACHMENT_MAX_SIZE_BYTES,
|
||||
buildMessageWithAttachments,
|
||||
@@ -20,9 +22,13 @@ function makeFile(name: string, type: string, size = 1024): File {
|
||||
function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
|
||||
return {
|
||||
id: 'test-id',
|
||||
kind: 'image',
|
||||
file: makeFile('test.png', 'image/png'),
|
||||
dataUri: 'data:image/png;base64,abc',
|
||||
mimeType: 'image/png',
|
||||
originalSizeBytes: 1024,
|
||||
payloadSizeBytes: 1024,
|
||||
compressed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -44,6 +50,14 @@ describe('isAllowedMimeType', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowed attachment MIME types', () => {
|
||||
it('includes images and supported document files', () => {
|
||||
expect(ALLOWED_ATTACHMENT_MIME_TYPES).toContain('image/png');
|
||||
expect(ALLOWED_ATTACHMENT_MIME_TYPES).toContain('application/pdf');
|
||||
expect(ALLOWED_ATTACHMENT_MIME_TYPES).toContain('text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileToDataUri', () => {
|
||||
it('reads a file as a data URI', async () => {
|
||||
const file = makeFile('photo.png', 'image/png', 4);
|
||||
@@ -63,7 +77,7 @@ describe('validateAndReadFile', () => {
|
||||
});
|
||||
|
||||
it('rejects unsupported MIME types', async () => {
|
||||
const file = makeFile('doc.pdf', 'application/pdf');
|
||||
const file = makeFile('vector.svg', 'image/svg+xml');
|
||||
const result = await validateAndReadFile(file, 0);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
@@ -71,6 +85,19 @@ describe('validateAndReadFile', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects files that exceed the file size limit', async () => {
|
||||
const oversizedFile = makeFile(
|
||||
'big.pdf',
|
||||
'application/pdf',
|
||||
ATTACHMENT_MAX_FILE_SIZE_BYTES + 1
|
||||
);
|
||||
const result = await validateAndReadFile(oversizedFile, 0);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
expect(result.error.code).toBe('too_large');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects files that exceed the size limit', async () => {
|
||||
const oversizedFile = makeFile('big.png', 'image/png', ATTACHMENT_MAX_SIZE_BYTES + 1);
|
||||
const result = await validateAndReadFile(oversizedFile, 0);
|
||||
@@ -86,8 +113,21 @@ describe('validateAndReadFile', () => {
|
||||
expect('attachment' in result).toBe(true);
|
||||
if ('attachment' in result) {
|
||||
expect(result.attachment.mimeType).toBe('image/png');
|
||||
expect(result.attachment.kind).toBe('image');
|
||||
expect(result.attachment.dataUri).toMatch(/^data:image\/png;base64,/);
|
||||
expect(result.attachment.file).toBe(file);
|
||||
expect(result.attachment.compressed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an attachment for a supported file', async () => {
|
||||
const file = makeFile('doc.pdf', 'application/pdf', 512);
|
||||
const result = await validateAndReadFile(file, 0);
|
||||
expect('attachment' in result).toBe(true);
|
||||
if ('attachment' in result) {
|
||||
expect(result.attachment.mimeType).toBe('application/pdf');
|
||||
expect(result.attachment.kind).toBe('file');
|
||||
expect(result.attachment.dataUri).toMatch(/^data:application\/pdf;base64,/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -138,6 +178,17 @@ describe('buildMessageWithAttachments', () => {
|
||||
expect(result).toBe('look [IMAGE:data:image/png;base64,a] [IMAGE:data:image/jpeg;base64,b]');
|
||||
});
|
||||
|
||||
it('emits FILE markers for non-image attachments', () => {
|
||||
const a = makeAttachment({
|
||||
kind: 'file',
|
||||
file: makeFile('doc.pdf', 'application/pdf'),
|
||||
dataUri: 'data:application/pdf;base64,abc',
|
||||
mimeType: 'application/pdf',
|
||||
});
|
||||
const result = buildMessageWithAttachments('read this', [a]);
|
||||
expect(result).toBe('read this [FILE:data:application/pdf;base64,abc]');
|
||||
});
|
||||
|
||||
it('trims leading/trailing whitespace from text', () => {
|
||||
const a = makeAttachment({ dataUri: 'data:image/png;base64,x' });
|
||||
const result = buildMessageWithAttachments(' hi ', [a]);
|
||||
|
||||
+137
-26
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Utilities for multimodal chat attachments.
|
||||
*
|
||||
* Images are embedded in the message text as `[IMAGE:<data-uri>]` markers,
|
||||
* which the Rust agent harness (`agent/multimodal.rs`) parses and forwards to
|
||||
* the inference provider. The backend supports up to 4 images per message at
|
||||
* 8 MB each by default (governed by `MultimodalConfig`).
|
||||
* Images are embedded as `[IMAGE:<data-uri>]` markers. Other supported files
|
||||
* are embedded as `[FILE:<data-uri>]` markers. The Rust agent harness
|
||||
* (`agent/multimodal.rs`) parses, validates, and expands both shapes before
|
||||
* the provider call.
|
||||
*/
|
||||
import debugFactory from 'debug';
|
||||
|
||||
export const ATTACHMENT_MAX_IMAGES = 4;
|
||||
export const ATTACHMENT_MAX_SIZE_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
const debug = debugFactory('chat:attachments');
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
'image/png',
|
||||
@@ -20,58 +20,167 @@ export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
|
||||
export type AllowedImageMimeType = (typeof ALLOWED_IMAGE_MIME_TYPES)[number];
|
||||
|
||||
export const ALLOWED_FILE_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'text/markdown',
|
||||
'application/zip',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/octet-stream',
|
||||
] as const;
|
||||
|
||||
export type AllowedFileMimeType = (typeof ALLOWED_FILE_MIME_TYPES)[number];
|
||||
export type AllowedAttachmentMimeType = AllowedImageMimeType | AllowedFileMimeType;
|
||||
export type AttachmentKind = 'image' | 'file';
|
||||
|
||||
export const ALLOWED_ATTACHMENT_MIME_TYPES = [
|
||||
...ALLOWED_IMAGE_MIME_TYPES,
|
||||
...ALLOWED_FILE_MIME_TYPES,
|
||||
] as const;
|
||||
|
||||
export const ATTACHMENT_MAX_IMAGES = 4;
|
||||
export const ATTACHMENT_MAX_FILES = 4;
|
||||
export const ATTACHMENT_MAX_IMAGE_SIZE_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
export const ATTACHMENT_MAX_FILE_SIZE_BYTES = 16 * 1024 * 1024; // 16 MB
|
||||
export const ATTACHMENT_MAX_SIZE_BYTES = ATTACHMENT_MAX_IMAGE_SIZE_BYTES;
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
kind: AttachmentKind;
|
||||
file: File;
|
||||
dataUri: string;
|
||||
mimeType: AllowedImageMimeType;
|
||||
previewUri?: string;
|
||||
mimeType: AllowedAttachmentMimeType;
|
||||
originalSizeBytes: number;
|
||||
payloadSizeBytes: number;
|
||||
compressed: boolean;
|
||||
}
|
||||
|
||||
export type AttachmentError =
|
||||
| { code: 'unsupported_type'; mimeType: string }
|
||||
| { code: 'too_large'; sizeBytes: number; maxBytes: number }
|
||||
| { code: 'too_many'; max: number }
|
||||
| { code: 'too_many'; kind: AttachmentKind; max: number }
|
||||
| { code: 'read_failed'; reason: string };
|
||||
|
||||
export function isAllowedMimeType(mime: string): mime is AllowedImageMimeType {
|
||||
return (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(mime);
|
||||
}
|
||||
|
||||
export function fileToDataUri(file: File): Promise<string> {
|
||||
export function isAllowedAttachmentMimeType(mime: string): mime is AllowedAttachmentMimeType {
|
||||
return (ALLOWED_ATTACHMENT_MIME_TYPES as readonly string[]).includes(mime);
|
||||
}
|
||||
|
||||
export function attachmentKindForMime(mime: AllowedAttachmentMimeType): AttachmentKind {
|
||||
return isAllowedMimeType(mime) ? 'image' : 'file';
|
||||
}
|
||||
|
||||
export function fileToDataUri(file: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
const name = file instanceof File ? file.name : 'blob';
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
|
||||
reader.onerror = () => reject(new Error(`Failed to read file: ${name}`));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateAndReadFile(
|
||||
async function blobToDataUri(blob: Blob, mimeType: string): Promise<string> {
|
||||
const namedBlob = new Blob([blob], { type: mimeType });
|
||||
return fileToDataUri(namedBlob);
|
||||
}
|
||||
|
||||
async function gzipBlob(file: File): Promise<Blob | null> {
|
||||
if (!('CompressionStream' in globalThis)) return null;
|
||||
|
||||
try {
|
||||
const compressionStream = new CompressionStream('gzip');
|
||||
const compressed = file.stream().pipeThrough(compressionStream);
|
||||
return await new Response(compressed).blob();
|
||||
} catch (error) {
|
||||
debug('[chat:attachments] gzip_failed name=%s error=%o', file.name, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeDataUriParam(value: string): string {
|
||||
return encodeURIComponent(value).replace(/'/g, '%27');
|
||||
}
|
||||
|
||||
async function buildAttachmentDataUri(
|
||||
file: File,
|
||||
existingCount: number
|
||||
): Promise<{ attachment: Attachment } | { error: AttachmentError }> {
|
||||
if (existingCount >= ATTACHMENT_MAX_IMAGES) {
|
||||
return { error: { code: 'too_many', max: ATTACHMENT_MAX_IMAGES } };
|
||||
mimeType: AllowedAttachmentMimeType
|
||||
): Promise<{ dataUri: string; payloadSizeBytes: number; compressed: boolean }> {
|
||||
debug(
|
||||
'[chat:attachments] compression:start name=%s mime=%s size=%d',
|
||||
file.name,
|
||||
mimeType,
|
||||
file.size
|
||||
);
|
||||
|
||||
const compressed = await gzipBlob(file);
|
||||
if (compressed && compressed.size < file.size) {
|
||||
const dataUri = await blobToDataUri(
|
||||
compressed,
|
||||
`application/gzip;original_mime=${encodeDataUriParam(mimeType)};name=${encodeDataUriParam(file.name)}`
|
||||
);
|
||||
debug(
|
||||
'[chat:attachments] compression:ok name=%s original=%d compressed=%d',
|
||||
file.name,
|
||||
file.size,
|
||||
compressed.size
|
||||
);
|
||||
return { dataUri, payloadSizeBytes: compressed.size, compressed: true };
|
||||
}
|
||||
|
||||
if (!isAllowedMimeType(file.type)) {
|
||||
const dataUri = await fileToDataUri(file);
|
||||
debug(
|
||||
'[chat:attachments] compression:skipped name=%s original=%d compressed=%s',
|
||||
file.name,
|
||||
file.size,
|
||||
compressed?.size ?? 'unavailable'
|
||||
);
|
||||
return { dataUri, payloadSizeBytes: file.size, compressed: false };
|
||||
}
|
||||
|
||||
export async function validateAndReadFile(
|
||||
file: File,
|
||||
existingCount: number,
|
||||
existingFileCount = 0
|
||||
): Promise<{ attachment: Attachment } | { error: AttachmentError }> {
|
||||
if (!isAllowedAttachmentMimeType(file.type)) {
|
||||
return { error: { code: 'unsupported_type', mimeType: file.type || 'unknown' } };
|
||||
}
|
||||
|
||||
if (file.size > ATTACHMENT_MAX_SIZE_BYTES) {
|
||||
return {
|
||||
error: { code: 'too_large', sizeBytes: file.size, maxBytes: ATTACHMENT_MAX_SIZE_BYTES },
|
||||
};
|
||||
const kind = attachmentKindForMime(file.type);
|
||||
const maxCount = kind === 'image' ? ATTACHMENT_MAX_IMAGES : ATTACHMENT_MAX_FILES;
|
||||
const count = kind === 'image' ? existingCount : existingFileCount;
|
||||
if (count >= maxCount) {
|
||||
return { error: { code: 'too_many', kind, max: maxCount } };
|
||||
}
|
||||
|
||||
const maxBytes =
|
||||
kind === 'image' ? ATTACHMENT_MAX_IMAGE_SIZE_BYTES : ATTACHMENT_MAX_FILE_SIZE_BYTES;
|
||||
if (file.size > maxBytes) {
|
||||
return { error: { code: 'too_large', sizeBytes: file.size, maxBytes } };
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUri = await fileToDataUri(file);
|
||||
const { dataUri, payloadSizeBytes, compressed } = await buildAttachmentDataUri(file, file.type);
|
||||
const previewUri = kind === 'image' ? await fileToDataUri(file) : undefined;
|
||||
return {
|
||||
attachment: {
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
kind,
|
||||
file,
|
||||
dataUri,
|
||||
mimeType: file.type as AllowedImageMimeType,
|
||||
previewUri,
|
||||
mimeType: file.type,
|
||||
originalSizeBytes: file.size,
|
||||
payloadSizeBytes,
|
||||
compressed,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -83,13 +192,15 @@ export async function validateAndReadFile(
|
||||
|
||||
/**
|
||||
* Compose the final message string by appending `[IMAGE:<data-uri>]` markers
|
||||
* for each attachment after the user's text. The Rust backend parses these
|
||||
* markers in `parse_image_markers` and strips them from the visible message
|
||||
* before forwarding clean text + image payload to the inference provider.
|
||||
* for image attachments and `[FILE:<data-uri>]` markers for other supported
|
||||
* files after the user's text. The Rust agent harness parses and strips these
|
||||
* markers before forwarding clean text and attachment payloads to the provider.
|
||||
*/
|
||||
export function buildMessageWithAttachments(text: string, attachments: Attachment[]): string {
|
||||
if (attachments.length === 0) return text;
|
||||
const markers = attachments.map(a => `[IMAGE:${a.dataUri}]`).join(' ');
|
||||
const markers = attachments
|
||||
.map(a => (a.kind === 'image' ? `[IMAGE:${a.dataUri}]` : `[FILE:${a.dataUri}]`))
|
||||
.join(' ');
|
||||
return text.trim() ? `${text.trim()} ${markers}` : markers;
|
||||
}
|
||||
|
||||
|
||||
@@ -1795,6 +1795,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'إرفاق صورة',
|
||||
'chat.attachment.remove': 'إزالة {name}',
|
||||
'chat.attachment.tooMany': 'الحد الأقصى {max} صور لكل رسالة',
|
||||
'chat.attachment.tooManyFiles': 'الحد الأقصى {max} ملفات لكل رسالة',
|
||||
'chat.attachment.tooLarge': 'حجم الصورة يتجاوز الحد المسموح {max}',
|
||||
'chat.attachment.unsupportedType': 'نوع ملف غير مدعوم. استخدم PNG أو JPEG أو WebP أو GIF أو BMP.',
|
||||
'chat.attachment.readFailed': 'تعذر قراءة الملف',
|
||||
|
||||
@@ -1836,6 +1836,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'ছবি সংযুক্ত করুন',
|
||||
'chat.attachment.remove': '{name} সরান',
|
||||
'chat.attachment.tooMany': 'প্রতি বার্তায় সর্বোচ্চ {max}টি ছবি',
|
||||
'chat.attachment.tooManyFiles': 'প্রতি বার্তায় সর্বোচ্চ {max}টি ফাইল',
|
||||
'chat.attachment.tooLarge': 'ছবি {max} আকারের সীমা অতিক্রম করেছে',
|
||||
'chat.attachment.unsupportedType':
|
||||
'অসমর্থিত ফাইল প্রকার। PNG, JPEG, WebP, GIF, বা BMP ব্যবহার করুন।',
|
||||
|
||||
@@ -1883,6 +1883,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'Bild anhängen',
|
||||
'chat.attachment.remove': '{name} entfernen',
|
||||
'chat.attachment.tooMany': 'Maximal {max} Bilder pro Nachricht',
|
||||
'chat.attachment.tooManyFiles': 'Maximal {max} Dateien pro Nachricht',
|
||||
'chat.attachment.tooLarge': 'Bild überschreitet die Größenbeschränkung von {max}',
|
||||
'chat.attachment.unsupportedType':
|
||||
'Nicht unterstützter Dateityp. Verwenden Sie PNG, JPEG, WebP, GIF oder BMP.',
|
||||
|
||||
@@ -2210,6 +2210,7 @@ const en: TranslationMap = {
|
||||
'chat.attachment.attach': 'Attach image',
|
||||
'chat.attachment.remove': 'Remove {name}',
|
||||
'chat.attachment.tooMany': 'Maximum {max} images per message',
|
||||
'chat.attachment.tooManyFiles': 'Maximum {max} files per message',
|
||||
'chat.attachment.tooLarge': 'Image exceeds {max} size limit',
|
||||
'chat.attachment.unsupportedType': 'Unsupported file type. Use PNG, JPEG, WebP, GIF, or BMP.',
|
||||
'chat.attachment.readFailed': 'Could not read file',
|
||||
|
||||
@@ -1875,6 +1875,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'Adjuntar imagen',
|
||||
'chat.attachment.remove': 'Eliminar {name}',
|
||||
'chat.attachment.tooMany': 'Máximo {max} imágenes por mensaje',
|
||||
'chat.attachment.tooManyFiles': 'Máximo {max} archivos por mensaje',
|
||||
'chat.attachment.tooLarge': 'La imagen supera el límite de tamaño de {max}',
|
||||
'chat.attachment.unsupportedType':
|
||||
'Tipo de archivo no compatible. Use PNG, JPEG, WebP, GIF o BMP.',
|
||||
|
||||
@@ -1883,6 +1883,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'Joindre une image',
|
||||
'chat.attachment.remove': 'Supprimer {name}',
|
||||
'chat.attachment.tooMany': 'Maximum {max} images par message',
|
||||
'chat.attachment.tooManyFiles': 'Maximum {max} fichiers par message',
|
||||
'chat.attachment.tooLarge': "L'image dépasse la taille limite de {max}",
|
||||
'chat.attachment.unsupportedType':
|
||||
'Type de fichier non pris en charge. Utilisez PNG, JPEG, WebP, GIF ou BMP.',
|
||||
|
||||
@@ -1835,6 +1835,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'छवि संलग्न करें',
|
||||
'chat.attachment.remove': '{name} हटाएं',
|
||||
'chat.attachment.tooMany': 'प्रति संदेश अधिकतम {max} छवियां',
|
||||
'chat.attachment.tooManyFiles': 'प्रति संदेश अधिकतम {max} फ़ाइलें',
|
||||
'chat.attachment.tooLarge': 'छवि {max} आकार सीमा से अधिक है',
|
||||
'chat.attachment.unsupportedType':
|
||||
'असमर्थित फ़ाइल प्रकार। PNG, JPEG, WebP, GIF, या BMP का उपयोग करें।',
|
||||
|
||||
@@ -1838,6 +1838,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'Lampirkan gambar',
|
||||
'chat.attachment.remove': 'Hapus {name}',
|
||||
'chat.attachment.tooMany': 'Maksimal {max} gambar per pesan',
|
||||
'chat.attachment.tooManyFiles': 'Maksimal {max} file per pesan',
|
||||
'chat.attachment.tooLarge': 'Gambar melebihi batas ukuran {max}',
|
||||
'chat.attachment.unsupportedType':
|
||||
'Jenis file tidak didukung. Gunakan PNG, JPEG, WebP, GIF, atau BMP.',
|
||||
|
||||
@@ -1867,6 +1867,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'Allega immagine',
|
||||
'chat.attachment.remove': 'Rimuovi {name}',
|
||||
'chat.attachment.tooMany': 'Massimo {max} immagini per messaggio',
|
||||
'chat.attachment.tooManyFiles': 'Massimo {max} file per messaggio',
|
||||
'chat.attachment.tooLarge': "L'immagine supera il limite di dimensione di {max}",
|
||||
'chat.attachment.unsupportedType': 'Tipo di file non supportato. Usa PNG, JPEG, WebP, GIF o BMP.',
|
||||
'chat.attachment.readFailed': 'Impossibile leggere il file',
|
||||
|
||||
@@ -1815,6 +1815,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': '이미지 첨부',
|
||||
'chat.attachment.remove': '{name} 제거',
|
||||
'chat.attachment.tooMany': '메시지당 최대 {max}개 이미지',
|
||||
'chat.attachment.tooManyFiles': '메시지당 최대 {max}개 파일',
|
||||
'chat.attachment.tooLarge': '이미지가 {max} 크기 제한을 초과합니다',
|
||||
'chat.attachment.unsupportedType':
|
||||
'지원되지 않는 파일 형식입니다. PNG, JPEG, WebP, GIF 또는 BMP를 사용하세요.',
|
||||
|
||||
@@ -1856,6 +1856,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'Dołącz obraz',
|
||||
'chat.attachment.remove': 'Usuń {name}',
|
||||
'chat.attachment.tooMany': 'Maksymalnie {max} obrazów na wiadomość',
|
||||
'chat.attachment.tooManyFiles': 'Maksymalnie {max} plików na wiadomość',
|
||||
'chat.attachment.tooLarge': 'Obraz przekracza limit rozmiaru {max}',
|
||||
'chat.attachment.unsupportedType': 'Nieobsługiwany typ pliku. Użyj PNG, JPEG, WebP, GIF lub BMP.',
|
||||
'chat.attachment.readFailed': 'Nie można odczytać pliku',
|
||||
|
||||
@@ -1874,6 +1874,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'Anexar imagem',
|
||||
'chat.attachment.remove': 'Remover {name}',
|
||||
'chat.attachment.tooMany': 'Máximo de {max} imagens por mensagem',
|
||||
'chat.attachment.tooManyFiles': 'Máximo de {max} arquivos por mensagem',
|
||||
'chat.attachment.tooLarge': 'A imagem excede o limite de tamanho de {max}',
|
||||
'chat.attachment.unsupportedType':
|
||||
'Tipo de arquivo não suportado. Use PNG, JPEG, WebP, GIF ou BMP.',
|
||||
|
||||
@@ -1850,6 +1850,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': 'Прикрепить изображение',
|
||||
'chat.attachment.remove': 'Удалить {name}',
|
||||
'chat.attachment.tooMany': 'Максимум {max} изображений на сообщение',
|
||||
'chat.attachment.tooManyFiles': 'Максимум {max} файлов на сообщение',
|
||||
'chat.attachment.tooLarge': 'Изображение превышает ограничение размера {max}',
|
||||
'chat.attachment.unsupportedType':
|
||||
'Неподдерживаемый тип файла. Используйте PNG, JPEG, WebP, GIF или BMP.',
|
||||
|
||||
@@ -1733,6 +1733,7 @@ const messages: TranslationMap = {
|
||||
'chat.attachment.attach': '添加图片',
|
||||
'chat.attachment.remove': '移除 {name}',
|
||||
'chat.attachment.tooMany': '每条消息最多 {max} 张图片',
|
||||
'chat.attachment.tooManyFiles': '每条消息最多 {max} 个文件',
|
||||
'chat.attachment.tooLarge': '图片超过 {max} 大小限制',
|
||||
'chat.attachment.unsupportedType': '不支持的文件类型。请使用 PNG、JPEG、WebP、GIF 或 BMP。',
|
||||
'chat.attachment.readFailed': '无法读取文件',
|
||||
|
||||
@@ -17,10 +17,10 @@ import MicComposer from '../features/human/MicComposer';
|
||||
import { useStickToBottom } from '../hooks/useStickToBottom';
|
||||
import { useUsageState } from '../hooks/useUsageState';
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
ALLOWED_ATTACHMENT_MIME_TYPES,
|
||||
type Attachment,
|
||||
ATTACHMENT_MAX_FILES,
|
||||
ATTACHMENT_MAX_IMAGES,
|
||||
ATTACHMENT_MAX_SIZE_BYTES,
|
||||
buildMessageWithAttachments,
|
||||
parseMessageImages,
|
||||
validateAndReadFile,
|
||||
@@ -107,9 +107,8 @@ import {
|
||||
TASKS_TAB_VALUE,
|
||||
} from './conversations/utils/threadFilter';
|
||||
|
||||
// Chat uses the reasoning model; `agentic-v1` is reserved for sub-agents
|
||||
// that execute tool calls, not the primary user-facing conversation.
|
||||
const CHAT_MODEL_ID = 'reasoning-v1';
|
||||
const CHAT_MODEL_HINT = 'hint:chat';
|
||||
const MULTIMODAL_MODEL_HINT = 'hint:reasoning';
|
||||
/** Maximum trailing characters rendered in the live-streaming assistant
|
||||
* preview bubble. The full response is revealed via `addInferenceResponse`
|
||||
* on `chat_done` — this is purely a ticker-tape affordance to signal
|
||||
@@ -277,7 +276,10 @@ const Conversations = ({
|
||||
(async () => {
|
||||
try {
|
||||
const profile = agentProfiles.find(p => p.id === selectedAgentProfileId);
|
||||
const hint = profile?.modelOverride ?? 'hint:chat';
|
||||
const hint =
|
||||
attachments.length > 0
|
||||
? MULTIMODAL_MODEL_HINT
|
||||
: (profile?.modelOverride ?? CHAT_MODEL_HINT);
|
||||
const res = await callCoreRpc<{ model: string }>({
|
||||
method: 'openhuman.inference_resolve_model',
|
||||
params: { hint },
|
||||
@@ -290,7 +292,7 @@ const Conversations = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentProfiles, selectedAgentProfileId]);
|
||||
}, [agentProfiles, attachments.length, selectedAgentProfileId]);
|
||||
|
||||
const textInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingTextRef = useRef(false);
|
||||
@@ -675,20 +677,20 @@ const Conversations = ({
|
||||
|
||||
const handleAttachFiles = async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
let acceptedCount = attachments.length;
|
||||
let acceptedImageCount = attachments.filter(attachment => attachment.kind === 'image').length;
|
||||
let acceptedFileCount = attachments.filter(attachment => attachment.kind === 'file').length;
|
||||
for (const file of Array.from(files)) {
|
||||
const result = await validateAndReadFile(file, acceptedCount);
|
||||
const result = await validateAndReadFile(file, acceptedImageCount, acceptedFileCount);
|
||||
if ('error' in result) {
|
||||
const { error } = result;
|
||||
if (error.code === 'too_many') {
|
||||
const key =
|
||||
error.kind === 'image' ? 'chat.attachment.tooMany' : 'chat.attachment.tooManyFiles';
|
||||
setAttachError(
|
||||
chatSendError(
|
||||
'attachment_invalid',
|
||||
t('chat.attachment.tooMany').replace('{max}', String(ATTACHMENT_MAX_IMAGES))
|
||||
)
|
||||
chatSendError('attachment_invalid', t(key).replace('{max}', String(error.max)))
|
||||
);
|
||||
} else if (error.code === 'too_large') {
|
||||
const maxMb = (ATTACHMENT_MAX_SIZE_BYTES / (1024 * 1024)).toFixed(0);
|
||||
const maxMb = (error.maxBytes / (1024 * 1024)).toFixed(0);
|
||||
setAttachError(
|
||||
chatSendError(
|
||||
'attachment_invalid',
|
||||
@@ -702,7 +704,15 @@ const Conversations = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
acceptedCount++;
|
||||
if (result.attachment.kind === 'image') {
|
||||
acceptedImageCount++;
|
||||
} else {
|
||||
acceptedFileCount++;
|
||||
}
|
||||
if (selectedAgentProfileId !== 'reasoning') {
|
||||
debug('attachment accepted; switching chat profile to reasoning for multimodal send');
|
||||
void handleSelectAgentProfile('reasoning');
|
||||
}
|
||||
setAttachments(prev => [...prev, result.attachment]);
|
||||
}
|
||||
};
|
||||
@@ -755,6 +765,11 @@ const Conversations = ({
|
||||
pendingSendRef.current = sendingThreadId;
|
||||
setPendingSendingThreadId(sendingThreadId);
|
||||
const pendingAttachments = attachments.slice();
|
||||
const modelOverride =
|
||||
pendingAttachments.length > 0
|
||||
? MULTIMODAL_MODEL_HINT
|
||||
: (agentProfiles.find(p => p.id === selectedAgentProfileId)?.modelOverride ??
|
||||
CHAT_MODEL_HINT);
|
||||
const messageText = buildMessageWithAttachments(trimmed, pendingAttachments);
|
||||
const userMessage: ThreadMessage = {
|
||||
id: `msg_${globalThis.crypto.randomUUID()}`,
|
||||
@@ -765,7 +780,11 @@ const Conversations = ({
|
||||
? {
|
||||
attachmentCount: pendingAttachments.length,
|
||||
attachmentNames: pendingAttachments.map(a => a.file.name),
|
||||
attachmentDataUris: pendingAttachments.map(a => a.dataUri),
|
||||
attachmentKinds: pendingAttachments.map(a => a.kind),
|
||||
attachmentDataUris: pendingAttachments
|
||||
.filter(a => a.kind === 'image')
|
||||
.map(a => a.previewUri ?? a.dataUri),
|
||||
attachmentCompressed: pendingAttachments.map(a => a.compressed),
|
||||
}
|
||||
: {},
|
||||
sender: 'user',
|
||||
@@ -818,7 +837,7 @@ const Conversations = ({
|
||||
await chatSend({
|
||||
threadId: sendingThreadId,
|
||||
message: messageText,
|
||||
model: CHAT_MODEL_ID,
|
||||
model: modelOverride,
|
||||
profileId: selectedAgentProfileId,
|
||||
locale: uiLocale,
|
||||
});
|
||||
@@ -2193,8 +2212,8 @@ const Conversations = ({
|
||||
handleInputKeyDown={handleInputKeyDown}
|
||||
inlineCompletionSuffix={inlineCompletionSuffix}
|
||||
isComposingTextRef={isComposingTextRef}
|
||||
maxAttachments={ATTACHMENT_MAX_IMAGES}
|
||||
allowedMimeTypes={ALLOWED_IMAGE_MIME_TYPES}
|
||||
maxAttachments={ATTACHMENT_MAX_IMAGES + ATTACHMENT_MAX_FILES}
|
||||
allowedMimeTypes={ALLOWED_ATTACHMENT_MIME_TYPES}
|
||||
attachmentsEnabled={CHAT_ATTACHMENTS_ENABLED}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -17,23 +17,46 @@ import type { Thread } from '../../types/thread';
|
||||
|
||||
// ── Hoisted mock state ──────────────────────────────────────────────────────
|
||||
|
||||
const { mockGetThreads, mockGetThreadMessages, mockUseUsageState } = vi.hoisted(() => ({
|
||||
mockGetThreads: vi.fn().mockResolvedValue({ threads: [], count: 0 }),
|
||||
mockGetThreadMessages: vi.fn().mockResolvedValue({ messages: [], count: 0 }),
|
||||
mockUseUsageState: vi.fn(() => ({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct: 0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
const { mockGetThreads, mockGetThreadMessages, mockSelectAgentProfile, mockUseUsageState } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetThreads: vi.fn().mockResolvedValue({ threads: [], count: 0 }),
|
||||
mockGetThreadMessages: vi.fn().mockResolvedValue({ messages: [], count: 0 }),
|
||||
mockSelectAgentProfile: vi.fn().mockImplementation((profileId: string) =>
|
||||
Promise.resolve({
|
||||
activeProfileId: profileId,
|
||||
profiles: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
description: 'Default',
|
||||
agentId: 'orchestrator',
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: 'reasoning',
|
||||
name: 'Reasoning',
|
||||
description: 'Reasoning',
|
||||
agentId: 'orchestrator',
|
||||
modelOverride: 'hint:reasoning',
|
||||
builtIn: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
mockUseUsageState: vi.fn(() => ({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct: 0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// ── Module mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,21 +92,27 @@ vi.mock('../../services/api/threadApi', () => ({
|
||||
|
||||
vi.mock('../../services/api/agentProfilesApi', () => ({
|
||||
agentProfilesApi: {
|
||||
list: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
activeProfileId: 'default',
|
||||
profiles: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
description: 'Default',
|
||||
agentId: 'orchestrator',
|
||||
builtIn: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
select: vi.fn().mockResolvedValue({ activeProfileId: 'default', profiles: [] }),
|
||||
list: vi.fn().mockResolvedValue({
|
||||
activeProfileId: 'default',
|
||||
profiles: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
description: 'Default',
|
||||
agentId: 'orchestrator',
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: 'reasoning',
|
||||
name: 'Reasoning',
|
||||
description: 'Reasoning',
|
||||
agentId: 'orchestrator',
|
||||
modelOverride: 'hint:reasoning',
|
||||
builtIn: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
select: mockSelectAgentProfile,
|
||||
upsert: vi.fn().mockResolvedValue({ activeProfileId: 'default', profiles: [] }),
|
||||
delete: vi.fn().mockResolvedValue({ activeProfileId: 'default', profiles: [] }),
|
||||
},
|
||||
@@ -91,9 +120,6 @@ vi.mock('../../services/api/agentProfilesApi', () => ({
|
||||
|
||||
vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));
|
||||
|
||||
// Attachments are gated off by default (CHAT_ATTACHMENTS_ENABLED, #3205); force
|
||||
// the flag on so these tests still exercise the underlying attachment pipeline
|
||||
// (validation, preview, attachment-only send) that ships behind the flag.
|
||||
vi.mock('../../utils/config', async importActual => ({
|
||||
...(await importActual<typeof import('../../utils/config')>()),
|
||||
CHAT_ATTACHMENTS_ENABLED: true,
|
||||
@@ -264,7 +290,22 @@ describe('Conversations — attachment feature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows unsupported type error for non-image files', async () => {
|
||||
it('shows unsupported type error for unsupported files', async () => {
|
||||
await renderWithSelectedThread();
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = makeFile('vector.svg', 'image/svg+xml', 512);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unsupported file type/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows attachment chip after selecting a supported document file', async () => {
|
||||
await renderWithSelectedThread();
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
@@ -275,7 +316,7 @@ describe('Conversations — attachment feature', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unsupported file type/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,6 +374,25 @@ describe('Conversations — attachment feature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to reasoning when a supported attachment is selected', async () => {
|
||||
await renderWithSelectedThread();
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = makeFile('auto-reasoning.png', 'image/png', 512);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSelectAgentProfile).toHaveBeenCalledWith('reasoning');
|
||||
expect(screen.getByRole('radio', { name: 'Reasoning' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears attachments and calls chatSend after sending with attachment + text', async () => {
|
||||
const { chatSend } = await import('../../services/chatService');
|
||||
const { textarea } = await renderWithSelectedThread();
|
||||
@@ -358,10 +418,49 @@ describe('Conversations — attachment feature', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatSend).toHaveBeenCalled();
|
||||
expect(chatSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'hint:reasoning',
|
||||
message: expect.stringContaining('[IMAGE:data:image/png;base64,'),
|
||||
})
|
||||
);
|
||||
expect(screen.queryByText('send.png')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends supported document files as FILE markers through the reasoning model', async () => {
|
||||
const { chatSend } = await import('../../services/chatService');
|
||||
const { textarea } = await renderWithSelectedThread();
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = makeFile('doc.pdf', 'application/pdf', 512);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'read this' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatSend).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'hint:reasoning',
|
||||
message: expect.stringContaining('[FILE:data:application/pdf;base64,'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders image thumbnails in user message bubble from extraMetadata', async () => {
|
||||
const thread = makeThread({ id: 'img-thread', title: 'Img Thread' });
|
||||
const dataUri = 'data:image/png;base64,abc123';
|
||||
|
||||
@@ -865,7 +865,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
expect(chatSend).toHaveBeenCalledWith({
|
||||
threadId: thread.id,
|
||||
message: 'hello cloud',
|
||||
model: 'reasoning-v1',
|
||||
model: 'hint:chat',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
@@ -889,7 +889,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
expect(chatSend).toHaveBeenCalledWith({
|
||||
threadId: thread.id,
|
||||
message: 'play highway to hell',
|
||||
model: 'reasoning-v1',
|
||||
model: 'hint:chat',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
@@ -941,7 +941,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
expect(chatSend).toHaveBeenCalledWith({
|
||||
threadId: thread.id,
|
||||
message: 'slow backend',
|
||||
model: 'reasoning-v1',
|
||||
model: 'hint:chat',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
@@ -1309,7 +1309,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
expect(chatSend).toHaveBeenCalledWith({
|
||||
threadId: thread.id,
|
||||
message: 'enter send',
|
||||
model: 'reasoning-v1',
|
||||
model: 'hint:chat',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
@@ -1384,7 +1384,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
expect(chatSend).toHaveBeenCalledWith({
|
||||
threadId: thread.id,
|
||||
message: '안녕',
|
||||
model: 'reasoning-v1',
|
||||
model: 'hint:chat',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ vi.mock('../utils/config', () => ({
|
||||
E2E_DEFAULT_CORE_MODE: '',
|
||||
E2E_RESTART_APP_AS_RELOAD: false,
|
||||
DEV_FORCE_ONBOARDING: false,
|
||||
CHAT_ATTACHMENTS_ENABLED: false,
|
||||
CHAT_ATTACHMENTS_ENABLED: true,
|
||||
SKILLS_GITHUB_REPO: 'test/skills',
|
||||
GA_MEASUREMENT_ID: undefined,
|
||||
OPENPANEL_API_URL: 'https://panel.tinyhumans.ai/api',
|
||||
|
||||
+4
-11
@@ -91,18 +91,11 @@ export const CONSUMER_FIRST_SESSION_ENABLED =
|
||||
import.meta.env.VITE_CONSUMER_FIRST_SESSION === 'true';
|
||||
|
||||
/**
|
||||
* Chat image attachments (the composer's attach button + file picker).
|
||||
*
|
||||
* **Default off.** The end-to-end attachment path is not usable yet: image
|
||||
* payloads are inlined as base64 into the chat message and rejected by the
|
||||
* managed backend (`413 Payload Too Large`), so attaching anything surfaces a
|
||||
* generic "Something went wrong" error (issue #3205). Until images are sent
|
||||
* out-of-band (uploaded + referenced by URL) and the backend accepts them, we
|
||||
* hide the entry point rather than ship a button that always fails.
|
||||
* Re-enable locally to work on the feature: `VITE_CHAT_ATTACHMENTS=true` in
|
||||
* `app/.env.local`.
|
||||
* Chat multimodal attachments (image + supported file markers). Enabled by
|
||||
* default now that core parses `[IMAGE:…]` / `[FILE:…]` payloads and the
|
||||
* renderer runs a lossless compression pass before sending.
|
||||
*/
|
||||
export const CHAT_ATTACHMENTS_ENABLED = import.meta.env.VITE_CHAT_ATTACHMENTS === 'true';
|
||||
export const CHAT_ATTACHMENTS_ENABLED = import.meta.env.VITE_CHAT_ATTACHMENTS !== 'false';
|
||||
|
||||
export const SKILLS_GITHUB_REPO =
|
||||
import.meta.env.VITE_SKILLS_GITHUB_REPO || 'tinyhumansai/openhuman-skills';
|
||||
|
||||
@@ -11,8 +11,9 @@ const USER_ID = 'pw-chat-subagent';
|
||||
const PROMPT = 'Research the answer to life and tell me a marker phrase.';
|
||||
const CANARY_FINAL = 'subagent-canary-final-7afe2';
|
||||
const RESEARCHER_REPLY = 'The researcher answer is 42.';
|
||||
const FORCED_RESPONSES = [
|
||||
const KEYWORD_RESPONSES = [
|
||||
{
|
||||
keyword: PROMPT,
|
||||
content: '',
|
||||
toolCalls: [
|
||||
{
|
||||
@@ -22,8 +23,8 @@ const FORCED_RESPONSES = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{ content: RESEARCHER_REPLY },
|
||||
{ content: `Done. The result is: ${CANARY_FINAL}` },
|
||||
{ keyword: 'Tell me a marker phrase', content: RESEARCHER_REPLY },
|
||||
{ keyword: RESEARCHER_REPLY, content: `Done. The result is: ${CANARY_FINAL}` },
|
||||
];
|
||||
|
||||
interface MockRequest {
|
||||
@@ -53,6 +54,13 @@ async function requests(): Promise<MockRequest[]> {
|
||||
return Array.isArray(payload.data) ? payload.data : [];
|
||||
}
|
||||
|
||||
async function completionRequestCount(): Promise<number> {
|
||||
const log = await requests();
|
||||
return log.filter(
|
||||
entry => entry.method === 'POST' && entry.url.includes('/openai/v1/chat/completions')
|
||||
).length;
|
||||
}
|
||||
|
||||
async function openChat(page: Page): Promise<void> {
|
||||
await bootAuthenticatedPage(page, USER_ID, '/chat');
|
||||
await page.goto('/#/chat');
|
||||
@@ -134,15 +142,19 @@ async function sendMessage(page: Page, prompt: string): Promise<void> {
|
||||
|
||||
test.describe('Chat Harness - Subagent', () => {
|
||||
test('delegates to a subagent and persists the final orchestrator text', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
await resetMock();
|
||||
await setMockBehavior('llmForcedResponses', JSON.stringify(FORCED_RESPONSES));
|
||||
await setMockBehavior('llmForcedResponses', '');
|
||||
await setMockBehavior('llmKeywordRules', JSON.stringify(KEYWORD_RESPONSES));
|
||||
await setMockBehavior('llmStreamChunkDelayMs', '10');
|
||||
|
||||
await openChat(page);
|
||||
const threadId = await createNewThread(page);
|
||||
await sendMessage(page, PROMPT);
|
||||
|
||||
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 75_000 });
|
||||
await expect.poll(completionRequestCount, { timeout: 90_000 }).toBeGreaterThanOrEqual(3);
|
||||
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const runtime = await page.evaluate(currentThreadId => {
|
||||
const store = (
|
||||
@@ -172,15 +184,6 @@ test.describe('Chat Harness - Subagent', () => {
|
||||
runtime.ids.some(id => id.includes(':subagent:'))
|
||||
).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const log = await requests();
|
||||
return log.filter(
|
||||
entry => entry.method === 'POST' && entry.url.includes('/openai/v1/chat/completions')
|
||||
).length;
|
||||
})
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
|
||||
await expect(page.getByText(CANARY_FINAL)).toBeVisible();
|
||||
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,26 @@ async function resetMock(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
interface MockRequest {
|
||||
method?: string;
|
||||
url?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
async function requests(): Promise<MockRequest[]> {
|
||||
const response = await fetch(`${MOCK_BASE}/__admin/requests`);
|
||||
const payload = (await response.json()) as unknown;
|
||||
if (Array.isArray(payload)) return payload as MockRequest[];
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
Array.isArray((payload as { data?: unknown }).data)
|
||||
) {
|
||||
return (payload as { data: MockRequest[] }).data;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function selectedThreadId(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const store = (
|
||||
@@ -52,7 +72,7 @@ async function newThread(page: Page): Promise<string> {
|
||||
}
|
||||
|
||||
test.describe('Chat management functional coverage', () => {
|
||||
test('attachment preview, remove, and attachment send path remain interactive', async ({
|
||||
test('attachment preview, remove, and multimodal send path remain interactive', async ({
|
||||
page,
|
||||
}) => {
|
||||
await resetMock();
|
||||
@@ -78,20 +98,56 @@ test.describe('Chat management functional coverage', () => {
|
||||
await page.getByRole('button', { name: /Remove pixel\.png/ }).click();
|
||||
await expect(page.getByText('pixel.png')).toHaveCount(0);
|
||||
|
||||
const pngBuffer = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
await fileInput.setInputFiles({ name: 'pixel.png', mimeType: 'image/png', buffer: pngBuffer });
|
||||
await expect(page.getByText('pixel.png')).toBeVisible();
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: 'pixel.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
name: 'notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('renderer uploaded text document', 'utf8'),
|
||||
});
|
||||
await expect(page.getByText('notes.txt')).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder('How can I help you today?').fill('Describe this image');
|
||||
await page.getByTestId('send-message-button').click();
|
||||
await expect(page.getByText("This model can't process images.")).toBeVisible({
|
||||
await expect(page.getByText('Attachment received by the assistant.')).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByPlaceholder('How can I help you today?')).toBeEnabled();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const log = await requests();
|
||||
const completion = log.find(
|
||||
request =>
|
||||
request.method === 'POST' &&
|
||||
request.url?.includes('/chat/completions') &&
|
||||
typeof request.body === 'string' &&
|
||||
request.body.includes('Describe this image')
|
||||
);
|
||||
return completion?.body ?? '';
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toContain('image_url');
|
||||
|
||||
const log = await requests();
|
||||
const completionBody =
|
||||
log.find(
|
||||
request =>
|
||||
request.method === 'POST' &&
|
||||
request.url?.includes('/chat/completions') &&
|
||||
typeof request.body === 'string' &&
|
||||
request.body.includes('Describe this image')
|
||||
)?.body ?? '';
|
||||
expect(completionBody).toContain('reasoning');
|
||||
expect(completionBody).toContain('[FILE-EXTRACTED:');
|
||||
expect(completionBody).toContain('renderer uploaded text document');
|
||||
});
|
||||
|
||||
test('thread rename and delete remain usable from the conversation UI', async ({ page }) => {
|
||||
|
||||
@@ -166,7 +166,7 @@ test.describe('Chat Multi Tool Round', () => {
|
||||
const threadId = await createNewThread(page);
|
||||
await sendMessage(page, PROMPT);
|
||||
|
||||
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 50_000 });
|
||||
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 50_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -158,7 +158,7 @@ test.describe('Chat Tool Call Flow', () => {
|
||||
const threadId = await createNewThread(page);
|
||||
await sendMessage(page, PROMPT);
|
||||
|
||||
await expect(page.getByText(CANARY_FINAL)).toBeVisible({ timeout: 40_000 });
|
||||
await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 40_000 });
|
||||
|
||||
const names = await expect
|
||||
.poll(async () => toolTimelineNames(page, threadId), { timeout: 20_000 })
|
||||
|
||||
@@ -3,8 +3,10 @@ use crate::openhuman::config::{
|
||||
};
|
||||
use crate::openhuman::inference::provider::ChatMessage;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use flate2::read::GzDecoder;
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -19,10 +21,9 @@ const ALLOWED_IMAGE_MIME_TYPES: &[&str] = &[
|
||||
|
||||
/// File-attachment marker prefix. Counterpart to [`IMAGE_MARKER_PREFIX`].
|
||||
/// Resolution rules mirror images: local paths, optional http(s) URLs
|
||||
/// gated by [`MultimodalFileConfig::allow_remote_fetch`]. `data:` URIs
|
||||
/// are intentionally rejected — the file pipeline does not inline
|
||||
/// base64 the way images do; users wanting inline content should paste
|
||||
/// it as text.
|
||||
/// gated by [`MultimodalFileConfig::allow_remote_fetch`], and renderer-owned
|
||||
/// `data:` URIs. Inline `application/gzip` data URIs are decompressed before
|
||||
/// validation when they carry an `original_mime=...` parameter.
|
||||
const FILE_MARKER_PREFIX: &str = "[FILE:";
|
||||
|
||||
/// Hard upper bound on how long [`pdf_extract::extract_text_from_mem`]
|
||||
@@ -488,45 +489,126 @@ async fn normalize_image_reference(
|
||||
}
|
||||
|
||||
fn normalize_data_uri(source: &str, max_bytes: usize) -> anyhow::Result<String> {
|
||||
let parsed = parse_data_uri(source).map_err(|reason| MultimodalError::InvalidMarker {
|
||||
input: source.to_string(),
|
||||
reason,
|
||||
})?;
|
||||
|
||||
let (mime, decoded) = if parsed.mime == "application/gzip" {
|
||||
let original_mime = data_uri_param(&parsed.params, "original_mime").ok_or_else(|| {
|
||||
MultimodalError::InvalidMarker {
|
||||
input: source.to_string(),
|
||||
reason: "compressed image data URI missing original_mime parameter".to_string(),
|
||||
}
|
||||
})?;
|
||||
(
|
||||
original_mime.to_ascii_lowercase(),
|
||||
gunzip_data_uri(source, &parsed.bytes, max_bytes)?,
|
||||
)
|
||||
} else {
|
||||
(parsed.mime, parsed.bytes)
|
||||
};
|
||||
|
||||
validate_mime(source, &mime)?;
|
||||
validate_size(source, decoded.len(), max_bytes)?;
|
||||
|
||||
Ok(format!("data:{mime};base64,{}", STANDARD.encode(decoded)))
|
||||
}
|
||||
|
||||
struct ParsedDataUri {
|
||||
mime: String,
|
||||
params: Vec<(String, String)>,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
fn parse_data_uri(source: &str) -> Result<ParsedDataUri, String> {
|
||||
let Some(comma_idx) = source.find(',') else {
|
||||
return Err(MultimodalError::InvalidMarker {
|
||||
input: source.to_string(),
|
||||
reason: "expected data URI payload".to_string(),
|
||||
}
|
||||
.into());
|
||||
return Err("expected data URI payload".to_string());
|
||||
};
|
||||
|
||||
let header = &source[..comma_idx];
|
||||
let payload = source[comma_idx + 1..].trim();
|
||||
|
||||
if !header.contains(";base64") {
|
||||
return Err("only base64 data URIs are supported".to_string());
|
||||
}
|
||||
|
||||
let mut parts = header.trim_start_matches("data:").split(';');
|
||||
let mime = parts.next().unwrap_or_default().trim().to_ascii_lowercase();
|
||||
let params = parts
|
||||
.filter_map(|part| {
|
||||
if part.eq_ignore_ascii_case("base64") {
|
||||
return None;
|
||||
}
|
||||
let (key, value) = part.split_once('=')?;
|
||||
Some((
|
||||
key.trim().to_ascii_lowercase(),
|
||||
percent_decode(value.trim()).unwrap_or_else(|| value.trim().to_string()),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let bytes = STANDARD
|
||||
.decode(payload)
|
||||
.map_err(|error| format!("invalid base64 payload: {error}"))?;
|
||||
|
||||
Ok(ParsedDataUri {
|
||||
mime,
|
||||
params,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn data_uri_param(params: &[(String, String)], key: &str) -> Option<String> {
|
||||
params
|
||||
.iter()
|
||||
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
|
||||
.map(|(_, value)| value.clone())
|
||||
}
|
||||
|
||||
fn percent_decode(value: &str) -> Option<String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' {
|
||||
if i + 2 >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?;
|
||||
let byte = u8::from_str_radix(hex, 16).ok()?;
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
fn gunzip_data_uri(
|
||||
source: &str,
|
||||
bytes: &[u8],
|
||||
max_decompressed_bytes: usize,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let limit = max_decompressed_bytes.saturating_add(1) as u64;
|
||||
let mut decoder = GzDecoder::new(bytes).take(limit);
|
||||
let mut out = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|error| MultimodalError::InvalidMarker {
|
||||
input: source.to_string(),
|
||||
reason: format!("invalid gzip payload: {error}"),
|
||||
})?;
|
||||
if out.len() > max_decompressed_bytes {
|
||||
return Err(MultimodalError::InvalidMarker {
|
||||
input: source.to_string(),
|
||||
reason: "only base64 data URIs are supported".to_string(),
|
||||
reason: format!("decompressed payload exceeds {max_decompressed_bytes} bytes"),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let mime = header
|
||||
.trim_start_matches("data:")
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
validate_mime(source, &mime)?;
|
||||
|
||||
let decoded = STANDARD
|
||||
.decode(payload)
|
||||
.map_err(|error| MultimodalError::InvalidMarker {
|
||||
input: source.to_string(),
|
||||
reason: format!("invalid base64 payload: {error}"),
|
||||
})?;
|
||||
|
||||
validate_size(source, decoded.len(), max_bytes)?;
|
||||
|
||||
Ok(format!("data:{mime};base64,{}", STANDARD.encode(decoded)))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn normalize_remote_image(
|
||||
@@ -727,12 +809,16 @@ async fn normalize_file_reference(
|
||||
remote_client: &Client,
|
||||
) -> anyhow::Result<FilePayload> {
|
||||
if source.starts_with("data:") {
|
||||
return Err(MultimodalError::InvalidFileMarker {
|
||||
input: source.to_string(),
|
||||
reason: "data: URIs are not supported for [FILE:…] markers — paste content as text"
|
||||
.to_string(),
|
||||
}
|
||||
.into());
|
||||
let (bytes, name, mime) = normalize_file_data_uri(source, max_bytes)?;
|
||||
return file_payload_from_bytes(
|
||||
source,
|
||||
bytes,
|
||||
name,
|
||||
mime,
|
||||
config,
|
||||
max_extracted_text_chars,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let (bytes, path_hint, name, header_content_type) =
|
||||
@@ -758,6 +844,54 @@ async fn normalize_file_reference(
|
||||
supported: config.allowed_mime_types.join(", "),
|
||||
})?;
|
||||
|
||||
file_payload_from_bytes(source, bytes, name, mime, config, max_extracted_text_chars).await
|
||||
}
|
||||
|
||||
fn normalize_file_data_uri(
|
||||
source: &str,
|
||||
max_bytes: usize,
|
||||
) -> anyhow::Result<(Vec<u8>, String, String)> {
|
||||
let parsed = parse_data_uri(source).map_err(|reason| MultimodalError::InvalidFileMarker {
|
||||
input: source.to_string(),
|
||||
reason,
|
||||
})?;
|
||||
let name = data_uri_param(&parsed.params, "name").unwrap_or_else(|| "attachment".to_string());
|
||||
|
||||
let (mime, bytes) = if parsed.mime == "application/gzip" {
|
||||
let original_mime = data_uri_param(&parsed.params, "original_mime").ok_or_else(|| {
|
||||
MultimodalError::InvalidFileMarker {
|
||||
input: source.to_string(),
|
||||
reason: "compressed file data URI missing original_mime parameter".to_string(),
|
||||
}
|
||||
})?;
|
||||
(
|
||||
original_mime.to_ascii_lowercase(),
|
||||
gunzip_data_uri(source, &parsed.bytes, max_bytes)?,
|
||||
)
|
||||
} else {
|
||||
(parsed.mime, parsed.bytes)
|
||||
};
|
||||
|
||||
if bytes.len() > max_bytes {
|
||||
return Err(MultimodalError::FileTooLarge {
|
||||
input: source.to_string(),
|
||||
size_bytes: bytes.len(),
|
||||
max_bytes,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok((bytes, name, mime))
|
||||
}
|
||||
|
||||
async fn file_payload_from_bytes(
|
||||
source: &str,
|
||||
bytes: Vec<u8>,
|
||||
name: String,
|
||||
mime: String,
|
||||
config: &MultimodalFileConfig,
|
||||
max_extracted_text_chars: usize,
|
||||
) -> anyhow::Result<FilePayload> {
|
||||
if !config.is_mime_allowed(&mime) {
|
||||
return Err(MultimodalError::UnsupportedFileMime {
|
||||
input: source.to_string(),
|
||||
|
||||
@@ -443,20 +443,131 @@ async fn prepare_messages_rejects_remote_file_when_disabled() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_messages_rejects_data_uri_file_marker() {
|
||||
async fn prepare_messages_extracts_data_uri_file_marker() {
|
||||
let messages = vec![ChatMessage::user(
|
||||
"[FILE:data:text/plain;base64,SGVsbG8=]".to_string(),
|
||||
"[FILE:data:text/plain;name=note.txt;base64,SGVsbG8=]".to_string(),
|
||||
)];
|
||||
|
||||
let err = prepare_messages_for_provider(
|
||||
let prepared = prepare_messages_for_provider(
|
||||
&messages,
|
||||
&MultimodalConfig::default(),
|
||||
&MultimodalFileConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("data: URIs are not supported for FILE markers");
|
||||
.expect("data: URI files should be supported for renderer uploads");
|
||||
|
||||
assert!(err.to_string().contains("data: URIs are not supported"));
|
||||
assert!(prepared.contains_files);
|
||||
let body = &prepared.messages[0].content;
|
||||
assert!(body.contains("[FILE-EXTRACTED:"));
|
||||
assert!(body.contains("name=\"note.txt\""));
|
||||
assert!(body.contains("Hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_messages_decompresses_gzipped_data_uri_file_marker() {
|
||||
use base64::Engine as _;
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use std::io::Write;
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(b"Hello compressed").unwrap();
|
||||
let gz = encoder.finish().unwrap();
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(gz);
|
||||
let messages = vec![ChatMessage::user(format!(
|
||||
"[FILE:data:application/gzip;original_mime=text%2Fplain;name=note.txt;base64,{encoded}]"
|
||||
))];
|
||||
|
||||
let prepared = prepare_messages_for_provider(
|
||||
&messages,
|
||||
&MultimodalConfig::default(),
|
||||
&MultimodalFileConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("compressed data URI file should decompress");
|
||||
|
||||
assert!(prepared.contains_files);
|
||||
let body = &prepared.messages[0].content;
|
||||
assert!(body.contains("Hello compressed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_messages_decompresses_gzipped_data_uri_image_marker() {
|
||||
use base64::Engine as _;
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use std::io::Write;
|
||||
|
||||
let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(&png).unwrap();
|
||||
let gz = encoder.finish().unwrap();
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(gz);
|
||||
let messages = vec![ChatMessage::user(format!(
|
||||
"[IMAGE:data:application/gzip;original_mime=image%2Fpng;name=shot.png;base64,{encoded}]"
|
||||
))];
|
||||
|
||||
let prepared = prepare_messages_for_provider(
|
||||
&messages,
|
||||
&MultimodalConfig::default(),
|
||||
&MultimodalFileConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect("compressed data URI image should decompress");
|
||||
|
||||
assert!(prepared.contains_images);
|
||||
let body = &prepared.messages[0].content;
|
||||
assert!(body.contains("[IMAGE:data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_messages_bounds_gzipped_data_uri_decompression() {
|
||||
use base64::Engine as _;
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use std::io::Write;
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(&vec![0u8; 1024 * 1024 + 1]).unwrap();
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(encoder.finish().unwrap());
|
||||
let messages = vec![ChatMessage::user(format!(
|
||||
"[IMAGE:data:application/gzip;original_mime=image%2Fpng;base64,{encoded}]"
|
||||
))];
|
||||
let image_config = MultimodalConfig {
|
||||
max_images: 4,
|
||||
max_image_size_mb: 1,
|
||||
allow_remote_fetch: false,
|
||||
};
|
||||
|
||||
let error =
|
||||
prepare_messages_for_provider(&messages, &image_config, &MultimodalFileConfig::default())
|
||||
.await
|
||||
.expect_err("compressed payload must be capped during decompression");
|
||||
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("decompressed payload exceeds 1048576 bytes"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_messages_rejects_gzip_without_original_mime() {
|
||||
use base64::Engine as _;
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use std::io::Write;
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(b"Hello compressed").unwrap();
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(encoder.finish().unwrap());
|
||||
let messages = vec![ChatMessage::user(format!(
|
||||
"[FILE:data:application/gzip;name=note.txt;base64,{encoded}]"
|
||||
))];
|
||||
|
||||
let error = prepare_messages_for_provider(
|
||||
&messages,
|
||||
&MultimodalConfig::default(),
|
||||
&MultimodalFileConfig::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("gzip without original_mime must fail");
|
||||
|
||||
assert!(error.to_string().contains("original_mime"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -87,6 +87,11 @@ pub struct OpenAiCompatibleProvider {
|
||||
/// Ollama, whose OpenAI-compat endpoint returns HTTP 400 on `tools`
|
||||
/// for many models.
|
||||
native_tool_calling: bool,
|
||||
/// Value reported by `capabilities().vision`. Defaults to `true` because
|
||||
/// OpenAI-compatible cloud endpoints accept the `image_url` message
|
||||
/// content shape. Local compatibility shims opt out unless they can prove
|
||||
/// the configured model supports vision.
|
||||
vision: bool,
|
||||
/// Ollama-specific `options.num_ctx` override. When set, every request
|
||||
/// to this provider includes `"options": {"num_ctx": <value>}` in the
|
||||
/// body so Ollama allocates the requested KV-cache size.
|
||||
@@ -200,6 +205,7 @@ impl OpenAiCompatibleProvider {
|
||||
temperature_unsupported_models: Vec::new(),
|
||||
temperature_override: None,
|
||||
native_tool_calling: true,
|
||||
vision: true,
|
||||
ollama_num_ctx: None,
|
||||
local_provider_kind: None,
|
||||
}
|
||||
@@ -214,6 +220,14 @@ impl OpenAiCompatibleProvider {
|
||||
self
|
||||
}
|
||||
|
||||
/// Toggle whether this provider advertises OpenAI-compatible vision input.
|
||||
/// Cloud providers default to enabled; local OpenAI-compatible shims use
|
||||
/// this to stay fail-closed for text-only local models.
|
||||
pub fn with_vision(mut self, enabled: bool) -> Self {
|
||||
self.vision = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the list of model glob patterns for which temperature must be
|
||||
/// omitted from request bodies.
|
||||
pub fn with_temperature_unsupported_models(mut self, patterns: Vec<String>) -> Self {
|
||||
|
||||
@@ -20,10 +20,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
fn capabilities(&self) -> crate::openhuman::inference::provider::traits::ProviderCapabilities {
|
||||
crate::openhuman::inference::provider::traits::ProviderCapabilities {
|
||||
native_tool_calling: self.native_tool_calling,
|
||||
// Kept `false` for now — vision is a per-*model* property the provider
|
||||
// can't know here; the Responses-API path is still text-only. Stays off
|
||||
// until it can be driven per-model (e.g. from `model_registry.vision`).
|
||||
vision: false,
|
||||
vision: self.vision,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1578,6 +1578,7 @@ fn capabilities_reports_native_tool_calling() {
|
||||
let p = make_provider("test", "https://example.com", None);
|
||||
let caps = <OpenAiCompatibleProvider as Provider>::capabilities(&p);
|
||||
assert!(caps.native_tool_calling);
|
||||
assert!(caps.vision);
|
||||
}
|
||||
|
||||
// Sub-issue 3 of #3098: Ollama's OpenAI-compat endpoint silently rejects the
|
||||
@@ -1613,6 +1614,14 @@ fn with_native_tool_calling_is_idempotent() {
|
||||
assert!(!caps.native_tool_calling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_vision_false_disables_capability() {
|
||||
let p = make_provider("test", "https://example.com", None).with_vision(false);
|
||||
let caps = <OpenAiCompatibleProvider as Provider>::capabilities(&p);
|
||||
assert!(!caps.vision);
|
||||
assert!(!p.supports_vision());
|
||||
}
|
||||
|
||||
/// `supports_native_tools()` is the gate the agent harness reads
|
||||
/// (`traits.rs:415`) when deciding whether to send tools natively or
|
||||
/// inject them into the prompt. It MUST agree with
|
||||
|
||||
@@ -1016,6 +1016,7 @@ fn make_ollama_provider(
|
||||
.with_temperature_unsupported_models(config.temperature_unsupported_models.clone())
|
||||
.with_temperature_override(temperature_override)
|
||||
.with_native_tool_calling(false)
|
||||
.with_vision(false)
|
||||
.with_ollama_num_ctx(num_ctx)
|
||||
.with_local_provider_kind(LocalProviderKind::Ollama);
|
||||
Ok((Box::new(provider), model.to_string()))
|
||||
@@ -1056,6 +1057,7 @@ fn make_lm_studio_provider(
|
||||
.with_temperature_unsupported_models(config.temperature_unsupported_models.clone())
|
||||
.with_temperature_override(temperature_override)
|
||||
.with_native_tool_calling(false)
|
||||
.with_vision(false)
|
||||
.with_local_provider_kind(LocalProviderKind::LmStudio);
|
||||
Ok((Box::new(provider), model.to_string()))
|
||||
}
|
||||
@@ -1092,6 +1094,7 @@ fn make_mlx_provider(
|
||||
.with_temperature_unsupported_models(config.temperature_unsupported_models.clone())
|
||||
.with_temperature_override(temperature_override)
|
||||
.with_native_tool_calling(false)
|
||||
.with_vision(false)
|
||||
.with_local_provider_kind(LocalProviderKind::Mlx);
|
||||
Ok((Box::new(provider), model.to_string()))
|
||||
}
|
||||
@@ -1139,6 +1142,7 @@ fn make_local_openai_provider(
|
||||
.with_temperature_unsupported_models(config.temperature_unsupported_models.clone())
|
||||
.with_temperature_override(temperature_override)
|
||||
.with_native_tool_calling(false)
|
||||
.with_vision(false)
|
||||
.with_local_provider_kind(LocalProviderKind::LocalOpenai);
|
||||
Ok((Box::new(provider), model.to_string()))
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ fn ollama_provider_opts_out_of_native_tool_calling() {
|
||||
!caps.native_tool_calling,
|
||||
"ollama provider must report native_tool_calling=false so the agent harness emits prompt-guided tool specs instead of an OpenAI-style `tools` array"
|
||||
);
|
||||
assert!(
|
||||
!caps.vision,
|
||||
"local Ollama-compatible providers stay fail-closed for vision until the configured model proves image support"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -241,10 +245,15 @@ fn lmstudio_provider_defaults_to_prompt_guided_tools() {
|
||||
let (provider, _model) =
|
||||
create_chat_provider_from_string("chat", "lmstudio:google/gemma-4-e4b", &config)
|
||||
.expect("lmstudio:<model> must build");
|
||||
let caps = provider.capabilities();
|
||||
assert!(
|
||||
!provider.capabilities().native_tool_calling,
|
||||
!caps.native_tool_calling,
|
||||
"lmstudio provider must default to native_tool_calling=false (conservative local dispatch)"
|
||||
);
|
||||
assert!(
|
||||
!caps.vision,
|
||||
"local LM Studio-compatible providers stay fail-closed for vision until the configured model proves image support"
|
||||
);
|
||||
}
|
||||
|
||||
// Note: a BYOK-cloud regression test (e.g. `openai:gpt-4o` keeps
|
||||
@@ -485,6 +494,10 @@ async fn cloud_provider_without_stored_key_fails_with_actionable_error() {
|
||||
let config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]);
|
||||
let (provider, model) = create_chat_provider_from_string("reasoning", "openai:gpt-4o", &config)
|
||||
.expect("provider should build without eagerly requiring credentials");
|
||||
assert!(
|
||||
provider.capabilities().vision,
|
||||
"cloud OpenAI-compatible providers must advertise vision so reasoning attachment turns reach the provider"
|
||||
);
|
||||
|
||||
let err = provider
|
||||
.chat_with_system(None, "hello", &model, 0.0)
|
||||
@@ -878,6 +891,18 @@ fn make_openhuman_backend_translates_summarization_hint() {
|
||||
assert_eq!(model, crate::openhuman::config::MODEL_SUMMARIZATION_V1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_openhuman_backend_reports_vision_capability() {
|
||||
let config = Config::default();
|
||||
let (provider, _) = make_openhuman_backend(&config).expect("factory should succeed");
|
||||
let caps = provider.capabilities();
|
||||
assert!(caps.native_tool_calling);
|
||||
assert!(
|
||||
caps.vision,
|
||||
"OpenHuman backend must report vision so attachment-driven reasoning turns clear the harness gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_openhuman_backend_falls_back_for_invalid_model() {
|
||||
// An invalid default_model must not be forwarded to the backend.
|
||||
|
||||
@@ -130,12 +130,11 @@ impl Provider for OpenHumanBackendProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
// Kept `false` for now: the hosted backend's default chat model is
|
||||
// text-only, so claiming vision would only let image turns through
|
||||
// the gate to come back empty. The image_url wire format + budgeting
|
||||
// hygiene ship here (#3205), but the capability stays off until the
|
||||
// backend routes image turns to a vision-capable model per-model.
|
||||
vision: false,
|
||||
// The hosted backend accepts OpenAI-compatible multimodal chat
|
||||
// payloads and routes attachment turns through the reasoning tier.
|
||||
// Keep this in sync with the frontend's attachment-driven
|
||||
// `hint:reasoning` switch so image markers clear the harness gate.
|
||||
vision: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2068,7 +2068,7 @@ async fn inference_openhuman_backend_provider_covers_authless_and_streaming_edge
|
||||
},
|
||||
);
|
||||
assert!(provider.supports_native_tools());
|
||||
assert!(!provider.supports_vision());
|
||||
assert!(provider.supports_vision());
|
||||
assert!(!provider.supports_streaming());
|
||||
|
||||
let missing_session = provider
|
||||
|
||||
Reference in New Issue
Block a user