feat(错误处理): 增强AI SDK错误处理并添加国际化支持

添加AiSdkErrorUnion类型用于统一处理AI SDK错误
实现safeToString函数安全转换任意值为字符串
添加formatAiSdkError和formatError函数格式化错误信息
在ErrorBlock中重构错误详情展示逻辑,支持多种错误类型
补充国际化字段用于错误信息展示
This commit is contained in:
icarus 2025-09-03 23:55:14 +08:00
parent 5aa8f3901f
commit 115368cbdd
4 changed files with 245 additions and 40 deletions

View File

@ -831,6 +831,7 @@
"invalid": "无效的MCP服务器"
}
},
"cause": "错误原因",
"chat": {
"chunk": {
"non_json": "返回了无效的数据格式"
@ -840,6 +841,7 @@
"quota_exceeded": "您今日免费配额已用尽,请前往 <provider>{{provider}}</provider> 获取API密钥配置API密钥后继续使用",
"response": "出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥"
},
"data": "数据",
"detail": "错误详情",
"details": "详细信息",
"http": {
@ -859,6 +861,7 @@
"exists": "模型已存在",
"not_exists": "模型不存在"
},
"name": "错误名称",
"no_api_key": "API 密钥未配置",
"pause_placeholder": "已中断",
"provider_disabled": "模型提供商未启用",
@ -867,9 +870,13 @@
"title": "渲染错误"
},
"requestBody": "请求内容",
"requestBodyValues": "请求体",
"requestUrl": "请求路径",
"responseBody": "响应内容",
"responseHeaders": "响应首部",
"stack": "堆栈信息",
"status": "状态码",
"statusCode": "状态码",
"unknown": "未知错误",
"user_message_not_found": "无法找到原始用户消息"
},

View File

@ -5,6 +5,8 @@ import { getProviderById } from '@renderer/services/ProviderService'
import { useAppDispatch } from '@renderer/store'
import { removeBlocksThunk } from '@renderer/store/thunk/messageThunk'
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
import { AISDKError, APICallError } from 'ai'
import { Alert as AntdAlert, Button, Modal } from 'antd'
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@ -131,53 +133,41 @@ const ErrorDetailModal: React.FC<ErrorDetailModalProps> = ({ open, onClose, erro
const copyErrorDetails = () => {
if (!error) return
const errorText = `
${t('error.message')}: ${error.message || 'N/A'}
${t('error.requestUrl')}: ${error.url || 'N/A'}
${t('error.requestBody')}: ${error.requestBody ? JSON.stringify(error.requestBody, null, 2) : 'N/A'}
${t('error.stack')}: ${error.stack || 'N/A'}
`.trim()
let errorText: string
if (AISDKError.isInstance(error)) {
errorText = formatAiSdkError(error)
} else if (error instanceof Error) {
errorText = formatError(error)
} else {
// fallback
errorText = safeToString(error)
}
navigator.clipboard.writeText(errorText)
}
const renderErrorDetails = (error: any) => {
const renderErrorDetails = (error?: Record<string, any>) => {
if (!error) return <div>{t('error.unknown')}</div>
if (APICallError.isInstance(error)) {
return <AiApiCallError error={error} />
}
if (AISDKError.isInstance(error)) {
return <AiSdkError error={error} />
}
if (error instanceof Error) {
return (
<ErrorDetailList>
<BuiltinError error={error} />
</ErrorDetailList>
)
}
// default
return (
<ErrorDetailList>
{error.message && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.message')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.message}</ErrorDetailValue>
</ErrorDetailItem>
)}
{error.url && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.requestUrl')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.url}</ErrorDetailValue>
</ErrorDetailItem>
)}
{error.requestBody && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.requestBody')}:</ErrorDetailLabel>
<CodeViewer className="source-view" language="json" expanded>
{JSON.stringify(error.requestBody, null, 2)}
</CodeViewer>
</ErrorDetailItem>
)}
{error.stack && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.stack')}:</ErrorDetailLabel>
<StackTrace>
<pre>{error.stack}</pre>
</StackTrace>
</ErrorDetailItem>
)}
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.unknown')}:</ErrorDetailLabel>
</ErrorDetailItem>
</ErrorDetailList>
)
}
@ -262,4 +252,109 @@ const Alert = styled(AntdAlert)`
}
`
// 作为 base渲染公共字段应当在 ErrorDetailList 中渲染
const BuiltinError = ({ error }: { error: Error }) => {
const { t } = useTranslation()
return (
<>
{error.name && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.name')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.message}</ErrorDetailValue>
</ErrorDetailItem>
)}
{error.message && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.message')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.message}</ErrorDetailValue>
</ErrorDetailItem>
)}
{error.stack && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.stack')}:</ErrorDetailLabel>
<StackTrace>
<pre>{error.stack}</pre>
</StackTrace>
</ErrorDetailItem>
)}
</>
)
}
// 作为 base渲染公共字段应当在 ErrorDetailList 中渲染
const AiSdkError = ({ error }: { error: AISDKError }) => {
const { t } = useTranslation()
const cause = safeToString(error.cause)
return (
<>
<BuiltinError error={error} />
{cause && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.cause')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.message}</ErrorDetailValue>
</ErrorDetailItem>
)}
</>
)
}
const AiApiCallError = ({ error }: { error: APICallError }) => {
const { t } = useTranslation()
// 这些字段是 unknown 类型,暂且不清楚都可能是什么类型,总之先覆盖下大部分场景
const requestBodyValues = safeToString(error.requestBodyValues)
const data = safeToString(error.data)
return (
<ErrorDetailList>
<AiSdkError error={error} />
{error.url && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.requestUrl')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.url}</ErrorDetailValue>
</ErrorDetailItem>
)}
{requestBodyValues && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.requestBodyValues')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.message}</ErrorDetailValue>
</ErrorDetailItem>
)}
{error.statusCode && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.statusCode')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.message}</ErrorDetailValue>
</ErrorDetailItem>
)}
{error.responseHeaders && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.responseHeaders')}:</ErrorDetailLabel>
<ErrorDetailValue>{error.message}</ErrorDetailValue>
</ErrorDetailItem>
)}
{error.responseBody && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.responseBody')}:</ErrorDetailLabel>
<CodeViewer className="source-view" language="json" expanded>
{JSON.stringify(error.responseBody, null, 2)}
</CodeViewer>
</ErrorDetailItem>
)}
{data && (
<ErrorDetailItem>
<ErrorDetailLabel>{t('error.data')}:</ErrorDetailLabel>
<StackTrace>
<pre>{error.stack}</pre>
</StackTrace>
</ErrorDetailItem>
)}
</ErrorDetailList>
)
}
export default React.memo(ErrorBlock)

