fix: remove cloneDeep to prevent stack overflow with base64 images (#11761)

* Initial plan

* fix: replace cloneDeep with shallow copy to prevent stack overflow with base64 images

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* fix: remove unnecessary cloneDeep in ApiService to prevent stack overflow

Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>

* fix: update message conversion logic for image enhancement models to preserve system messages and collapse user content

* Revert "fix: replace cloneDeep with shallow copy to prevent stack overflow with base64 images"

This reverts commit e203f72fc6.

* fix: 添加数据URL媒体类型解析和URL媒体类型推测功能

* Update src/renderer/src/aiCore/prepareParams/messageConverter.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: remove dead code

* fix: resolve PR review issues for Proxy API Server

- Fix silent data loss: upgrade image load failure from warn to error level with context
- Update JSDoc: rewrite convertMessagesToSdkMessages documentation to accurately describe collapse behavior
- Add test coverage: base64 extraction from data URLs with proper mediaType handling
- Add edge case tests: collapse logic for no assistant, empty images, multiple assistants, conversation endings
- Document mutation safety: add comments explaining shallow copy is intentional in ApiService

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix comment

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Copilot 2025-12-12 15:16:31 +08:00 committed by GitHub
parent be9a8b8699
commit d7b9a6e09a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 340 additions and 57 deletions

View File

@ -137,6 +137,73 @@ describe('messageConverter', () => {
}) })
}) })
it('extracts base64 data from data URLs and preserves mediaType', async () => {
const model = createModel()
const message = createMessage('user')
message.__mockContent = 'Check this image'
message.__mockImageBlocks = [createImageBlock(message.id, { url: 'data:image/png;base64,iVBORw0KGgoAAAANS' })]
const result = await convertMessageToSdkParam(message, true, model)
expect(result).toEqual({
role: 'user',
content: [
{ type: 'text', text: 'Check this image' },
{ type: 'image', image: 'iVBORw0KGgoAAAANS', mediaType: 'image/png' }
]
})
})
it('handles data URLs without mediaType gracefully', async () => {
const model = createModel()
const message = createMessage('user')
message.__mockContent = 'Check this'
message.__mockImageBlocks = [createImageBlock(message.id, { url: 'data:;base64,AAABBBCCC' })]
const result = await convertMessageToSdkParam(message, true, model)
expect(result).toEqual({
role: 'user',
content: [
{ type: 'text', text: 'Check this' },
{ type: 'image', image: 'AAABBBCCC' }
]
})
})
it('skips malformed data URLs without comma separator', async () => {
const model = createModel()
const message = createMessage('user')
message.__mockContent = 'Malformed data url'
message.__mockImageBlocks = [createImageBlock(message.id, { url: 'data:image/pngAAABBB' })]
const result = await convertMessageToSdkParam(message, true, model)
expect(result).toEqual({
role: 'user',
content: [
{ type: 'text', text: 'Malformed data url' }
// Malformed data URL is excluded from the content
]
})
})
it('handles multiple large base64 images without stack overflow', async () => {
const model = createModel()
const message = createMessage('user')
// Create large base64 strings (~500KB each) to simulate real-world large images
const largeBase64 = 'A'.repeat(500_000)
message.__mockContent = 'Check these images'
message.__mockImageBlocks = [
createImageBlock(message.id, { url: `data:image/png;base64,${largeBase64}` }),
createImageBlock(message.id, { url: `data:image/png;base64,${largeBase64}` }),
createImageBlock(message.id, { url: `data:image/png;base64,${largeBase64}` })
]
// Should not throw RangeError: Maximum call stack size exceeded
await expect(convertMessageToSdkParam(message, true, model)).resolves.toBeDefined()
})
it('returns file instructions as a system message when native uploads succeed', async () => { it('returns file instructions as a system message when native uploads succeed', async () => {
const model = createModel() const model = createModel()
const message = createMessage('user') const message = createMessage('user')
@ -165,7 +232,7 @@ describe('messageConverter', () => {
}) })
describe('convertMessagesToSdkMessages', () => { describe('convertMessagesToSdkMessages', () => {
it('appends assistant images to the final user message for image enhancement models', async () => { it('collapses to [system?, user(image)] for image enhancement models', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const initialUser = createMessage('user') const initialUser = createMessage('user')
initialUser.__mockContent = 'Start editing' initialUser.__mockContent = 'Start editing'
@ -180,14 +247,6 @@ describe('messageConverter', () => {
const result = await convertMessagesToSdkMessages([initialUser, assistant, finalUser], model) const result = await convertMessagesToSdkMessages([initialUser, assistant, finalUser], model)
expect(result).toEqual([ expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start editing' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Here is the current preview' }]
},
{ {
role: 'user', role: 'user',
content: [ content: [
@ -198,7 +257,7 @@ describe('messageConverter', () => {
]) ])
}) })
it('preserves preceding system instructions when building enhancement payloads', async () => { it('preserves system messages and collapses others for enhancement payloads', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const fileUser = createMessage('user') const fileUser = createMessage('user')
fileUser.__mockContent = 'Use this document as inspiration' fileUser.__mockContent = 'Use this document as inspiration'
@ -221,11 +280,6 @@ describe('messageConverter', () => {
expect(result).toEqual([ expect(result).toEqual([
{ role: 'system', content: 'fileid://reference' }, { role: 'system', content: 'fileid://reference' },
{ role: 'user', content: [{ type: 'text', text: 'Use this document as inspiration' }] },
{
role: 'assistant',
content: [{ type: 'text', text: 'Generated previews ready' }]
},
{ {
role: 'user', role: 'user',
content: [ content: [
@ -235,5 +289,120 @@ describe('messageConverter', () => {
} }
]) ])
}) })
it('handles no previous assistant message with images', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user1 = createMessage('user')
user1.__mockContent = 'Start'
const user2 = createMessage('user')
user2.__mockContent = 'Continue without images'
const result = await convertMessagesToSdkMessages([user1, user2], model)
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Continue without images' }]
}
])
})
it('handles assistant message without images', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user1 = createMessage('user')
user1.__mockContent = 'Start'
const assistant = createMessage('assistant')
assistant.__mockContent = 'Text only response'
assistant.__mockImageBlocks = []
const user2 = createMessage('user')
user2.__mockContent = 'Follow up'
const result = await convertMessagesToSdkMessages([user1, assistant, user2], model)
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Follow up' }]
}
])
})
it('handles multiple assistant messages by using the most recent one', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user1 = createMessage('user')
user1.__mockContent = 'Start'
const assistant1 = createMessage('assistant')
assistant1.__mockContent = 'First response'
assistant1.__mockImageBlocks = [createImageBlock(assistant1.id, { url: 'https://example.com/old.png' })]
const user2 = createMessage('user')
user2.__mockContent = 'Continue'
const assistant2 = createMessage('assistant')
assistant2.__mockContent = 'Second response'
assistant2.__mockImageBlocks = [createImageBlock(assistant2.id, { url: 'https://example.com/new.png' })]
const user3 = createMessage('user')
user3.__mockContent = 'Final request'
const result = await convertMessagesToSdkMessages([user1, assistant1, user2, assistant2, user3], model)
expect(result).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'Final request' },
{ type: 'image', image: 'https://example.com/new.png' }
]
}
])
})
it('handles conversation ending with assistant message', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user = createMessage('user')
user.__mockContent = 'Start'
const assistant = createMessage('assistant')
assistant.__mockContent = 'Response with image'
assistant.__mockImageBlocks = [createImageBlock(assistant.id, { url: 'https://example.com/image.png' })]
const result = await convertMessagesToSdkMessages([user, assistant], model)
// The user message is the last user message, but since the assistant comes after,
// there's no "previous" assistant message (search starts from messages.length-2 backwards)
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start' }]
}
])
})
it('handles empty content in last user message', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user1 = createMessage('user')
user1.__mockContent = 'Start'
const assistant = createMessage('assistant')
assistant.__mockContent = 'Here is the preview'
assistant.__mockImageBlocks = [createImageBlock(assistant.id, { url: 'https://example.com/preview.png' })]
const user2 = createMessage('user')
user2.__mockContent = ''
const result = await convertMessagesToSdkMessages([user1, assistant, user2], model)
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'image', image: 'https://example.com/preview.png' }]
}
])
})
}) })
}) })

