diff --git a/packages/core/src/services/evaluation/service.ts b/packages/core/src/services/evaluation/service.ts index df8bf098..00e563c7 100644 --- a/packages/core/src/services/evaluation/service.ts +++ b/packages/core/src/services/evaluation/service.ts @@ -200,6 +200,75 @@ export class EvaluationService implements IEvaluationService { this.imageUnderstandingService = dependencies.imageUnderstandingService; } + private formatExecutionErrorMessage(error: unknown, fallback = 'Unknown evaluation execution error'): string { + if (typeof error === 'string') { + return error.trim() || fallback; + } + + if (error instanceof Error) { + const message = error.message?.trim(); + if (message && !/^\[object .+\]$/.test(message)) { + return message; + } + + const cause = (error as Error & { cause?: unknown }).cause; + if (cause !== undefined) { + return this.formatExecutionErrorMessage(cause, fallback); + } + + return message || fallback; + } + + if (error && typeof error === 'object') { + const record = error as Record; + const directMessage = record.message; + if (typeof directMessage === 'string' && directMessage.trim() && !/^\[object .+\]$/.test(directMessage.trim())) { + return directMessage.trim(); + } + if (directMessage !== undefined && directMessage !== error) { + return this.formatExecutionErrorMessage(directMessage, fallback); + } + + const nestedError = record.error; + if (nestedError !== undefined && nestedError !== error) { + const nestedMessage = this.formatExecutionErrorMessage(nestedError, ''); + if (nestedMessage) { + return nestedMessage; + } + } + + const status = record.status ?? record.statusCode ?? (record.response as Record | undefined)?.status; + const body = record.body ?? record.data ?? (record.response as Record | undefined)?.data; + const bodyMessage = body !== undefined && body !== error + ? this.formatExecutionErrorMessage(body, '') + : ''; + + if (status !== undefined) { + return bodyMessage + ? `HTTP ${String(status)}: ${bodyMessage}` + : `HTTP ${String(status)} error`; + } + + try { + return JSON.stringify(error); + } catch { + return fallback; + } + } + + if (error === null || error === undefined) { + return fallback; + } + + return String(error); + } + + private toError(error: unknown): Error { + return error instanceof Error + ? error + : new Error(this.formatExecutionErrorMessage(error)); + } + /** * 执行评估(非流式) */ @@ -251,7 +320,7 @@ export class EvaluationService implements IEvaluationService { return this.parseEvaluationResult(result, request.type, responseMetadata); } catch (error) { throw new EvaluationExecutionError( - error instanceof Error ? error.message : String(error), + this.formatExecutionErrorMessage(error), error instanceof Error ? error : undefined ); } @@ -267,7 +336,7 @@ export class EvaluationService implements IEvaluationService { try { this.validateRequest(request); } catch (error) { - callbacks.onError(error instanceof Error ? error : new Error(String(error))); + callbacks.onError(this.toError(error)); return; } @@ -275,7 +344,7 @@ export class EvaluationService implements IEvaluationService { const modelConfig = await this.validateModel(request.evaluationModelKey); await this.evaluateStreamInternal(request, callbacks, modelConfig); } catch (error) { - callbacks.onError(error instanceof Error ? error : new Error(String(error))); + callbacks.onError(this.toError(error)); } } @@ -291,7 +360,7 @@ export class EvaluationService implements IEvaluationService { try { await this.evaluateStructuredCompareStream(request, normalizedCompare, callbacks); } catch (error) { - callbacks.onError(error instanceof Error ? error : new Error(String(error))); + callbacks.onError(this.toError(error)); } return; } @@ -301,7 +370,7 @@ export class EvaluationService implements IEvaluationService { try { template = await this.getEvaluationTemplate(request.type, request.mode); } catch (error) { - callbacks.onError(error instanceof Error ? error : new Error(String(error))); + callbacks.onError(this.toError(error)); return; } @@ -328,7 +397,7 @@ export class EvaluationService implements IEvaluationService { ); callbacks.onComplete(response); } catch (error) { - callbacks.onError(error instanceof Error ? error : new Error(String(error))); + callbacks.onError(this.toError(error)); } return; } @@ -357,11 +426,11 @@ export class EvaluationService implements IEvaluationService { ); callbacks.onComplete(response); } catch (error) { - callbacks.onError(error instanceof Error ? error : new Error(String(error))); + callbacks.onError(this.toError(error)); } }, onError: (error) => { - callbacks.onError(new EvaluationExecutionError(error.message, error)); + callbacks.onError(new EvaluationExecutionError(this.formatExecutionErrorMessage(error), error)); }, }; @@ -370,7 +439,7 @@ export class EvaluationService implements IEvaluationService { } catch (error) { callbacks.onError( new EvaluationExecutionError( - error instanceof Error ? error.message : String(error), + this.formatExecutionErrorMessage(error), error instanceof Error ? error : undefined ) ); @@ -2053,7 +2122,7 @@ export class EvaluationService implements IEvaluationService { return this.parseEvaluationResult(synthesisResult, request.type, responseMetadata); } catch (error) { throw new EvaluationExecutionError( - error instanceof Error ? error.message : String(error), + this.formatExecutionErrorMessage(error), error instanceof Error ? error : undefined ); } @@ -2114,17 +2183,17 @@ export class EvaluationService implements IEvaluationService { ); callbacks.onComplete(response); } catch (error) { - callbacks.onError(error instanceof Error ? error : new Error(String(error))); + callbacks.onError(this.toError(error)); } }, onError: (error) => { - callbacks.onError(new EvaluationExecutionError(error.message, error)); + callbacks.onError(new EvaluationExecutionError(this.formatExecutionErrorMessage(error), error)); }, }); } catch (error) { callbacks.onError( new EvaluationExecutionError( - error instanceof Error ? error.message : String(error), + this.formatExecutionErrorMessage(error), error instanceof Error ? error : undefined ) ); diff --git a/packages/core/tests/unit/evaluation/service.test.ts b/packages/core/tests/unit/evaluation/service.test.ts index bfcf9b43..598215bb 100644 --- a/packages/core/tests/unit/evaluation/service.test.ts +++ b/packages/core/tests/unit/evaluation/service.test.ts @@ -1,6 +1,7 @@ import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest' import { EvaluationService } from '../../../src/services/evaluation/service' import { + EvaluationExecutionError, EvaluationValidationError, EvaluationModelError, EvaluationTemplateError, @@ -351,6 +352,30 @@ describe('EvaluationService', () => { }) }) + describe('execution errors', () => { + it('formats plain object provider errors without leaking [object Object]', async () => { + mockLLMService.sendMessage.mockRejectedValueOnce({ + status: 429, + body: { + error: { + message: 'rate limit exceeded', + }, + }, + }) + + try { + await evaluationService.evaluate(createPromptOnlyRequest()) + throw new Error('Expected evaluation to fail') + } catch (error) { + expect(error).toBeInstanceOf(EvaluationExecutionError) + expect((error as Error).message).toContain('HTTP 429') + expect((error as Error).message).toContain('rate limit exceeded') + expect((error as Error).message).not.toContain('[object Object]') + expect((error as EvaluationExecutionError).params?.details).not.toContain('[object Object]') + } + }) + }) + describe('evaluateStream', () => { it('calls callbacks correctly for prompt-only', async () => { const onToken = vi.fn() diff --git a/packages/ui/src/utils/error.ts b/packages/ui/src/utils/error.ts index 61af7c97..f213754c 100644 --- a/packages/ui/src/utils/error.ts +++ b/packages/ui/src/utils/error.ts @@ -36,6 +36,19 @@ export class AppError extends Error { } } +function isObjectString(value: string): boolean { + return /^\[object .+\]$/.test(value) +} + +function stringifyUnknownObject(value: unknown, fallback: string): string { + try { + const serialized = JSON.stringify(value) + return serialized && !isObjectString(serialized) ? serialized : fallback + } catch { + return fallback + } +} + /** * 从未知类型的错误中提取错误消息 * @param error - 未知类型的错误对象 @@ -44,10 +57,20 @@ export class AppError extends Error { */ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string { if (error instanceof Error) { - return error.message + const message = error.message?.trim() + if (message && !isObjectString(message)) { + return message + } + + const cause = (error as Error & { cause?: unknown }).cause + if (cause !== undefined) { + return getErrorMessage(cause, fallback) + } + + return message || fallback } if (typeof error === 'string') { - return error + return error.trim() && !isObjectString(error.trim()) ? error : fallback } if (error === null || error === undefined) { return fallback @@ -57,8 +80,30 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str if (typeof error === 'object') { const maybeMessage = (error as { message?: unknown }).message if (typeof maybeMessage === 'string' && maybeMessage.trim()) { - return maybeMessage + return isObjectString(maybeMessage.trim()) ? fallback : maybeMessage } + + if (maybeMessage !== undefined && maybeMessage !== error) { + return getErrorMessage(maybeMessage, fallback) + } + + const maybeError = (error as { error?: unknown }).error + if (maybeError !== undefined && maybeError !== error) { + return getErrorMessage(maybeError, fallback) + } + + const status = (error as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } }).status + ?? (error as { statusCode?: unknown }).statusCode + ?? (error as { response?: { status?: unknown } }).response?.status + const body = (error as { body?: unknown; data?: unknown; response?: { data?: unknown } }).body + ?? (error as { data?: unknown }).data + ?? (error as { response?: { data?: unknown } }).response?.data + if (status !== undefined) { + const detail = body !== undefined && body !== error ? getErrorMessage(body, '') : '' + return detail ? `HTTP ${String(status)}: ${detail}` : `HTTP ${String(status)} error` + } + + return stringifyUnknownObject(error, fallback) } try { @@ -72,6 +117,30 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } +function hasReadableErrorShape(value: Record): boolean { + return ( + 'message' in value || + 'error' in value || + 'status' in value || + 'statusCode' in value || + 'response' in value || + 'code' in value + ) +} + +function normalizeI18nParams(params: Record | undefined): Record | undefined { + if (!params) return undefined + + return Object.fromEntries( + Object.entries(params).map(([key, value]) => [ + key, + isRecord(value) || Array.isArray(value) + ? getErrorMessage(value, stringifyUnknownObject(value, 'Unknown error')) + : value, + ]) + ) +} + /** * 将结构化错误(code + params)转换为用户可读的 i18n 文案。 * @@ -91,7 +160,7 @@ export function getI18nErrorMessage(error: unknown, fallback = 'Unknown error'): const hasKey = i18n.global.te(code) if (hasKey) { try { - return i18n.global.t(code, params ?? {}) + return i18n.global.t(code, normalizeI18nParams(params) ?? {}) } catch { // If interpolation fails for any reason, fall back to raw error message. } @@ -111,7 +180,13 @@ export function getI18nErrorMessage(error: unknown, fallback = 'Unknown error'): export function formatErrorSummary(summary: string, error: unknown, fallback = 'Unknown error'): string { const normalizedSummary = summary.trim() const detail = getI18nErrorMessage(error, fallback).trim() + const suppressPlainObjectDetail = + isRecord(error) && + !(error instanceof Error) && + !Array.isArray(error) && + !hasReadableErrorShape(error) const hasMeaningfulDetail = + !suppressPlainObjectDetail && detail && detail !== fallback && detail !== normalizedSummary && diff --git a/packages/ui/tests/unit/utils/error-i18n.spec.ts b/packages/ui/tests/unit/utils/error-i18n.spec.ts index c5baf19f..8e324254 100644 --- a/packages/ui/tests/unit/utils/error-i18n.spec.ts +++ b/packages/ui/tests/unit/utils/error-i18n.spec.ts @@ -59,6 +59,44 @@ describe('getI18nErrorMessage', () => { expect(msg).toBe('RAW_MESSAGE') }) + it('plain object provider errors are formatted without [object Object]', () => { + const msg = getI18nErrorMessage({ + status: 429, + body: { + error: { + message: 'rate limit exceeded', + }, + }, + }) + + expect(msg).toContain('HTTP 429') + expect(msg).toContain('rate limit exceeded') + expect(msg).not.toContain('[object Object]') + }) + + it('normalizes object i18n params before interpolation', () => { + setLocale('zh-CN') + + const msg = getI18nErrorMessage({ + code: 'error.evaluation.execution', + params: { + details: { + status: 429, + body: { + error: { + message: 'rate limit exceeded', + }, + }, + }, + }, + }) + + expect(msg).toContain('评估执行错误') + expect(msg).toContain('HTTP 429') + expect(msg).toContain('rate limit exceeded') + expect(msg).not.toContain('[object Object]') + }) + it('formatErrorSummary 在只有英文 fallback 时不重复拼接详情', () => { const result = formatErrorSummary('Failed to save configuration', { foo: 'bar' }, 'Unknown error') expect(result).toBe('Failed to save configuration')