Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex 2026-01-06 12:10:00 +08:00
commit d60ed5d9e4
13 changed files with 362 additions and 105 deletions

View 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)
})
})

View File

@ -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')
}

View File

@ -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)

View File

@ -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 })

View File

@ -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 as unknown as BlobPart], { 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 as unknown as BlobPart], { type: parseResult.mediaType })
} else if (src.startsWith('file://')) {
// 处理本地文件路径
const bytes = await window.api.fs.read(src)

View File

@ -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",

View File

@ -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": "无法找到原始用户消息",

View File

@ -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": "無法找到原始使用者訊息",

View File

@ -33,6 +33,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 { Alert as AntdAlert, Modal } from 'antd'
import React, { useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@ -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
@ -273,6 +307,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()
@ -307,13 +351,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 || '')
}
@ -321,14 +384,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"
@ -341,6 +406,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()
@ -358,14 +446,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)) && (
@ -394,23 +475,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')} />}
</>
)}

View File

@ -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)
})
})
})

View File

@ -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'
}

View File

@ -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 }
}

View File

@ -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}'
]
}
}
],
// 全局共享配置