View File

@ -7,6 +7,7 @@ import { loggerService } from '@logger'
import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models' import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models'
import type { Message, Model } from '@renderer/types' import type { Message, Model } from '@renderer/types'
import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage' import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage'
import { parseDataUrlMediaType } from '@renderer/utils/image'
import { import {
findFileBlocks, findFileBlocks,
findImageBlocks, findImageBlocks,
@ -59,23 +60,29 @@ async function convertImageBlockToImagePart(imageBlocks: ImageMessageBlock[]): P
mediaType: image.mime mediaType: image.mime
}) })
} catch (error) { } catch (error) {
logger.warn('Failed to load image:', error as Error) logger.error('Failed to load image file, image will be excluded from message:', {
fileId: imageBlock.file.id,
fileName: imageBlock.file.origin_name,
error: error as Error
})
} }
} else if (imageBlock.url) { } else if (imageBlock.url) {
const isBase64 = imageBlock.url.startsWith('data:') const url = imageBlock.url
if (isBase64) { const isDataUrl = url.startsWith('data:')
const base64 = imageBlock.url.match(/^data:[^;]*;base64,(.+)$/)![1] if (isDataUrl) {
const mimeMatch = imageBlock.url.match(/^data:([^;]+)/) const { mediaType } = parseDataUrlMediaType(url)
parts.push({ const commaIndex = url.indexOf(',')
type: 'image', if (commaIndex === -1) {
image: base64, logger.error('Malformed data URL detected (missing comma separator), image will be excluded:', {
mediaType: mimeMatch ? mimeMatch[1] : 'image/png' urlPrefix: url.slice(0, 50) + '...'
}) })
continue
}
const base64Data = url.slice(commaIndex + 1)
parts.push({ type: 'image', image: base64Data, ...(mediaType ? { mediaType } : {}) })
} else { } else {
parts.push({ // For remote URLs we keep payload minimal to match existing expectations.
type: 'image', parts.push({ type: 'image', image: url })
image: imageBlock.url
})
} }
} }
} }
@ -194,17 +201,20 @@ async function convertMessageToAssistantModelMessage(
* This function processes messages and transforms them into the format required by the SDK. * This function processes messages and transforms them into the format required by the SDK.
* It handles special cases for vision models and image enhancement models. * It handles special cases for vision models and image enhancement models.
* *
* @param messages - Array of messages to convert. Must contain at least 3 messages when using image enhancement models for special handling. * @param messages - Array of messages to convert.
* @param model - The model configuration that determines conversion behavior * @param model - The model configuration that determines conversion behavior
* *
* @returns A promise that resolves to an array of SDK-compatible model messages * @returns A promise that resolves to an array of SDK-compatible model messages
* *
* @remarks * @remarks
* For image enhancement models with 3+ messages: * For image enhancement models:
* - Examines the last 2 messages to find an assistant message containing image blocks * - Collapses the conversation into [system?, user(image)] format
* - If found, extracts images from the assistant message and appends them to the last user message content * - Searches backwards through all messages to find the most recent assistant message with images
* - Returns all converted messages (not just the last two) with the images merged into the user message * - Preserves all system messages (including ones generated from file uploads like 'fileid://...')
* - Typical pattern: [system?, assistant(image), user] -> [system?, assistant, user(image)] * - Extracts the last user message content and merges images from the previous assistant message
* - Returns only the collapsed messages: system messages (if any) followed by a single user message
* - If no user message is found, returns only system messages
* - Typical pattern: [system?, user, assistant(image), user] -> [system?, user(image)]
* *
* For other models: * For other models:
* - Returns all converted messages in order without special image handling * - Returns all converted messages in order without special image handling
@ -220,25 +230,66 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M
sdkMessages.push(...(Array.isArray(sdkMessage) ? sdkMessage : [sdkMessage])) sdkMessages.push(...(Array.isArray(sdkMessage) ? sdkMessage : [sdkMessage]))
} }
// Special handling for image enhancement models // Special handling for image enhancement models
// Only merge images into the user message // Target behavior: Collapse the conversation into [system?, user(image)].
// [system?, assistant(image), user] -> [system?, assistant, user(image)] // Explanation of why we don't simply use slice:
if (isImageEnhancementModel(model) && messages.length >= 3) { // 1) We need to preserve all system messages: During the convertMessageToSdkParam process, native file uploads may insert `system(fileid://...)`.
const needUpdatedMessages = messages.slice(-2) // Directly slicing the original messages or already converted sdkMessages could easily result in missing these system instructions.
const assistantMessage = needUpdatedMessages.find((m) => m.role === 'assistant') // Therefore, we first perform a full conversion and then aggregate the system messages afterward.
const userSdkMessage = sdkMessages[sdkMessages.length - 1] // 2) The conversion process may split messages: A single user message might be broken into two SDK messages—[system, user].
// Slicing either side could lead to obtaining semantically incorrect fragments (e.g., only the split-out system message).
// 3) The “previous assistant message” is not necessarily the second-to-last one: There might be system messages or other message blocks inserted in between,
// making a simple slice(-2) assumption too rigid. Here, we trace back from the end of the original messages to locate the most recent assistant message, which better aligns with business semantics.
// 4) This is a “collapse” rather than a simple “slice”: Ultimately, we need to synthesize a new user message
// (with text from the last user message and images from the previous assistant message). Using slice can only extract subarrays,
// which still require reassembly; constructing directly according to the target structure is clearer and more reliable.
if (isImageEnhancementModel(model)) {
// Collect all system messages (including ones generated from file uploads)
const systemMessages = sdkMessages.filter((m): m is SystemModelMessage => m.role === 'system')
if (assistantMessage && userSdkMessage?.role === 'user') { // Find the last user message (SDK converted)
const imageBlocks = findImageBlocks(assistantMessage) const lastUserSdkIndex = (() => {
for (let i = sdkMessages.length - 1; i >= 0; i--) {
if (sdkMessages[i].role === 'user') return i
}
return -1
})()
const lastUserSdk = lastUserSdkIndex >= 0 ? (sdkMessages[lastUserSdkIndex] as UserModelMessage) : null
// Find the nearest preceding assistant message in original messages
let prevAssistant: Message | null = null
for (let i = messages.length - 2; i >= 0; i--) {
if (messages[i].role === 'assistant') {
prevAssistant = messages[i]
break
}
}
// Build the final user content parts
let finalUserParts: Array<TextPart | FilePart | ImagePart> = []
if (lastUserSdk) {
if (typeof lastUserSdk.content === 'string') {
finalUserParts.push({ type: 'text', text: lastUserSdk.content })
} else if (Array.isArray(lastUserSdk.content)) {
finalUserParts = [...lastUserSdk.content]
}
}
// Append images from the previous assistant message if any
if (prevAssistant) {
const imageBlocks = findImageBlocks(prevAssistant)
const imageParts = await convertImageBlockToImagePart(imageBlocks) const imageParts = await convertImageBlockToImagePart(imageBlocks)
if (imageParts.length > 0) { if (imageParts.length > 0) {
if (typeof userSdkMessage.content === 'string') { finalUserParts.push(...imageParts)
userSdkMessage.content = [{ type: 'text', text: userSdkMessage.content }, ...imageParts]
} else if (Array.isArray(userSdkMessage.content)) {
userSdkMessage.content.push(...imageParts)
} }
} }
// If we couldn't find a last user message, fall back to returning collected system messages only
if (!lastUserSdk) {
return systemMessages
} }
return [...systemMessages, { role: 'user', content: finalUserParts }]
} }
return sdkMessages return sdkMessages

