|
|
import { useState } from 'react'; |
|
|
import { MessageExtra } from '../utils/types'; |
|
|
import toast from 'react-hot-toast'; |
|
|
import { useAppContext } from '../utils/app.context'; |
|
|
import * as pdfjs from 'pdfjs-dist'; |
|
|
import pdfjsWorkerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; |
|
|
import { TextContent, TextItem } from 'pdfjs-dist/types/src/display/api'; |
|
|
|
|
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerSrc; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export interface ChatExtraContextApi { |
|
|
items?: MessageExtra[]; |
|
|
addItems: (items: MessageExtra[]) => void; |
|
|
removeItem: (idx: number) => void; |
|
|
clearItems: () => void; |
|
|
onFileAdded: (files: File[]) => void; |
|
|
} |
|
|
|
|
|
export function useChatExtraContext(): ChatExtraContextApi { |
|
|
const { serverProps, config } = useAppContext(); |
|
|
const [items, setItems] = useState<MessageExtra[]>([]); |
|
|
|
|
|
const addItems = (newItems: MessageExtra[]) => { |
|
|
setItems((prev) => [...prev, ...newItems]); |
|
|
}; |
|
|
|
|
|
const removeItem = (idx: number) => { |
|
|
setItems((prev) => prev.filter((_, i) => i !== idx)); |
|
|
}; |
|
|
|
|
|
const clearItems = () => { |
|
|
setItems([]); |
|
|
}; |
|
|
|
|
|
const isSupportVision = serverProps?.modalities?.vision; |
|
|
|
|
|
const onFileAdded = async (files: File[]) => { |
|
|
try { |
|
|
for (const file of files) { |
|
|
const mimeType = file.type; |
|
|
|
|
|
|
|
|
|
|
|
if (file.size > 500 * 1024 * 1024) { |
|
|
toast.error('File is too large. Maximum size is 500MB.'); |
|
|
break; |
|
|
} |
|
|
|
|
|
if (mimeType.startsWith('image/')) { |
|
|
if (!isSupportVision) { |
|
|
toast.error('Multimodal is not supported by this server or model.'); |
|
|
break; |
|
|
} |
|
|
|
|
|
let base64Url = await getFileAsBase64(file); |
|
|
if (mimeType === 'image/svg+xml') { |
|
|
|
|
|
base64Url = await svgBase64UrlToPngDataURL(base64Url); |
|
|
} |
|
|
addItems([ |
|
|
{ |
|
|
type: 'imageFile', |
|
|
name: file.name, |
|
|
base64Url, |
|
|
}, |
|
|
]); |
|
|
} else if (mimeType.startsWith('video/')) { |
|
|
toast.error('Video files are not supported yet.'); |
|
|
break; |
|
|
} else if (mimeType.startsWith('audio/')) { |
|
|
if (!/mpeg|wav/.test(mimeType)) { |
|
|
toast.error('Only mp3 and wav audio files are supported.'); |
|
|
break; |
|
|
} |
|
|
|
|
|
|
|
|
const base64Data = await getFileAsBase64(file, false); |
|
|
addItems([ |
|
|
{ |
|
|
type: 'audioFile', |
|
|
name: file.name, |
|
|
mimeType, |
|
|
base64Data, |
|
|
}, |
|
|
]); |
|
|
} else if (mimeType.startsWith('application/pdf')) { |
|
|
if (config.pdfAsImage && !isSupportVision) { |
|
|
toast( |
|
|
'Multimodal is not supported, PDF will be converted to text instead of image.' |
|
|
); |
|
|
break; |
|
|
} |
|
|
|
|
|
if (config.pdfAsImage && isSupportVision) { |
|
|
|
|
|
const base64Urls = await convertPDFToImage(file); |
|
|
addItems( |
|
|
base64Urls.map((base64Url) => ({ |
|
|
type: 'imageFile', |
|
|
name: file.name, |
|
|
base64Url, |
|
|
})) |
|
|
); |
|
|
} else { |
|
|
|
|
|
const content = await convertPDFToText(file); |
|
|
addItems([ |
|
|
{ |
|
|
type: 'textFile', |
|
|
name: file.name, |
|
|
content, |
|
|
}, |
|
|
]); |
|
|
if (isSupportVision) { |
|
|
toast.success( |
|
|
'PDF file converted to text. You can also convert it to image, see in Settings.' |
|
|
); |
|
|
} |
|
|
} |
|
|
break; |
|
|
} else { |
|
|
|
|
|
|
|
|
const reader = new FileReader(); |
|
|
reader.onload = (event) => { |
|
|
if (event.target?.result) { |
|
|
const content = event.target.result as string; |
|
|
if (!isLikelyNotBinary(content)) { |
|
|
toast.error('File is binary. Please upload a text file.'); |
|
|
return; |
|
|
} |
|
|
addItems([ |
|
|
{ |
|
|
type: 'textFile', |
|
|
name: file.name, |
|
|
content, |
|
|
}, |
|
|
]); |
|
|
} |
|
|
}; |
|
|
reader.readAsText(file); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
const message = error instanceof Error ? error.message : String(error); |
|
|
const errorMessage = `Error processing file: ${message}`; |
|
|
toast.error(errorMessage); |
|
|
} |
|
|
}; |
|
|
|
|
|
return { |
|
|
items: items.length > 0 ? items : undefined, |
|
|
addItems, |
|
|
removeItem, |
|
|
clearItems, |
|
|
onFileAdded, |
|
|
}; |
|
|
} |
|
|
|
|
|
async function getFileAsBase64(file: File, outputUrl = true): Promise<string> { |
|
|
return new Promise((resolve, reject) => { |
|
|
const reader = new FileReader(); |
|
|
reader.onload = (event) => { |
|
|
if (event.target?.result) { |
|
|
let result = event.target.result as string; |
|
|
if (!outputUrl) { |
|
|
|
|
|
result = result.substring(result.indexOf(',') + 1); |
|
|
} |
|
|
resolve(result); |
|
|
} else { |
|
|
reject(new Error('Failed to read file.')); |
|
|
} |
|
|
}; |
|
|
reader.readAsDataURL(file); |
|
|
}); |
|
|
} |
|
|
|
|
|
async function getFileAsBuffer(file: File): Promise<ArrayBuffer> { |
|
|
return new Promise((resolve, reject) => { |
|
|
const reader = new FileReader(); |
|
|
reader.onload = (event) => { |
|
|
if (event.target?.result) { |
|
|
resolve(event.target.result as ArrayBuffer); |
|
|
} else { |
|
|
reject(new Error('Failed to read file.')); |
|
|
} |
|
|
}; |
|
|
reader.readAsArrayBuffer(file); |
|
|
}); |
|
|
} |
|
|
|
|
|
async function convertPDFToText(file: File): Promise<string> { |
|
|
const buffer = await getFileAsBuffer(file); |
|
|
const pdf = await pdfjs.getDocument(buffer).promise; |
|
|
const numPages = pdf.numPages; |
|
|
const textContentPromises: Promise<TextContent>[] = []; |
|
|
for (let i = 1; i <= numPages; i++) { |
|
|
textContentPromises.push( |
|
|
pdf.getPage(i).then((page) => page.getTextContent()) |
|
|
); |
|
|
} |
|
|
const textContents = await Promise.all(textContentPromises); |
|
|
const textItems = textContents.flatMap((textContent: TextContent) => |
|
|
textContent.items.map((item) => (item as TextItem).str ?? '') |
|
|
); |
|
|
return textItems.join('\n'); |
|
|
} |
|
|
|
|
|
|
|
|
async function convertPDFToImage(file: File): Promise<string[]> { |
|
|
const buffer = await getFileAsBuffer(file); |
|
|
const doc = await pdfjs.getDocument(buffer).promise; |
|
|
const pages: Promise<string>[] = []; |
|
|
|
|
|
for (let i = 1; i <= doc.numPages; i++) { |
|
|
const page = await doc.getPage(i); |
|
|
const viewport = page.getViewport({ scale: 1.5 }); |
|
|
const canvas = document.createElement('canvas'); |
|
|
const ctx = canvas.getContext('2d'); |
|
|
canvas.width = viewport.width; |
|
|
canvas.height = viewport.height; |
|
|
if (!ctx) { |
|
|
throw new Error('Failed to get 2D context from canvas'); |
|
|
} |
|
|
const task = page.render({ canvasContext: ctx, viewport: viewport }); |
|
|
pages.push( |
|
|
task.promise.then(() => { |
|
|
return canvas.toDataURL(); |
|
|
}) |
|
|
); |
|
|
} |
|
|
|
|
|
return await Promise.all(pages); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function isLikelyNotBinary(str: string): boolean { |
|
|
const options = { |
|
|
prefixLength: 1024 * 10, |
|
|
suspiciousCharThresholdRatio: 0.15, |
|
|
maxAbsoluteNullBytes: 2, |
|
|
}; |
|
|
|
|
|
if (!str) { |
|
|
return true; |
|
|
} |
|
|
|
|
|
const sampleLength = Math.min(str.length, options.prefixLength); |
|
|
if (sampleLength === 0) { |
|
|
return true; |
|
|
} |
|
|
|
|
|
let suspiciousCharCount = 0; |
|
|
let nullByteCount = 0; |
|
|
|
|
|
for (let i = 0; i < sampleLength; i++) { |
|
|
const charCode = str.charCodeAt(i); |
|
|
|
|
|
|
|
|
|
|
|
if (charCode === 0xfffd) { |
|
|
suspiciousCharCount++; |
|
|
continue; |
|
|
} |
|
|
|
|
|
|
|
|
if (charCode === 0x0000) { |
|
|
nullByteCount++; |
|
|
|
|
|
|
|
|
suspiciousCharCount++; |
|
|
continue; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (charCode < 32) { |
|
|
if ( |
|
|
charCode !== 9 && |
|
|
charCode !== 10 && |
|
|
charCode !== 13 && |
|
|
charCode !== 7 && |
|
|
charCode !== 8 |
|
|
) { |
|
|
suspiciousCharCount++; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
if (nullByteCount > options.maxAbsoluteNullBytes) { |
|
|
return false; |
|
|
} |
|
|
|
|
|
|
|
|
const ratio = suspiciousCharCount / sampleLength; |
|
|
return ratio <= options.suspiciousCharThresholdRatio; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function svgBase64UrlToPngDataURL(base64UrlSvg: string): Promise<string> { |
|
|
const backgroundColor = 'white'; |
|
|
|
|
|
return new Promise((resolve, reject) => { |
|
|
try { |
|
|
const img = new Image(); |
|
|
|
|
|
img.onload = () => { |
|
|
const canvas = document.createElement('canvas'); |
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
if (!ctx) { |
|
|
reject(new Error('Failed to get 2D canvas context.')); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const targetWidth = img.naturalWidth || 300; |
|
|
const targetHeight = img.naturalHeight || 300; |
|
|
|
|
|
canvas.width = targetWidth; |
|
|
canvas.height = targetHeight; |
|
|
|
|
|
if (backgroundColor) { |
|
|
ctx.fillStyle = backgroundColor; |
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height); |
|
|
} |
|
|
|
|
|
ctx.drawImage(img, 0, 0, targetWidth, targetHeight); |
|
|
resolve(canvas.toDataURL('image/png')); |
|
|
}; |
|
|
|
|
|
img.onerror = () => { |
|
|
reject( |
|
|
new Error('Failed to load SVG image. Ensure the SVG data is valid.') |
|
|
); |
|
|
}; |
|
|
|
|
|
|
|
|
img.src = base64UrlSvg; |
|
|
} catch (error) { |
|
|
const message = error instanceof Error ? error.message : String(error); |
|
|
const errorMessage = `Error converting SVG to PNG: ${message}`; |
|
|
toast.error(errorMessage); |
|
|
reject(new Error(errorMessage)); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|