View File

@ -1,4 +1,4 @@
import type { ImageModel, LanguageModel } from 'ai'
import type { AISDKError, APICallError, ImageModel, LanguageModel } from 'ai'
import { generateObject, generateText, ModelMessage, streamObject, streamText } from 'ai'
export type StreamTextParams = Omit<Parameters<typeof streamText>[0], 'model' | 'messages'> &
@ -27,3 +27,6 @@ export type StreamObjectParams = Omit<Parameters<typeof streamObject>[0], 'model
export type GenerateObjectParams = Omit<Parameters<typeof generateObject>[0], 'model'>
export type AiSdkModel = LanguageModel | ImageModel
// 该类型用于格式化错误信息,目前只处理 APICallError待扩展
export type AiSdkErrorUnion = AISDKError | APICallError

View File

@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import { AiSdkErrorUnion } from '@renderer/types/aiCoreTypes'
import { AISDKError, APICallError } from 'ai'
import { t } from 'i18next'
import z from 'zod'
@ -123,3 +124,102 @@ export const formatZodError = (error: z.ZodError, title?: string) => {
const errorMessage = readableErrors.join('\n')
return title ? `${title}: \n${errorMessage}` : errorMessage
}
/**
*
* @param value - unknown
* @returns
*
* @description
* :
* - null undefined 'null'
* -
* - (bigint等)使 String()
* - 使 JSON.stringify
* -
*
* @example
* ```ts
* safeToString(null) // 'null'
* safeToString('test') // 'test'
* safeToString(123) // '123'
* safeToString({a: 1}) // '{"a":1}'
* ```
*/
export function safeToString(value: unknown): string {
// 处理 null 和 undefined
if (value == null) {
return 'null'
}
// 字符串直接返回
if (typeof value === 'string') {
return value
}
// 数字、布尔值、bigint 等原始类型,安全用 String()
if (typeof value !== 'object' && typeof value !== 'function') {
return String(value)
}
// 处理对象(包括数组)
if (typeof value === 'object') {
// 处理函数
if (typeof value === 'function') {
return value.toString()
}
// 其他对象
try {
return JSON.stringify(value, getCircularReplacer())
} catch (err) {
return '[Unserializable: ' + err + ']'
}
}
return String(value)
}
// 防止循环引用导致的 JSON.stringify 崩溃
function getCircularReplacer() {
const seen = new WeakSet()
return (_key: string, value: unknown) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]'
}
seen.add(value)
}
return value
}
}
export function formatError(error: Error): string {
return `${t('error.name')}: ${error.name}\n${t('error.message')}: ${error.message}\n${t('error.stack')}: ${error.stack}`
}
export function formatAiSdkError(error: AiSdkErrorUnion): string {
let text = formatError(error) + '\n'
if (error.cause) {
text += `${t('error.cause')}: ${error.cause}\n`
}
if (APICallError.isInstance(error)) {
if (error.statusCode) {
text += `${t('error.statusCode')}: ${error.statusCode}\n`
}
text += `${t('error.requestUrl')}: ${error.url}\n`
const requestBodyValues = safeToString(error.requestBodyValues)
text += `${t('error.requestBodyValues')}: ${requestBodyValues}\n`
if (error.responseHeaders) {
text += `${t('error.responseHeaders')}: ${error.responseHeaders}\n`
}
if (error.responseBody) {
text += `${t('error.responseBody')}: ${error.responseBody}\n`
}
if (error.data) {
const data = safeToString(error.data)
text += `${t('error.data')}: ${data}\n`
}
}
return text.trim()
}