From 115368cbddcd49e446d980cd9fc56a72d5704910 Mon Sep 17 00:00:00 2001 From: icarus Date: Wed, 3 Sep 2025 23:55:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BAAI=20SDK=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=9B=BD=E9=99=85=E5=8C=96=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加AiSdkErrorUnion类型用于统一处理AI SDK错误 实现safeToString函数安全转换任意值为字符串 添加formatAiSdkError和formatError函数格式化错误信息 在ErrorBlock中重构错误详情展示逻辑,支持多种错误类型 补充国际化字段用于错误信息展示 --- src/renderer/src/i18n/locales/zh-cn.json | 7 + .../pages/home/Messages/Blocks/ErrorBlock.tsx | 173 ++++++++++++++---- src/renderer/src/types/aiCoreTypes.ts | 5 +- src/renderer/src/utils/error.ts | 100 ++++++++++ 4 files changed, 245 insertions(+), 40 deletions(-) diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 88f8655fde..d732fc6b77 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -831,6 +831,7 @@ "invalid": "无效的MCP服务器" } }, + "cause": "错误原因", "chat": { "chunk": { "non_json": "返回了无效的数据格式" @@ -840,6 +841,7 @@ "quota_exceeded": "您今日免费配额已用尽,请前往 {{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": "无法找到原始用户消息" }, diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index d82f7ca751..68b2987cc9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -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 = ({ 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) => { if (!error) return
{t('error.unknown')}
+ if (APICallError.isInstance(error)) { + return + } + if (AISDKError.isInstance(error)) { + return + } + if (error instanceof Error) { + return ( + + + + ) + } + // default return ( - {error.message && ( - - {t('error.message')}: - {error.message} - - )} - - {error.url && ( - - {t('error.requestUrl')}: - {error.url} - - )} - - {error.requestBody && ( - - {t('error.requestBody')}: - - {JSON.stringify(error.requestBody, null, 2)} - - - )} - - {error.stack && ( - - {t('error.stack')}: - -
{error.stack}
-
-
- )} + + {t('error.unknown')}: +
) } @@ -262,4 +252,109 @@ const Alert = styled(AntdAlert)` } ` +// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染 +const BuiltinError = ({ error }: { error: Error }) => { + const { t } = useTranslation() + return ( + <> + {error.name && ( + + {t('error.name')}: + {error.message} + + )} + {error.message && ( + + {t('error.message')}: + {error.message} + + )} + {error.stack && ( + + {t('error.stack')}: + +
{error.stack}
+
+
+ )} + + ) +} + +// 作为 base,渲染公共字段,应当在 ErrorDetailList 中渲染 +const AiSdkError = ({ error }: { error: AISDKError }) => { + const { t } = useTranslation() + const cause = safeToString(error.cause) + return ( + <> + + {cause && ( + + {t('error.cause')}: + {error.message} + + )} + + ) +} + +const AiApiCallError = ({ error }: { error: APICallError }) => { + const { t } = useTranslation() + + // 这些字段是 unknown 类型,暂且不清楚都可能是什么类型,总之先覆盖下大部分场景 + const requestBodyValues = safeToString(error.requestBodyValues) + const data = safeToString(error.data) + + return ( + + + + {error.url && ( + + {t('error.requestUrl')}: + {error.url} + + )} + + {requestBodyValues && ( + + {t('error.requestBodyValues')}: + {error.message} + + )} + + {error.statusCode && ( + + {t('error.statusCode')}: + {error.message} + + )} + {error.responseHeaders && ( + + {t('error.responseHeaders')}: + {error.message} + + )} + + {error.responseBody && ( + + {t('error.responseBody')}: + + {JSON.stringify(error.responseBody, null, 2)} + + + )} + + {data && ( + + {t('error.data')}: + +
{error.stack}
+
+
+ )} +
+ ) +} + export default React.memo(ErrorBlock) diff --git a/src/renderer/src/types/aiCoreTypes.ts b/src/renderer/src/types/aiCoreTypes.ts index e93218ab9e..614211a5c7 100644 --- a/src/renderer/src/types/aiCoreTypes.ts +++ b/src/renderer/src/types/aiCoreTypes.ts @@ -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[0], 'model' | 'messages'> & @@ -27,3 +27,6 @@ export type StreamObjectParams = Omit[0], 'model export type GenerateObjectParams = Omit[0], 'model'> export type AiSdkModel = LanguageModel | ImageModel + +// 该类型用于格式化错误信息,目前只处理 APICallError,待扩展 +export type AiSdkErrorUnion = AISDKError | APICallError diff --git a/src/renderer/src/utils/error.ts b/src/renderer/src/utils/error.ts index 44f04aa036..5c9279c827 100644 --- a/src/renderer/src/utils/error.ts +++ b/src/renderer/src/utils/error.ts @@ -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() +}