View File

@ -22,7 +22,7 @@ import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt' import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt'
import { NOT_SUPPORT_API_KEY_PROVIDER_TYPES, NOT_SUPPORT_API_KEY_PROVIDERS } from '@renderer/utils/provider' import { NOT_SUPPORT_API_KEY_PROVIDER_TYPES, NOT_SUPPORT_API_KEY_PROVIDERS } from '@renderer/utils/provider'
import { cloneDeep, isEmpty, takeRight } from 'lodash' import { isEmpty, takeRight } from 'lodash'
import type { ModernAiProviderConfig } from '../aiCore/index_new' import type { ModernAiProviderConfig } from '../aiCore/index_new'
import AiProviderNew from '../aiCore/index_new' import AiProviderNew from '../aiCore/index_new'
@ -99,9 +99,11 @@ export async function fetchChatCompletion({
}) })
// Get base provider and apply API key rotation // Get base provider and apply API key rotation
// NOTE: Shallow copy is intentional. Provider objects are not mutated by downstream code.
// Nested properties (if any) are never modified after creation.
const baseProvider = getProviderByModel(assistant.model || getDefaultModel()) const baseProvider = getProviderByModel(assistant.model || getDefaultModel())
const providerWithRotatedKey = { const providerWithRotatedKey = {
...cloneDeep(baseProvider), ...baseProvider,
apiKey: getRotatedApiKey(baseProvider) apiKey: getRotatedApiKey(baseProvider)
} }
@ -183,8 +185,10 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
} }
// Apply API key rotation // Apply API key rotation
// NOTE: Shallow copy is intentional. Provider objects are not mutated by downstream code.
// Nested properties (if any) are never modified after creation.
const providerWithRotatedKey = { const providerWithRotatedKey = {
...cloneDeep(provider), ...provider,
apiKey: getRotatedApiKey(provider) apiKey: getRotatedApiKey(provider)
} }
@ -288,8 +292,10 @@ export async function fetchNoteSummary({ content, assistant }: { content: string
} }
// Apply API key rotation // Apply API key rotation
// NOTE: Shallow copy is intentional. Provider objects are not mutated by downstream code.
// Nested properties (if any) are never modified after creation.
const providerWithRotatedKey = { const providerWithRotatedKey = {
...cloneDeep(provider), ...provider,
apiKey: getRotatedApiKey(provider) apiKey: getRotatedApiKey(provider)
} }
@ -382,8 +388,10 @@ export async function fetchGenerate({
} }
// Apply API key rotation // Apply API key rotation
// NOTE: Shallow copy is intentional. Provider objects are not mutated by downstream code.
// Nested properties (if any) are never modified after creation.
const providerWithRotatedKey = { const providerWithRotatedKey = {
...cloneDeep(provider), ...provider,
apiKey: getRotatedApiKey(provider) apiKey: getRotatedApiKey(provider)
} }
@ -492,8 +500,10 @@ function getRotatedApiKey(provider: Provider): string {
export async function fetchModels(provider: Provider): Promise<Model[]> { export async function fetchModels(provider: Provider): Promise<Model[]> {
// Apply API key rotation // Apply API key rotation
// NOTE: Shallow copy is intentional. Provider objects are not mutated by downstream code.
// Nested properties (if any) are never modified after creation.
const providerWithRotatedKey = { const providerWithRotatedKey = {
...cloneDeep(provider), ...provider,
apiKey: getRotatedApiKey(provider) apiKey: getRotatedApiKey(provider)
} }

View File

@ -7,7 +7,8 @@ import {
captureScrollableAsDataURL, captureScrollableAsDataURL,
compressImage, compressImage,
convertToBase64, convertToBase64,
makeSvgSizeAdaptive makeSvgSizeAdaptive,
parseDataUrlMediaType
} from '../image' } from '../image'
// mock 依赖 // mock 依赖
@ -201,4 +202,36 @@ describe('utils/image', () => {
expect(result.outerHTML).toBe(originalOuterHTML) expect(result.outerHTML).toBe(originalOuterHTML)
}) })
}) })
describe('parseDataUrlMediaType', () => {
it('extracts media type and base64 flag from standard data url', () => {
const r = parseDataUrlMediaType('data:image/png;base64,AAA')
expect(r.mediaType).toBe('image/png')
expect(r.isBase64).toBe(true)
})
it('handles additional parameters in header', () => {
const r = parseDataUrlMediaType('data:image/jpeg;name=foo;base64,AAA')
expect(r.mediaType).toBe('image/jpeg')
expect(r.isBase64).toBe(true)
})
it('returns undefined media type when missing and detects non-base64', () => {
const r = parseDataUrlMediaType('data:text/plain,hello')
expect(r.mediaType).toBe('text/plain')
expect(r.isBase64).toBe(false)
})
it('handles empty mediatype header', () => {
const r = parseDataUrlMediaType('data:;base64,AAA')
expect(r.mediaType).toBeUndefined()
expect(r.isBase64).toBe(true)
})
it('gracefully handles non data urls', () => {
const r = parseDataUrlMediaType('https://example.com/x.png')
expect(r.mediaType).toBeUndefined()
expect(r.isBase64).toBe(false)
})
})
}) })

View File

@ -617,3 +617,23 @@ export const convertImageToPng = async (blob: Blob): Promise<Blob> => {
img.src = url img.src = url
}) })
} }
/**
* Parse media type from a data URL without using heavy regular expressions.
*
* data:[<mediatype>][;base64],<data>
* - mediatype may be empty (defaults to text/plain;charset=US-ASCII per spec)
* - we only care about extracting media type and whether it's base64
*/
export function parseDataUrlMediaType(url: string): { mediaType?: string; isBase64: boolean } {
if (!url.startsWith('data:')) return { isBase64: false }
const comma = url.indexOf(',')
if (comma === -1) return { isBase64: false }
// strip leading 'data:' and take header portion only
const header = url.slice(5, comma)
const semi = header.indexOf(';')
const mediaType = (semi === -1 ? header : header.slice(0, semi)).trim() || undefined
// base64 flag may appear anywhere after mediatype in the header
const isBase64 = header.indexOf(';base64') !== -1
return { mediaType, isBase64 }
}