mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-08 22:39:36 +08:00
fix: prevent OOM when handling large base64 image data (#12244)
* fix: prevent OOM when handling large base64 image data - Add memory-safe parseDataUrl utility using string operations instead of regex - Truncate large base64 data in ErrorBlock detail modal to prevent freezing - Update ImageViewer, FileStorage, messageConverter to use shared parseDataUrl - Deprecate parseDataUrlMediaType in favor of shared utility - Add GB support to formatFileSize - Add comprehensive unit tests for parseDataUrl (18 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: simplify parseDataUrl API to return DataUrlParts | null - Change return type from discriminated union to simple nullable type - Update all call sites to use optional chaining (?.) - Update tests to use toBeNull() for failure cases - More idiomatic and consistent with codebase patterns (e.g., parseJSON) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2a31fa2ad5
commit
76ee67d4d7
138
packages/shared/__tests__/utils.test.ts
Normal file
138
packages/shared/__tests__/utils.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isBase64ImageDataUrl, isDataUrl, parseDataUrl } from '../utils'
|
||||
|
||||
describe('parseDataUrl', () => {
|
||||
it('parses a standard base64 image data URL', () => {
|
||||
const result = parseDataUrl('data:image/png;base64,iVBORw0KGgo=')
|
||||
expect(result).toEqual({
|
||||
mediaType: 'image/png',
|
||||
isBase64: true,
|
||||
data: 'iVBORw0KGgo='
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a base64 data URL with additional parameters', () => {
|
||||
const result = parseDataUrl('data:image/jpeg;name=foo;base64,/9j/4AAQ')
|
||||
expect(result).toEqual({
|
||||
mediaType: 'image/jpeg',
|
||||
isBase64: true,
|
||||
data: '/9j/4AAQ'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a plain text data URL (non-base64)', () => {
|
||||
const result = parseDataUrl('data:text/plain,Hello%20World')
|
||||
expect(result).toEqual({
|
||||
mediaType: 'text/plain',
|
||||
isBase64: false,
|
||||
data: 'Hello%20World'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a data URL with empty media type', () => {
|
||||
const result = parseDataUrl('data:;base64,SGVsbG8=')
|
||||
expect(result).toEqual({
|
||||
mediaType: undefined,
|
||||
isBase64: true,
|
||||
data: 'SGVsbG8='
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-data URLs', () => {
|
||||
const result = parseDataUrl('https://example.com/image.png')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for malformed data URL without comma', () => {
|
||||
const result = parseDataUrl('data:image/png;base64')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
const result = parseDataUrl('')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('handles large base64 data without performance issues', () => {
|
||||
// Simulate a 4K image base64 string (about 1MB)
|
||||
const largeData = 'A'.repeat(1024 * 1024)
|
||||
const dataUrl = `data:image/png;base64,${largeData}`
|
||||
|
||||
const start = performance.now()
|
||||
const result = parseDataUrl(dataUrl)
|
||||
const duration = performance.now() - start
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.mediaType).toBe('image/png')
|
||||
expect(result?.isBase64).toBe(true)
|
||||
expect(result?.data).toBe(largeData)
|
||||
// Should complete in under 10ms (string operations are fast)
|
||||
expect(duration).toBeLessThan(10)
|
||||
})
|
||||
|
||||
it('parses SVG data URL', () => {
|
||||
const result = parseDataUrl('data:image/svg+xml;base64,PHN2Zz4=')
|
||||
expect(result).toEqual({
|
||||
mediaType: 'image/svg+xml',
|
||||
isBase64: true,
|
||||
data: 'PHN2Zz4='
|
||||
})
|
||||
})
|
||||
|
||||
it('parses JSON data URL', () => {
|
||||
const result = parseDataUrl('data:application/json,{"key":"value"}')
|
||||
expect(result).toEqual({
|
||||
mediaType: 'application/json',
|
||||
isBase64: false,
|
||||
data: '{"key":"value"}'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDataUrl', () => {
|
||||
it('returns true for valid data URLs', () => {
|
||||
expect(isDataUrl('data:image/png;base64,ABC')).toBe(true)
|
||||
expect(isDataUrl('data:text/plain,hello')).toBe(true)
|
||||
expect(isDataUrl('data:,simple')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-data URLs', () => {
|
||||
expect(isDataUrl('https://example.com')).toBe(false)
|
||||
expect(isDataUrl('file:///path/to/file')).toBe(false)
|
||||
expect(isDataUrl('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for malformed data URLs', () => {
|
||||
expect(isDataUrl('data:')).toBe(false)
|
||||
expect(isDataUrl('data:image/png')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBase64ImageDataUrl', () => {
|
||||
it('returns true for base64 image data URLs', () => {
|
||||
expect(isBase64ImageDataUrl('data:image/png;base64,ABC')).toBe(true)
|
||||
expect(isBase64ImageDataUrl('data:image/jpeg;base64,/9j/')).toBe(true)
|
||||
expect(isBase64ImageDataUrl('data:image/gif;base64,R0lG')).toBe(true)
|
||||
expect(isBase64ImageDataUrl('data:image/webp;base64,UklG')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-base64 image data URLs', () => {
|
||||
expect(isBase64ImageDataUrl('data:image/svg+xml,<svg></svg>')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-image data URLs', () => {
|
||||
expect(isBase64ImageDataUrl('data:text/plain;base64,SGVsbG8=')).toBe(false)
|
||||
expect(isBase64ImageDataUrl('data:application/json,{}')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular URLs', () => {
|
||||
expect(isBase64ImageDataUrl('https://example.com/image.png')).toBe(false)
|
||||
expect(isBase64ImageDataUrl('file:///image.png')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for malformed data URLs', () => {
|
||||
expect(isBase64ImageDataUrl('data:image/png')).toBe(false)
|
||||
expect(isBase64ImageDataUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -88,3 +88,81 @@ const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i
|
||||
export function withoutTrailingApiVersion(url: string): string {
|
||||
return url.replace(TRAILING_VERSION_REGEX, '')
|
||||
}
|
||||
|
||||
export interface DataUrlParts {
|
||||
/** The media type (e.g., 'image/png', 'text/plain') */
|
||||
mediaType?: string
|
||||
/** Whether the data is base64 encoded */
|
||||
isBase64: boolean
|
||||
/** The data portion (everything after the comma). This is the raw string, not decoded. */
|
||||
data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a data URL into its component parts without using regex on the data portion.
|
||||
* This is memory-safe for large data URLs (e.g., 4K images) as it uses indexOf instead of regex.
|
||||
*
|
||||
* Data URL format: data:[<mediatype>][;base64],<data>
|
||||
*
|
||||
* @param url - The data URL string to parse
|
||||
* @returns DataUrlParts if valid, null if invalid
|
||||
*
|
||||
* @example
|
||||
* parseDataUrl('data:image/png;base64,iVBORw0KGgo...')
|
||||
* // { mediaType: 'image/png', isBase64: true, data: 'iVBORw0KGgo...' }
|
||||
*
|
||||
* parseDataUrl('data:text/plain,Hello')
|
||||
* // { mediaType: 'text/plain', isBase64: false, data: 'Hello' }
|
||||
*
|
||||
* parseDataUrl('invalid-url')
|
||||
* // null
|
||||
*/
|
||||
export function parseDataUrl(url: string): DataUrlParts | null {
|
||||
if (!url.startsWith('data:')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const commaIndex = url.indexOf(',')
|
||||
if (commaIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const header = url.slice(5, commaIndex)
|
||||
|
||||
const isBase64 = header.includes(';base64')
|
||||
|
||||
const semicolonIndex = header.indexOf(';')
|
||||
const mediaType = (semicolonIndex === -1 ? header : header.slice(0, semicolonIndex)).trim() || undefined
|
||||
|
||||
const data = url.slice(commaIndex + 1)
|
||||
|
||||
return { mediaType, isBase64, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a data URL.
|
||||
*
|
||||
* @param url - The string to check
|
||||
* @returns true if the string is a valid data URL
|
||||
*/
|
||||
export function isDataUrl(url: string): boolean {
|
||||
return url.startsWith('data:') && url.includes(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a data URL contains base64-encoded image data.
|
||||
*
|
||||
* @param url - The data URL to check
|
||||
* @returns true if the URL is a base64-encoded image data URL
|
||||
*/
|
||||
export function isBase64ImageDataUrl(url: string): boolean {
|
||||
if (!url.startsWith('data:image/')) {
|
||||
return false
|
||||
}
|
||||
const commaIndex = url.indexOf(',')
|
||||
if (commaIndex === -1) {
|
||||
return false
|
||||
}
|
||||
const header = url.slice(5, commaIndex)
|
||||
return header.includes(';base64')
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
scanDir
|
||||
} from '@main/utils/file'
|
||||
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
|
||||
import { parseDataUrl } from '@shared/utils'
|
||||
import type { FileMetadata, NotesTreeNode } from '@types'
|
||||
import { FileTypes } from '@types'
|
||||
import chardet from 'chardet'
|
||||
@ -672,8 +673,8 @@ class FileStorage {
|
||||
throw new Error('Base64 data is required')
|
||||
}
|
||||
|
||||
// 移除 base64 头部信息(如果存在)
|
||||
const base64String = base64Data.replace(/^data:.*;base64,/, '')
|
||||
const parseResult = parseDataUrl(base64Data)
|
||||
const base64String = parseResult?.data ?? base64Data
|
||||
const buffer = Buffer.from(base64String, 'base64')
|
||||
const uuid = uuidv4()
|
||||
const ext = '.png'
|
||||
@ -1464,8 +1465,8 @@ class FileStorage {
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
const base64Data = data.replace(/^data:image\/png;base64,/, '')
|
||||
fs.writeFileSync(filePath, base64Data, 'base64')
|
||||
const parseResult = parseDataUrl(data)
|
||||
fs.writeFileSync(filePath, parseResult?.data ?? data, 'base64')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[IPC - Error] An error occurred saving the image:', error as Error)
|
||||
|
||||
@ -8,13 +8,13 @@ import { loggerService } from '@logger'
|
||||
import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models'
|
||||
import type { Message, Model } from '@renderer/types'
|
||||
import type { FileMessageBlock, ImageMessageBlock, ThinkingMessageBlock } from '@renderer/types/newMessage'
|
||||
import { parseDataUrlMediaType } from '@renderer/utils/image'
|
||||
import {
|
||||
findFileBlocks,
|
||||
findImageBlocks,
|
||||
findThinkingBlocks,
|
||||
getMainTextContent
|
||||
} from '@renderer/utils/messageUtils/find'
|
||||
import { parseDataUrl } from '@shared/utils'
|
||||
import type {
|
||||
AssistantModelMessage,
|
||||
FilePart,
|
||||
@ -69,18 +69,16 @@ async function convertImageBlockToImagePart(imageBlocks: ImageMessageBlock[]): P
|
||||
}
|
||||
} else if (imageBlock.url) {
|
||||
const url = imageBlock.url
|
||||
const isDataUrl = url.startsWith('data:')
|
||||
if (isDataUrl) {
|
||||
const { mediaType } = parseDataUrlMediaType(url)
|
||||
const commaIndex = url.indexOf(',')
|
||||
if (commaIndex === -1) {
|
||||
logger.error('Malformed data URL detected (missing comma separator), image will be excluded:', {
|
||||
urlPrefix: url.slice(0, 50) + '...'
|
||||
})
|
||||
continue
|
||||
}
|
||||
const base64Data = url.slice(commaIndex + 1)
|
||||
parts.push({ type: 'image', image: base64Data, ...(mediaType ? { mediaType } : {}) })
|
||||
const parseResult = parseDataUrl(url)
|
||||
if (parseResult?.isBase64) {
|
||||
const { mediaType, data } = parseResult
|
||||
parts.push({ type: 'image', image: data, ...(mediaType ? { mediaType } : {}) })
|
||||
} else if (url.startsWith('data:')) {
|
||||
// Malformed data URL or non-base64 data URL
|
||||
logger.error('Malformed or non-base64 data URL detected, image will be excluded:', {
|
||||
urlPrefix: url.slice(0, 50) + '...'
|
||||
})
|
||||
continue
|
||||
} else {
|
||||
// For remote URLs we keep payload minimal to match existing expectations.
|
||||
parts.push({ type: 'image', image: url })
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import { loggerService } from '@logger'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { convertImageToPng } from '@renderer/utils/image'
|
||||
import { parseDataUrl } from '@shared/utils'
|
||||
import type { ImageProps as AntImageProps } from 'antd'
|
||||
import { Dropdown, Image as AntImage, Space } from 'antd'
|
||||
import { Base64 } from 'js-base64'
|
||||
@ -37,12 +38,13 @@ const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
|
||||
let blob: Blob
|
||||
|
||||
if (src.startsWith('data:')) {
|
||||
// 处理 base64 格式的图片
|
||||
const match = src.match(/^data:(image\/\w+);base64,(.+)$/)
|
||||
if (!match) throw new Error('Invalid base64 image format')
|
||||
const mimeType = match[1]
|
||||
const byteArray = Base64.toUint8Array(match[2])
|
||||
blob = new Blob([byteArray], { type: mimeType })
|
||||
// 处理 base64 格式的图片 - 使用 parseDataUrl 避免正则匹配大字符串导致OOM
|
||||
const parseResult = parseDataUrl(src)
|
||||
if (!parseResult || !parseResult.mediaType || !parseResult.isBase64) {
|
||||
throw new Error('Invalid base64 image format')
|
||||
}
|
||||
const byteArray = Base64.toUint8Array(parseResult.data)
|
||||
blob = new Blob([byteArray], { type: parseResult.mediaType })
|
||||
} else if (src.startsWith('file://')) {
|
||||
// 处理本地文件路径
|
||||
const bytes = await window.api.fs.read(src)
|
||||
|
||||
@ -1297,6 +1297,7 @@
|
||||
"backup": {
|
||||
"file_format": "Backup file format error"
|
||||
},
|
||||
"base64DataTruncated": "Base64 image data truncated, size",
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "Open debug panel",
|
||||
@ -1377,6 +1378,8 @@
|
||||
"text": "Text",
|
||||
"toolInput": "Tool Input",
|
||||
"toolName": "Tool Name",
|
||||
"truncated": "Data truncated, original size",
|
||||
"truncatedBadge": "Truncated",
|
||||
"unknown": "Unknown error",
|
||||
"usage": "Usage",
|
||||
"user_message_not_found": "Cannot find original user message to resend",
|
||||
|
||||
@ -1297,6 +1297,7 @@
|
||||
"backup": {
|
||||
"file_format": "备份文件格式错误"
|
||||
},
|
||||
"base64DataTruncated": "Base64 图片数据已截断,大小",
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "打开调试面板",
|
||||
@ -1377,6 +1378,8 @@
|
||||
"text": "文本",
|
||||
"toolInput": "工具输入",
|
||||
"toolName": "工具名",
|
||||
"truncated": "数据已截断,原始大小",
|
||||
"truncatedBadge": "已截断",
|
||||
"unknown": "未知错误",
|
||||
"usage": "用量",
|
||||
"user_message_not_found": "无法找到原始用户消息",
|
||||
|
||||
@ -1297,6 +1297,7 @@
|
||||
"backup": {
|
||||
"file_format": "備份檔案格式錯誤"
|
||||
},
|
||||
"base64DataTruncated": "Base64 圖片資料已截斷,大小",
|
||||
"boundary": {
|
||||
"default": {
|
||||
"devtools": "開啟除錯面板",
|
||||
@ -1377,6 +1378,8 @@
|
||||
"text": "文字",
|
||||
"toolInput": "工具輸入",
|
||||
"toolName": "工具名稱",
|
||||
"truncated": "資料已截斷,原始大小",
|
||||
"truncatedBadge": "已截斷",
|
||||
"unknown": "未知錯誤",
|
||||
"usage": "用量",
|
||||
"user_message_not_found": "無法找到原始使用者訊息",
|
||||
|
||||
@ -32,6 +32,8 @@ import {
|
||||
} from '@renderer/types/error'
|
||||
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
|
||||
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
|
||||
import { formatFileSize } from '@renderer/utils/file'
|
||||
import { KB } from '@shared/config/constant'
|
||||
import { Button } from 'antd'
|
||||
import { Alert as AntdAlert, Modal } from 'antd'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
@ -41,6 +43,38 @@ import styled from 'styled-components'
|
||||
|
||||
const HTTP_ERROR_CODES = [400, 401, 403, 404, 429, 500, 502, 503, 504]
|
||||
|
||||
const MAX_DISPLAY_SIZE = 100 * KB
|
||||
|
||||
/**
|
||||
* Truncate large data to prevent OOM when displaying error details.
|
||||
* Uses simple string operations to avoid regex performance issues with large strings.
|
||||
*/
|
||||
const truncateLargeData = (
|
||||
data: string,
|
||||
t: (key: string) => string
|
||||
): { content: string; truncated: boolean; isLikelyBase64: boolean } => {
|
||||
if (!data || data.length <= MAX_DISPLAY_SIZE) {
|
||||
return { content: data, truncated: false, isLikelyBase64: false }
|
||||
}
|
||||
|
||||
const isLikelyBase64 = data.includes('data:image/') && data.includes(';base64,')
|
||||
const formattedSize = formatFileSize(data.length)
|
||||
|
||||
if (isLikelyBase64) {
|
||||
return {
|
||||
content: `[${t('error.base64DataTruncated')} ~${formattedSize}]`,
|
||||
truncated: true,
|
||||
isLikelyBase64: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: data.slice(0, MAX_DISPLAY_SIZE) + `\n\n... [${t('error.truncated')} ${formattedSize}]`,
|
||||
truncated: true,
|
||||
isLikelyBase64: false
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
block: ErrorMessageBlock
|
||||
message: Message
|
||||
@ -275,6 +309,16 @@ const Alert = styled(AntdAlert)`
|
||||
}
|
||||
`
|
||||
|
||||
const TruncatedBadge = styled.span`
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
color: var(--color-warning);
|
||||
background: var(--color-warning-bg, rgba(250, 173, 20, 0.1));
|
||||
border-radius: 4px;
|
||||
`
|
||||
|
||||
// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染
|
||||
const BuiltinError = ({ error }: { error: SerializedError }) => {
|
||||
const { t } = useTranslation()
|
||||
@ -309,13 +353,32 @@ const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
|
||||
const { t } = useTranslation()
|
||||
const { highlightCode } = useCodeStyle()
|
||||
const [highlightedString, setHighlightedString] = useState('')
|
||||
const [isTruncated, setIsTruncated] = useState(false)
|
||||
const cause = error.cause
|
||||
|
||||
useEffect(() => {
|
||||
const highlight = async () => {
|
||||
try {
|
||||
const result = await highlightCode(JSON.stringify(JSON.parse(cause || '{}'), null, 2), 'json')
|
||||
setHighlightedString(result)
|
||||
// Truncate large data before processing to prevent OOM
|
||||
const { content: truncatedCause, truncated, isLikelyBase64 } = truncateLargeData(cause || '', t)
|
||||
setIsTruncated(truncated)
|
||||
|
||||
// Skip JSON parsing and syntax highlighting for base64 data
|
||||
if (isLikelyBase64) {
|
||||
setHighlightedString(truncatedCause)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to parse and format JSON
|
||||
try {
|
||||
const parsed = JSON.parse(truncatedCause || '{}')
|
||||
const formatted = JSON.stringify(parsed, null, 2)
|
||||
const result = await highlightCode(formatted, 'json')
|
||||
setHighlightedString(result)
|
||||
} catch {
|
||||
// If not valid JSON, use as-is
|
||||
setHighlightedString(truncatedCause || '')
|
||||
}
|
||||
} catch {
|
||||
setHighlightedString(cause || '')
|
||||
}
|
||||
@ -323,14 +386,16 @@ const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
|
||||
const timer = setTimeout(highlight, 0)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [highlightCode, cause])
|
||||
}, [highlightCode, cause, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BuiltinError error={error} />
|
||||
{cause && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
|
||||
<ErrorDetailLabel>
|
||||
{t('error.cause')}:{isTruncated && <TruncatedBadge>{t('error.truncatedBadge')}</TruncatedBadge>}
|
||||
</ErrorDetailLabel>
|
||||
<ErrorDetailValue>
|
||||
<div
|
||||
className="markdown [&_pre]:!bg-transparent [&_pre_span]:whitespace-pre-wrap"
|
||||
@ -343,6 +408,29 @@ const AiSdkErrorBase = ({ error }: { error: SerializedAiSdkError }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Wrapper component to safely display potentially large data in CodeViewer
|
||||
const TruncatedCodeViewer: React.FC<{
|
||||
value: string
|
||||
label: string
|
||||
language?: string
|
||||
}> = ({ value, label, language = 'json' }) => {
|
||||
const { t } = useTranslation()
|
||||
const { content, truncated, isLikelyBase64 } = truncateLargeData(value, t)
|
||||
|
||||
return (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>
|
||||
{label}:{truncated && <TruncatedBadge>{t('error.truncatedBadge')}</TruncatedBadge>}
|
||||
</ErrorDetailLabel>
|
||||
{isLikelyBase64 ? (
|
||||
<ErrorDetailValue>{content}</ErrorDetailValue>
|
||||
) : (
|
||||
<CodeViewer value={content} className="source-view" language={language} expanded />
|
||||
)}
|
||||
</ErrorDetailItem>
|
||||
)
|
||||
}
|
||||
|
||||
const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -360,14 +448,7 @@ const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => {
|
||||
)}
|
||||
|
||||
{isSerializedAiSdkAPICallError(error) && (
|
||||
<>
|
||||
{error.responseBody && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.responseBody')}:</ErrorDetailLabel>
|
||||
<CodeViewer value={error.responseBody} className="source-view" language="json" expanded />
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
</>
|
||||
<>{error.responseBody && <TruncatedCodeViewer value={error.responseBody} label={t('error.responseBody')} />}</>
|
||||
)}
|
||||
|
||||
{(isSerializedAiSdkAPICallError(error) || isSerializedAiSdkDownloadError(error)) && (
|
||||
@ -396,23 +477,10 @@ const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => {
|
||||
)}
|
||||
|
||||
{error.requestBodyValues && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.requestBodyValues')}:</ErrorDetailLabel>
|
||||
<CodeViewer
|
||||
value={safeToString(error.requestBodyValues)}
|
||||
className="source-view"
|
||||
language="json"
|
||||
expanded
|
||||
/>
|
||||
</ErrorDetailItem>
|
||||
<TruncatedCodeViewer value={safeToString(error.requestBodyValues)} label={t('error.requestBodyValues')} />
|
||||
)}
|
||||
|
||||
{error.data && (
|
||||
<ErrorDetailItem>
|
||||
<ErrorDetailLabel>{t('error.data')}:</ErrorDetailLabel>
|
||||
<CodeViewer value={safeToString(error.data)} className="source-view" language="json" expanded />
|
||||
</ErrorDetailItem>
|
||||
)}
|
||||
{error.data && <TruncatedCodeViewer value={safeToString(error.data)} label={t('error.data')} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -7,8 +7,7 @@ import {
|
||||
captureScrollableAsDataURL,
|
||||
compressImage,
|
||||
convertToBase64,
|
||||
makeSvgSizeAdaptive,
|
||||
parseDataUrlMediaType
|
||||
makeSvgSizeAdaptive
|
||||
} from '../image'
|
||||
|
||||
// mock 依赖
|
||||
@ -202,36 +201,4 @@ describe('utils/image', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { FileMetadata } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types'
|
||||
import { audioExts, documentExts, imageExts, KB, MB, textExts, videoExts } from '@shared/config/constant'
|
||||
import { audioExts, documentExts, GB, imageExts, KB, MB, textExts, videoExts } from '@shared/config/constant'
|
||||
import mime from 'mime-types'
|
||||
|
||||
/**
|
||||
@ -46,6 +46,10 @@ export function removeFileExtension(filePath: string): string {
|
||||
* @returns {string} 格式化后的文件大小字符串
|
||||
*/
|
||||
export function formatFileSize(size: number): string {
|
||||
if (size >= GB) {
|
||||
return (size / GB).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
if (size >= MB) {
|
||||
return (size / MB).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
@ -617,23 +617,3 @@ export const convertImageToPng = async (blob: Blob): Promise<Blob> => {
|
||||
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 }
|
||||
}
|
||||
|
||||
@ -56,6 +56,18 @@ export default defineConfig({
|
||||
'packages/aiCore/**/__tests__/**/*.{test,spec}.{ts,tsx}'
|
||||
]
|
||||
}
|
||||
},
|
||||
// shared 包单元测试配置
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: 'shared',
|
||||
environment: 'node',
|
||||
include: [
|
||||
'packages/shared/**/*.{test,spec}.{ts,tsx}',
|
||||
'packages/shared/**/__tests__/**/*.{test,spec}.{ts,tsx}'
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
// 全局共享配置
|
||||
|
||||
Loading…
Reference in New Issue
Block a user