fix: 消息级错误可见化——API 失败(如 402 余额耗尽)不再渲染为空白回合

This commit is contained in:
Rick Ye
2026-09-10 10:58:30 +08:00
parent 8df8a0e3d1
commit ddecaef4ef
2 changed files with 41 additions and 6 deletions
-5
View File
@@ -135,11 +135,6 @@ export default function App(): JSX.Element {
// 主进程侧 openView(插件 main 工厂 / 深链按钮)→ 渲染层切视图 // 主进程侧 openView(插件 main 工厂 / 深链按钮)→ 渲染层切视图
useEffect(() => window.api.onPluginsView((change) => handleOpenView(change.view, change.params)), [handleOpenView]) useEffect(() => window.api.onPluginsView((change) => handleOpenView(change.view, change.params)), [handleOpenView])
// TEMP-DEBUG: 移除(改为 App.tsx 模块级触发)
// 主进程侧 openView(插件 main 工厂 / 深链按钮)→ 渲染层切视图
useEffect(() => window.api.onPluginsView((change) => handleOpenView(change.view, change.params)), [handleOpenView])
// 插件列表变化(扫描/重载/启停)→ 刷新外置插件页(iframe 换 src 整页重载) // 插件列表变化(扫描/重载/启停)→ 刷新外置插件页(iframe 换 src 整页重载)
useEffect( useEffect(
() => () =>
@@ -7,7 +7,7 @@ import { StreamingText } from './StreamingText'
import { Thinking, thinkingRows } from './Thinking' import { Thinking, thinkingRows } from './Thinking'
import { ToolChips, type ToolCallView } from './ToolChips' import { ToolChips, type ToolCallView } from './ToolChips'
import { useEntrance } from '../hooks/useEntrance' import { useEntrance } from '../hooks/useEntrance'
import { LuFoldVertical, LuGitFork, LuHistory, LuLoaderCircle, LuUndo2 } from 'react-icons/lu' import { LuFoldVertical, LuGitFork, LuHistory, LuLoaderCircle, LuTriangleAlert, LuUndo2 } from 'react-icons/lu'
import { formatTokens } from '../lib/utils' import { formatTokens } from '../lib/utils'
interface ContentBlock { interface ContentBlock {
@@ -41,6 +41,29 @@ function lastThinkingLine(text: string): string {
return '' return ''
} }
/** 消息级错误(stopReason:'error',如 API 402 余额耗尽/限流):无此错误返回空串 */
function readMessageError(raw: unknown): string {
if (!raw || typeof raw !== 'object') return ''
const m = raw as { stopReason?: unknown; errorMessage?: unknown }
if (m.stopReason !== 'error' || typeof m.errorMessage !== 'string' || !m.errorMessage.trim()) return ''
return m.errorMessage
}
/** 提炼错误文案的可读部分:'402: {"message":"Insufficient Balance",…}' → '402: Insufficient Balance' */
function readableMessageError(err: string): string {
const match = err.match(/^(\d{3}):\s*([\s\S]*)$/)
const body = match ? match[2] : err
try {
const parsed = JSON.parse(body) as { message?: unknown }
if (parsed && typeof parsed.message === 'string' && parsed.message) {
return match ? `${match[1]}: ${parsed.message}` : parsed.message
}
} catch {
// 非 JSON 体,保持原文
}
return err
}
/** 工作计时:working=true 起表,结束后冻结读数。 /** 工作计时:working=true 起表,结束后冻结读数。
* working 中途抖动(pi 轮次间隙 streaming/turnActive 抖动)不锁存: * working 中途抖动(pi 轮次间隙 streaming/turnActive 抖动)不锁存:
* 恢复 true 时解除冻结,继续从起点计时。 */ * 恢复 true 时解除冻结,继续从起点计时。 */
@@ -166,6 +189,8 @@ function AssistantTurnBase({
let thinking = 0 let thinking = 0
let stream: UiMessage['stream'] | null = null let stream: UiMessage['stream'] | null = null
let answerText = '' let answerText = ''
// 消息级错误(API 失败等):回合内最后一条错误保留展示,否则错误轮会渲染成空白
let messageError = ''
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。 // 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
// 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。 // 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
@@ -177,6 +202,8 @@ function AssistantTurnBase({
stream = message.stream stream = message.stream
continue continue
} }
const msgErr = readMessageError(message.raw)
if (msgErr) messageError = msgErr
const asAnswer = settled && streamPos === -1 && idx === messages.length - 1 const asAnswer = settled && streamPos === -1 && idx === messages.length - 1
for (const block of readBlocks(message.raw)) { for (const block of readBlocks(message.raw)) {
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
@@ -436,6 +463,19 @@ function AssistantTurnBase({
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */} {/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
{turnActive && !stream && <Loading label="处理中" variant="Dots" />} {turnActive && !stream && <Loading label="处理中" variant="Dots" />}
{/* 消息级错误(stopReason:'error',如 API 402 余额耗尽 / 限流):常驻可见,
不随过程区折叠——否则失败轮在界面上就是一片空白,无从排查 */}
{messageError && (
<div className="mt-2 flex items-start gap-2.5 rounded-control border border-red/40 bg-red-tint px-3.5 py-2.5 text-[12.5px] text-red">
<LuTriangleAlert size={14} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="mb-0.5 font-medium">模型请求失败</div>
<div className="break-all text-red/80">{readableMessageError(messageError)}</div>
</div>
</div>
)}
{/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork), {/* 结论底部的操作行:复制 + 会话分叉。末回合走会话末尾分叉(onFork),
历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt), 历史回合以「本回合之后第一条用户消息」为锚点(forkEntryId + onForkAt),
新会话都包含到本回合答案为止。常驻不闪现。 */} 新会话都包含到本回合答案为止。常驻不闪现。 */}