mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 21:01:32 +08:00
feat(错误处理): 增强AI SDK错误处理并添加国际化支持
添加AiSdkErrorUnion类型用于统一处理AI SDK错误 实现safeToString函数安全转换任意值为字符串 添加formatAiSdkError和formatError函数格式化错误信息 在ErrorBlock中重构错误详情展示逻辑,支持多种错误类型 补充国际化字段用于错误信息展示
This commit is contained in:
parent
5aa8f3901f
commit
115368cbdd
@ -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": "无法找到原始用户消息"
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user