From 76ee67d4d7c6c8de1efb2ead772028f29991033a Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 6 Jan 2026 00:34:14 +0800 Subject: [PATCH] fix: prevent OOM when handling large base64 image data (#12244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude --- packages/shared/__tests__/utils.test.ts | 138 ++++++++++++++++++ packages/shared/utils.ts | 78 ++++++++++ src/main/services/FileStorage.ts | 9 +- .../aiCore/prepareParams/messageConverter.ts | 24 ++- src/renderer/src/components/ImageViewer.tsx | 14 +- src/renderer/src/i18n/locales/en-us.json | 3 + src/renderer/src/i18n/locales/zh-cn.json | 3 + src/renderer/src/i18n/locales/zh-tw.json | 3 + .../pages/home/Messages/Blocks/ErrorBlock.tsx | 122 ++++++++++++---- .../src/utils/__tests__/image.test.ts | 35 +---- src/renderer/src/utils/file.ts | 6 +- src/renderer/src/utils/image.ts | 20 --- vitest.config.ts | 12 ++ 13 files changed, 362 insertions(+), 105 deletions(-) create mode 100644 packages/shared/__tests__/utils.test.ts diff --git a/packages/shared/__tests__/utils.test.ts b/packages/shared/__tests__/utils.test.ts new file mode 100644 index 0000000000..7682270add --- /dev/null +++ b/packages/shared/__tests__/utils.test.ts @@ -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,')).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) + }) +}) diff --git a/packages/shared/utils.ts b/packages/shared/utils.ts index 7e90624aba..1afd7cbf8b 100644 --- a/packages/shared/utils.ts +++ b/packages/shared/utils.ts @@ -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:[][;base64], + * + * @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') +} diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 2d7520ca67..a82094efcb 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -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) diff --git a/src/renderer/src/aiCore/prepareParams/messageConverter.ts b/src/renderer/src/aiCore/prepareParams/messageConverter.ts index c3798c1f43..56c5f6a4e7 100644 --- a/src/renderer/src/aiCore/prepareParams/messageConverter.ts +++ b/src/renderer/src/aiCore/prepareParams/messageConverter.ts @@ -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 }) diff --git a/src/renderer/src/components/ImageViewer.tsx b/src/renderer/src/components/ImageViewer.tsx index 21bcee025f..22c4c884d5 100644 --- a/src/renderer/src/components/ImageViewer.tsx +++ b/src/renderer/src/components/ImageViewer.tsx @@ -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 = ({ 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) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 41d5933311..7ac425c54a 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 252758d6e5..e598016fe2 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "无法找到原始用户消息", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index e1bc20d092..ec39a08058 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "無法找到原始使用者訊息", diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index b8d1950df7..43e06790a3 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -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 ( <> {cause && ( - {t('error.cause')}: + + {t('error.cause')}:{isTruncated && {t('error.truncatedBadge')}} +
{ ) } +// 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 ( + + + {label}:{truncated && {t('error.truncatedBadge')}} + + {isLikelyBase64 ? ( + {content} + ) : ( + + )} + + ) +} + const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => { const { t } = useTranslation() @@ -360,14 +448,7 @@ const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => { )} {isSerializedAiSdkAPICallError(error) && ( - <> - {error.responseBody && ( - - {t('error.responseBody')}: - - - )} - + <>{error.responseBody && } )} {(isSerializedAiSdkAPICallError(error) || isSerializedAiSdkDownloadError(error)) && ( @@ -396,23 +477,10 @@ const AiSdkError = ({ error }: { error: SerializedAiSdkErrorUnion }) => { )} {error.requestBodyValues && ( - - {t('error.requestBodyValues')}: - - + )} - {error.data && ( - - {t('error.data')}: - - - )} + {error.data && } )} diff --git a/src/renderer/src/utils/__tests__/image.test.ts b/src/renderer/src/utils/__tests__/image.test.ts index 3f35e7c737..0e02957395 100644 --- a/src/renderer/src/utils/__tests__/image.test.ts +++ b/src/renderer/src/utils/__tests__/image.test.ts @@ -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) - }) - }) }) diff --git a/src/renderer/src/utils/file.ts b/src/renderer/src/utils/file.ts index 1d4bf4577c..c1a68ee2eb 100644 --- a/src/renderer/src/utils/file.ts +++ b/src/renderer/src/utils/file.ts @@ -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' } diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index 6f8d8c3d18..3d4824549a 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -617,23 +617,3 @@ export const convertImageToPng = async (blob: Blob): Promise => { img.src = url }) } - -/** - * Parse media type from a data URL without using heavy regular expressions. - * - * data:[][;base64], - * - 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 } -} diff --git a/vitest.config.ts b/vitest.config.ts index a245f7a416..e91d014b6b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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}' + ] + } } ], // 全局共享配置