mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-20 13:21:48 +08:00
feat: AI 对话错误详情展示优化
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
type AssistantMessage as PiAssistantMessage,
|
||||
type Message as PiMessage,
|
||||
type Usage as PiUsage,
|
||||
streamSimple,
|
||||
} from '@mariozechner/pi-ai'
|
||||
|
||||
import type { AgentConfig, AgentStreamChunk, AgentResult, SkillContext } from './types'
|
||||
@@ -104,6 +105,18 @@ export class Agent {
|
||||
let debugLastLoggedCount = 0
|
||||
let debugLlmRound = 1
|
||||
|
||||
// 捕获最后一次 LLM 调用的请求体(用于错误诊断)
|
||||
let lastRequestPayload: unknown = null
|
||||
const errorCapturingStreamFn: typeof streamSimple = (model, context, options) => {
|
||||
return streamSimple(model, context, {
|
||||
...options,
|
||||
onPayload: (payload) => {
|
||||
lastRequestPayload = payload
|
||||
options?.onPayload?.(payload)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化 PiAgentCore
|
||||
const coreAgent = new PiAgentCore({
|
||||
initialState: {
|
||||
@@ -111,6 +124,7 @@ export class Agent {
|
||||
thinkingLevel: this.piModel.reasoning ? 'medium' : 'off',
|
||||
},
|
||||
getApiKey: () => this.apiKey,
|
||||
streamFn: errorCapturingStreamFn,
|
||||
convertToLlm: (messages) => {
|
||||
const filtered = messages.filter(
|
||||
(msg): msg is PiMessage => msg.role === 'user' || msg.role === 'assistant' || msg.role === 'toolResult'
|
||||
@@ -199,7 +213,45 @@ export class Agent {
|
||||
}
|
||||
|
||||
if (coreAgent.state.error) {
|
||||
throw new Error(coreAgent.state.error)
|
||||
const agentError = new Error(coreAgent.state.error) as Error & {
|
||||
agentContext?: {
|
||||
provider?: string
|
||||
model?: string
|
||||
api?: string
|
||||
url?: string
|
||||
requestBody?: string
|
||||
}
|
||||
}
|
||||
const lastMsg = [...coreAgent.state.messages].reverse().find((m) => m.role === 'assistant') as
|
||||
| (PiAssistantMessage & { provider?: string; model?: string; api?: string })
|
||||
| undefined
|
||||
const ctx: NonNullable<typeof agentError.agentContext> = {}
|
||||
if (lastMsg) {
|
||||
ctx.provider = lastMsg.provider
|
||||
ctx.model = lastMsg.model
|
||||
ctx.api = lastMsg.api
|
||||
}
|
||||
// 从 model.baseUrl 和 api 类型构造完整请求 URL
|
||||
const baseUrl = (this.piModel as Record<string, unknown>).baseUrl as string | undefined
|
||||
if (baseUrl) {
|
||||
const apiType = lastMsg?.api || (this.piModel as Record<string, unknown>).api
|
||||
const pathMap: Record<string, string> = {
|
||||
'openai-completions': '/chat/completions',
|
||||
'openai-responses': '/responses',
|
||||
'anthropic-messages': '/messages',
|
||||
}
|
||||
const apiPath = typeof apiType === 'string' ? pathMap[apiType] : undefined
|
||||
ctx.url = apiPath ? baseUrl.replace(/\/+$/, '') + apiPath : baseUrl
|
||||
}
|
||||
if (lastRequestPayload) {
|
||||
try {
|
||||
ctx.requestBody = JSON.stringify(lastRequestPayload, null, 2)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
agentError.agentContext = ctx
|
||||
throw agentError
|
||||
}
|
||||
|
||||
// 提取最终回复
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Agent 模块类型定义
|
||||
*/
|
||||
|
||||
import type { TokenUsage, AgentRuntimeStatus } from '../../../shared/types'
|
||||
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../../../shared/types'
|
||||
|
||||
export type { TokenUsage, AgentRuntimeStatus } from '../../../shared/types'
|
||||
export type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../../../shared/types'
|
||||
|
||||
/**
|
||||
* Agent 配置
|
||||
@@ -36,8 +36,8 @@ export interface AgentStreamChunk {
|
||||
toolParams?: Record<string, unknown>
|
||||
/** 工具执行结果(type=tool_result 时) */
|
||||
toolResult?: unknown
|
||||
/** 错误信息(type=error 时) */
|
||||
error?: string
|
||||
/** 结构化错误信息(type=error 时) */
|
||||
error?: SerializedErrorInfo
|
||||
/** 是否完成 */
|
||||
isFinished?: boolean
|
||||
/** Token 使用量(type=done 时返回累计值) */
|
||||
@@ -58,6 +58,8 @@ export interface AgentResult {
|
||||
toolRounds: number
|
||||
/** 总 Token 使用量(累计所有 LLM 调用) */
|
||||
totalUsage?: TokenUsage
|
||||
/** 结构化错误信息(请求失败时) */
|
||||
error?: SerializedErrorInfo
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 将任意错误对象序列化为 SerializedErrorInfo,
|
||||
* 保留尽可能多的 HTTP / provider 上下文,用于前端详情展示和日志记录。
|
||||
*/
|
||||
|
||||
import type { SerializedErrorInfo } from '../../shared/types'
|
||||
|
||||
function safeString(val: unknown): string | null {
|
||||
if (val === undefined || val === null) return null
|
||||
if (typeof val === 'string') return val
|
||||
try {
|
||||
return JSON.stringify(val, null, 2)
|
||||
} catch {
|
||||
return String(val)
|
||||
}
|
||||
}
|
||||
|
||||
function extractFromCandidate(candidate: unknown, info: SerializedErrorInfo): void {
|
||||
if (!candidate || typeof candidate !== 'object') return
|
||||
|
||||
const rec = candidate as Record<string, unknown>
|
||||
|
||||
if (typeof rec.statusCode === 'number' && info.statusCode == null) {
|
||||
info.statusCode = rec.statusCode
|
||||
}
|
||||
|
||||
if (typeof rec.status === 'number' && info.statusCode == null) {
|
||||
info.statusCode = rec.status
|
||||
}
|
||||
|
||||
if (typeof rec.url === 'string' && !info.url) {
|
||||
info.url = rec.url
|
||||
}
|
||||
|
||||
if (typeof rec.responseBody === 'string' && !info.responseBody) {
|
||||
info.responseBody = rec.responseBody
|
||||
}
|
||||
|
||||
if (rec.responseHeaders && typeof rec.responseHeaders === 'object' && !info.responseHeaders) {
|
||||
try {
|
||||
const headers = rec.responseHeaders as Record<string, unknown>
|
||||
const plain: Record<string, string> = {}
|
||||
for (const [key, val] of Object.entries(headers)) {
|
||||
plain[key] = String(val)
|
||||
}
|
||||
info.responseHeaders = plain
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI SDK: headers 在 error.headers (Headers 对象)
|
||||
if (rec.headers && typeof rec.headers === 'object' && !info.responseHeaders) {
|
||||
try {
|
||||
const h = rec.headers
|
||||
if (typeof (h as any).entries === 'function') {
|
||||
const plain: Record<string, string> = {}
|
||||
for (const [key, val] of (h as any).entries()) {
|
||||
plain[key] = String(val)
|
||||
}
|
||||
if (Object.keys(plain).length > 0) {
|
||||
info.responseHeaders = plain
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (rec.requestBodyValues !== undefined && !info.requestBody) {
|
||||
info.requestBody = safeString(rec.requestBodyValues)
|
||||
}
|
||||
if (rec.requestBody !== undefined && !info.requestBody) {
|
||||
info.requestBody = safeString(rec.requestBody)
|
||||
}
|
||||
|
||||
// OpenAI SDK: error.error 包含 response body 中的 error 对象
|
||||
if (rec.error !== undefined && typeof rec.error === 'object' && !info.responseBody) {
|
||||
info.responseBody = safeString(rec.error)
|
||||
}
|
||||
|
||||
if (rec.data !== undefined && !info.responseBody) {
|
||||
info.responseBody = safeString(rec.data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误消息字符串中尝试解析 HTTP 状态码。
|
||||
* OpenAI SDK 的 APIError.message 通常以 "NNN " 开头,如 "401 Incorrect API key..."
|
||||
*/
|
||||
function parseStatusCodeFromMessage(message: string | null): number | null {
|
||||
if (!message) return null
|
||||
const match = message.match(/^(\d{3})\s/)
|
||||
if (match) {
|
||||
const code = parseInt(match[1], 10)
|
||||
if (code >= 100 && code < 600) return code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function serializeError(error: unknown, provider?: string): SerializedErrorInfo {
|
||||
const info: SerializedErrorInfo = {
|
||||
name: null,
|
||||
message: null,
|
||||
stack: null,
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
info.provider = provider
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
info.message = 'Unknown error'
|
||||
return info
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
info.message = error
|
||||
info.statusCode = parseStatusCodeFromMessage(error)
|
||||
return info
|
||||
}
|
||||
|
||||
if (!(typeof error === 'object')) {
|
||||
info.message = String(error)
|
||||
return info
|
||||
}
|
||||
|
||||
const err = error as Record<string, unknown>
|
||||
|
||||
if (typeof err.name === 'string') info.name = err.name
|
||||
if (typeof err.message === 'string') info.message = err.message
|
||||
if (typeof err.stack === 'string') info.stack = err.stack
|
||||
|
||||
if (err.cause !== undefined) {
|
||||
info.cause = safeString(err.cause)
|
||||
}
|
||||
|
||||
// Agent 层附加的上下文(provider / model / url / requestBody)
|
||||
if (err.agentContext && typeof err.agentContext === 'object') {
|
||||
const ctx = err.agentContext as Record<string, unknown>
|
||||
if (typeof ctx.provider === 'string' && !info.provider) {
|
||||
info.provider = ctx.provider
|
||||
}
|
||||
if (typeof ctx.url === 'string' && !info.url) {
|
||||
info.url = ctx.url
|
||||
}
|
||||
if (typeof ctx.requestBody === 'string' && !info.requestBody) {
|
||||
info.requestBody = ctx.requestBody
|
||||
}
|
||||
}
|
||||
|
||||
// 从主错误对象及其嵌套 lastError / errors / cause 中提取 HTTP 上下文
|
||||
const candidates: unknown[] = [error]
|
||||
if (err.lastError) candidates.push(err.lastError)
|
||||
if (Array.isArray(err.errors)) candidates.push(...err.errors)
|
||||
if (err.cause && typeof err.cause === 'object') candidates.push(err.cause)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
extractFromCandidate(candidate, info)
|
||||
}
|
||||
|
||||
// 如果没有从属性中提取到 statusCode,尝试从 message 字符串解析
|
||||
if (info.statusCode == null) {
|
||||
info.statusCode = parseStatusCodeFromMessage(info.message)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as aiConversations from '../ai/conversations'
|
||||
import * as llm from '../ai/llm'
|
||||
import * as rag from '../ai/rag'
|
||||
import { aiLogger, setDebugMode } from '../ai/logger'
|
||||
import { serializeError } from '../ai/serialize-error'
|
||||
import { getLogsDir } from '../paths'
|
||||
import { Agent, type AgentStreamChunk, type SkillContext } from '../ai/agent'
|
||||
import { getDefaultGeneralAssistantId } from '../ai/assistant/defaultGeneral'
|
||||
@@ -1166,15 +1167,16 @@ export function registerAIHandlers({ win }: IpcContext): void {
|
||||
})
|
||||
return
|
||||
}
|
||||
const friendlyError = formatAIError(error, activeAIConfig.provider)
|
||||
aiLogger.error('IPC', `Agent execution error: ${requestId}`, {
|
||||
error: String(error),
|
||||
friendlyError,
|
||||
})
|
||||
const serializedError = serializeError(error, activeAIConfig.provider)
|
||||
serializedError.friendlyMessage = formatAIError(error, activeAIConfig.provider)
|
||||
if (!serializedError.url && activeAIConfig.baseUrl) {
|
||||
serializedError.url = activeAIConfig.baseUrl
|
||||
}
|
||||
aiLogger.error('IPC', `Agent execution error: ${requestId}`, serializedError)
|
||||
// 发送错误 chunk
|
||||
win.webContents.send('agent:streamChunk', {
|
||||
requestId,
|
||||
chunk: { type: 'error', error: friendlyError, isFinished: true },
|
||||
chunk: { type: 'error', error: serializedError, isFinished: true },
|
||||
})
|
||||
// 发送完成事件(带错误信息),确保前端 promise 能 resolve
|
||||
win.webContents.send('agent:complete', {
|
||||
@@ -1184,7 +1186,7 @@ export function registerAIHandlers({ win }: IpcContext): void {
|
||||
toolsUsed: [],
|
||||
toolRounds: 0,
|
||||
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
||||
error: friendlyError,
|
||||
error: serializedError,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -79,7 +79,9 @@ export interface ChatStreamChunk {
|
||||
|
||||
// Agent API 类型 — 从 shared/types 统一导入
|
||||
export type { TokenUsage, AgentRuntimeStatus } from '../../shared/types'
|
||||
import type { TokenUsage, AgentRuntimeStatus } from '../../shared/types'
|
||||
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../../shared/types'
|
||||
|
||||
export type { SerializedErrorInfo } from '../../shared/types'
|
||||
|
||||
export interface AgentStreamChunk {
|
||||
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error'
|
||||
@@ -90,7 +92,7 @@ export interface AgentStreamChunk {
|
||||
toolParams?: Record<string, unknown>
|
||||
toolResult?: unknown
|
||||
status?: AgentRuntimeStatus
|
||||
error?: string
|
||||
error?: SerializedErrorInfo
|
||||
isFinished?: boolean
|
||||
/** Token 使用量(type=done 时返回累计值) */
|
||||
usage?: TokenUsage
|
||||
@@ -100,6 +102,7 @@ export interface AgentResult {
|
||||
content: string
|
||||
toolsUsed: string[]
|
||||
toolRounds: number
|
||||
error?: SerializedErrorInfo
|
||||
}
|
||||
|
||||
/** 单条脱敏规则 */
|
||||
@@ -907,7 +910,10 @@ export const agentApi = {
|
||||
assistantId?: string,
|
||||
skillId?: string | null,
|
||||
enableAutoSkill?: boolean
|
||||
): { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> } => {
|
||||
): {
|
||||
requestId: string
|
||||
promise: Promise<{ success: boolean; result?: AgentResult; error?: SerializedErrorInfo }>
|
||||
} => {
|
||||
// 防御性处理:确保传给 IPC 的 context 是“可结构化克隆”的纯对象
|
||||
// 避免调用方误传入响应式 Proxy(例如 Pinia/Vue state)导致 invoke 失败
|
||||
const sanitizedContext: ToolContext = {
|
||||
@@ -949,7 +955,7 @@ export const agentApi = {
|
||||
chatType ?? 'group'
|
||||
)
|
||||
|
||||
const promise = new Promise<{ success: boolean; result?: AgentResult; error?: string }>((resolve) => {
|
||||
const promise = new Promise<{ success: boolean; result?: AgentResult; error?: SerializedErrorInfo }>((resolve) => {
|
||||
// 监听流式 chunks
|
||||
const chunkHandler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
@@ -965,7 +971,7 @@ export const agentApi = {
|
||||
// 监听完成事件
|
||||
const completeHandler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { requestId: string; result: AgentResult & { error?: string } }
|
||||
data: { requestId: string; result: AgentResult & { error?: SerializedErrorInfo } }
|
||||
) => {
|
||||
if (data.requestId === requestId) {
|
||||
console.log('[preload] Agent 完成,requestId:', requestId, 'hasError:', !!data.result?.error)
|
||||
@@ -1009,7 +1015,14 @@ export const agentApi = {
|
||||
console.error('[preload] Agent invoke 错误:', error)
|
||||
ipcRenderer.removeListener('agent:streamChunk', chunkHandler)
|
||||
ipcRenderer.removeListener('agent:complete', completeHandler)
|
||||
resolve({ success: false, error: String(error) })
|
||||
resolve({
|
||||
success: false,
|
||||
error: {
|
||||
name: error instanceof Error ? error.name : null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? (error.stack ?? null) : null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Vendored
+5
-3
@@ -1,6 +1,6 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { AnalysisSession, MessageType, ImportProgress, ExportProgress } from '../../src/types/base'
|
||||
import type { TokenUsage, AgentRuntimeStatus } from '../shared/types'
|
||||
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../shared/types'
|
||||
import type {
|
||||
MemberActivity,
|
||||
MemberNameHistory,
|
||||
@@ -672,7 +672,7 @@ interface AgentStreamChunk {
|
||||
toolParams?: Record<string, unknown>
|
||||
toolResult?: unknown
|
||||
status?: AgentRuntimeStatus
|
||||
error?: string
|
||||
error?: SerializedErrorInfo
|
||||
isFinished?: boolean
|
||||
/** Token 使用量(type=done 时返回累计值) */
|
||||
usage?: TokenUsage
|
||||
@@ -684,6 +684,7 @@ interface AgentResult {
|
||||
toolRounds: number
|
||||
/** 总 Token 使用量(累计所有 LLM 调用) */
|
||||
totalUsage?: TokenUsage
|
||||
error?: SerializedErrorInfo
|
||||
}
|
||||
|
||||
/** Owner 信息(当前用户在对话中的身份) */
|
||||
@@ -768,7 +769,7 @@ interface AgentApi {
|
||||
assistantId?: string,
|
||||
skillId?: string | null,
|
||||
enableAutoSkill?: boolean
|
||||
) => { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> }
|
||||
) => { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: SerializedErrorInfo }> }
|
||||
abort: (requestId: string) => Promise<{ success: boolean; error?: string }>
|
||||
}
|
||||
|
||||
@@ -1213,6 +1214,7 @@ export {
|
||||
AgentStreamChunk,
|
||||
AgentRuntimeStatus,
|
||||
AgentResult,
|
||||
SerializedErrorInfo,
|
||||
ToolContext,
|
||||
DesensitizeRule,
|
||||
PreprocessConfig,
|
||||
|
||||
@@ -5,6 +5,25 @@
|
||||
* 所有使用方应从此处导入,避免重复定义导致类型漂移。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 序列化后的结构化错误信息,跨进程传输 & 持久化存储。
|
||||
* 所有字段可选——仅在原始错误中存在时才填充。
|
||||
*/
|
||||
export interface SerializedErrorInfo {
|
||||
name: string | null
|
||||
message: string | null
|
||||
stack: string | null
|
||||
statusCode?: number | null
|
||||
url?: string | null
|
||||
responseBody?: string | null
|
||||
responseHeaders?: Record<string, string> | null
|
||||
requestBody?: string | null
|
||||
cause?: string | null
|
||||
provider?: string | null
|
||||
/** formatAIError 生成的用户友好摘要 */
|
||||
friendlyMessage?: string | null
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
|
||||
@@ -5,6 +5,7 @@ import dayjs from 'dayjs'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type { ContentBlock, ToolBlockContent } from '@/composables/useAIChat'
|
||||
import CaptureButton from '@/components/common/CaptureButton.vue'
|
||||
import ErrorBlock from './ErrorBlock.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { t, te, locale } = useI18n()
|
||||
@@ -406,6 +407,9 @@ async function handleCopyMarkdown() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误块 -->
|
||||
<ErrorBlock v-else-if="block.type === 'error'" :error="block.error" />
|
||||
</template>
|
||||
|
||||
<!-- 流式处理中指示器(当最后一个块是已完成的工具块时显示) -->
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SerializedErrorInfo } from '@electron/shared/types'
|
||||
import ErrorDetailModal from './ErrorDetailModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
error: SerializedErrorInfo
|
||||
}>()
|
||||
|
||||
const showDetail = ref(false)
|
||||
|
||||
function getDisplayMessage(error: SerializedErrorInfo): string {
|
||||
return error.friendlyMessage || error.message || t('ai.chat.error.unknown')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group relative my-2 w-full min-w-[320px] cursor-pointer rounded-lg border border-red-200 bg-red-50/60 px-3.5 py-3 text-[13px] transition-all duration-200 hover:border-red-300 hover:bg-red-50 dark:border-red-900/30 dark:bg-red-950/20 dark:hover:border-red-800/50 dark:hover:bg-red-950/30"
|
||||
@click="showDetail = true"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="mb-1.5 flex items-center gap-2">
|
||||
<div class="flex shrink-0 items-center justify-center text-red-500 dark:text-red-400">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="pr-5 text-[13px] font-semibold leading-[1.4] text-red-600 dark:text-red-400">
|
||||
{{ t('ai.chat.error.title') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="ml-6 line-clamp-3 break-words text-xs leading-normal text-gray-600 dark:text-gray-400">
|
||||
{{ getDisplayMessage(error) }}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-2.5 ml-6 flex items-center">
|
||||
<div
|
||||
class="ml-auto inline-flex items-center gap-0.5 text-xs text-gray-400 transition-colors duration-150 group-hover:text-red-500 dark:text-gray-500 dark:group-hover:text-red-400"
|
||||
>
|
||||
{{ t('ai.chat.error.viewDetail') }}
|
||||
<UIcon name="i-heroicons-chevron-right" class="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Detail Modal -->
|
||||
<ErrorDetailModal v-model:open="showDetail" :error="error" />
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { SerializedErrorInfo } from '@electron/shared/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
error: SerializedErrorInfo
|
||||
}>()
|
||||
|
||||
interface DetailField {
|
||||
label: string
|
||||
value: string
|
||||
isCode?: boolean
|
||||
isStack?: boolean
|
||||
}
|
||||
|
||||
const fields = computed<DetailField[]>(() => {
|
||||
const e = props.error
|
||||
const list: DetailField[] = []
|
||||
|
||||
if (e.url) {
|
||||
list.push({ label: t('ai.chat.error.requestUrl'), value: e.url })
|
||||
}
|
||||
if (e.statusCode != null) {
|
||||
list.push({ label: t('ai.chat.error.statusCode'), value: String(e.statusCode) })
|
||||
}
|
||||
if (e.responseBody) {
|
||||
list.push({ label: t('ai.chat.error.responseBody'), value: formatJson(e.responseBody), isCode: true })
|
||||
}
|
||||
if (e.responseHeaders) {
|
||||
list.push({
|
||||
label: t('ai.chat.error.responseHeaders'),
|
||||
value: JSON.stringify(e.responseHeaders, null, 2),
|
||||
isCode: true,
|
||||
})
|
||||
}
|
||||
if (e.name) {
|
||||
list.push({ label: t('ai.chat.error.name'), value: e.name })
|
||||
}
|
||||
if (e.message) {
|
||||
list.push({ label: t('ai.chat.error.message'), value: e.message })
|
||||
}
|
||||
if (e.stack) {
|
||||
list.push({ label: t('ai.chat.error.stack'), value: e.stack, isStack: true })
|
||||
}
|
||||
if (e.cause) {
|
||||
list.push({ label: t('ai.chat.error.cause'), value: formatJson(e.cause), isCode: true })
|
||||
}
|
||||
if (e.provider) {
|
||||
list.push({ label: t('ai.chat.error.provider'), value: e.provider })
|
||||
}
|
||||
if (e.requestBody) {
|
||||
list.push({ label: t('ai.chat.error.requestBody'), value: formatJson(e.requestBody), isCode: true })
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
function formatJson(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyAll() {
|
||||
const text = fields.value.map((f) => `${f.label}: ${f.value}`).join('\n\n')
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toast.success(t('ai.chat.error.copied'))
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:ui="{
|
||||
content: 'sm:max-w-[700px] z-[100]',
|
||||
overlay: 'backdrop-blur-sm z-[99]',
|
||||
}"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex max-h-[80vh] flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="shrink-0 border-b border-gray-200 px-6 py-4 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/40">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('ai.chat.error.detail') }}
|
||||
</h2>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" variant="ghost" color="neutral" size="sm" @click="open = false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="(field, idx) in fields" :key="idx" class="flex flex-col gap-1.5">
|
||||
<div class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ field.label }}
|
||||
</div>
|
||||
|
||||
<!-- Stack trace: red mono -->
|
||||
<div
|
||||
v-if="field.isStack"
|
||||
class="rounded-md border border-red-200 bg-red-50/50 p-3 dark:border-red-900/40 dark:bg-red-950/20"
|
||||
>
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-red-600 dark:text-red-400"
|
||||
>{{ field.value }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Code block -->
|
||||
<div
|
||||
v-else-if="field.isCode"
|
||||
class="rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<pre
|
||||
class="m-0 max-h-[300px] overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-gray-700 dark:text-gray-300"
|
||||
>{{ field.value }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Plain text -->
|
||||
<div
|
||||
v-else
|
||||
class="select-text break-words rounded-md border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
|
||||
>
|
||||
{{ field.value }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fields.length === 0" class="py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ t('ai.chat.error.unknown') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="shrink-0 border-t border-gray-200 px-6 py-3 dark:border-gray-800">
|
||||
<div class="flex justify-end">
|
||||
<UButton
|
||||
icon="i-heroicons-document-duplicate"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
@click="handleCopyAll"
|
||||
>
|
||||
{{ t('ai.chat.error.copyAll') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -193,6 +193,24 @@
|
||||
"noMessages": "No messages to export"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "Processing Failed",
|
||||
"detail": "Error Details",
|
||||
"viewDetail": "View Details",
|
||||
"requestUrl": "Request URL",
|
||||
"statusCode": "Status Code",
|
||||
"responseBody": "Response Body",
|
||||
"responseHeaders": "Response Headers",
|
||||
"requestBody": "Request Body",
|
||||
"name": "Error Name",
|
||||
"message": "Error Message",
|
||||
"stack": "Stack Trace",
|
||||
"cause": "Error Cause",
|
||||
"provider": "Provider",
|
||||
"copyAll": "Copy All",
|
||||
"copied": "Copied to clipboard",
|
||||
"unknown": "Unknown error"
|
||||
},
|
||||
"dataSource": {
|
||||
"title": "Data Source",
|
||||
"keywords": "Keywords:",
|
||||
|
||||
@@ -193,6 +193,24 @@
|
||||
"noMessages": "会話にメッセージがありません"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "処理に失敗しました",
|
||||
"detail": "エラー詳細",
|
||||
"viewDetail": "詳細を表示",
|
||||
"requestUrl": "リクエストURL",
|
||||
"statusCode": "ステータスコード",
|
||||
"responseBody": "レスポンスボディ",
|
||||
"responseHeaders": "レスポンスヘッダー",
|
||||
"requestBody": "リクエストボディ",
|
||||
"name": "エラー名",
|
||||
"message": "エラーメッセージ",
|
||||
"stack": "スタックトレース",
|
||||
"cause": "エラー原因",
|
||||
"provider": "プロバイダー",
|
||||
"copyAll": "すべてコピー",
|
||||
"copied": "クリップボードにコピーしました",
|
||||
"unknown": "不明なエラー"
|
||||
},
|
||||
"dataSource": {
|
||||
"title": "データソース",
|
||||
"keywords": "キーワード:",
|
||||
|
||||
@@ -193,6 +193,24 @@
|
||||
"noMessages": "对话没有消息"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "处理失败",
|
||||
"detail": "错误详情",
|
||||
"viewDetail": "查看详情",
|
||||
"requestUrl": "请求路径",
|
||||
"statusCode": "状态码",
|
||||
"responseBody": "响应内容",
|
||||
"responseHeaders": "响应头",
|
||||
"requestBody": "请求体",
|
||||
"name": "错误名称",
|
||||
"message": "错误信息",
|
||||
"stack": "堆栈信息",
|
||||
"cause": "错误原因",
|
||||
"provider": "服务商",
|
||||
"copyAll": "复制全部",
|
||||
"copied": "已复制到剪贴板",
|
||||
"unknown": "未知错误"
|
||||
},
|
||||
"dataSource": {
|
||||
"title": "数据源",
|
||||
"keywords": "关键词:",
|
||||
|
||||
@@ -193,6 +193,24 @@
|
||||
"noMessages": "對話沒有訊息"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "處理失敗",
|
||||
"detail": "錯誤詳情",
|
||||
"viewDetail": "查看詳情",
|
||||
"requestUrl": "請求路徑",
|
||||
"statusCode": "狀態碼",
|
||||
"responseBody": "回應內容",
|
||||
"responseHeaders": "回應標頭",
|
||||
"requestBody": "請求主體",
|
||||
"name": "錯誤名稱",
|
||||
"message": "錯誤訊息",
|
||||
"stack": "堆疊資訊",
|
||||
"cause": "錯誤原因",
|
||||
"provider": "服務商",
|
||||
"copyAll": "複製全部",
|
||||
"copied": "已複製到剪貼簿",
|
||||
"unknown": "未知錯誤"
|
||||
},
|
||||
"dataSource": {
|
||||
"title": "資料來源",
|
||||
"keywords": "關鍵字:",
|
||||
|
||||
+30
-10
@@ -12,7 +12,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useAssistantStore } from '@/stores/assistant'
|
||||
import { useSkillStore } from '@/stores/skill'
|
||||
import type { TokenUsage, AgentRuntimeStatus } from '@electron/shared/types'
|
||||
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '@electron/shared/types'
|
||||
|
||||
// 工具调用记录
|
||||
export interface ToolCallRecord {
|
||||
@@ -49,6 +49,7 @@ export type ContentBlock =
|
||||
tool: ToolBlockContent
|
||||
}
|
||||
| { type: 'skill'; skillId: string; skillName: string }
|
||||
| { type: 'error'; error: SerializedErrorInfo }
|
||||
|
||||
// 消息类型
|
||||
export interface ChatMessage {
|
||||
@@ -867,8 +868,12 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
}
|
||||
if (!hasStreamError) {
|
||||
hasStreamError = true
|
||||
appendTextToBlocks(`\n\n❌ 处理失败:${chunk.error || '未知错误'}`)
|
||||
updateAIMessage({ isStreaming: false })
|
||||
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
|
||||
blocks.push({
|
||||
type: 'error',
|
||||
error: chunk.error || { name: null, message: '未知错误', stack: null },
|
||||
})
|
||||
updateAIMessage({ contentBlocks: [...blocks], isStreaming: false })
|
||||
}
|
||||
setAgentPhase(state, 'error')
|
||||
break
|
||||
@@ -908,11 +913,19 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
|
||||
await saveConversation(resolvedConversationId, userMessage, targetBuffer.messages[aiMessageIndex])
|
||||
} else if (!hasStreamError) {
|
||||
appendTextToBlocks(`\n\n❌ 处理失败:${result.error || '未知错误'}`)
|
||||
const blocks = targetBuffer.messages[aiMessageIndex].contentBlocks || []
|
||||
blocks.push({
|
||||
type: 'error',
|
||||
error: result.error || { name: null, message: '未知错误', stack: null },
|
||||
})
|
||||
targetBuffer.messages[aiMessageIndex] = {
|
||||
...targetBuffer.messages[aiMessageIndex],
|
||||
contentBlocks: [...blocks],
|
||||
isStreaming: false,
|
||||
}
|
||||
await saveConversation(resolvedConversationId, userMessage, targetBuffer.messages[aiMessageIndex])
|
||||
} else {
|
||||
await saveConversation(resolvedConversationId, userMessage, targetBuffer.messages[aiMessageIndex])
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
@@ -922,13 +935,20 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
|
||||
|
||||
const lastMessage = targetBuffer.messages[targetBuffer.messages.length - 1]
|
||||
if (lastMessage && lastMessage.role === 'assistant') {
|
||||
lastMessage.content = `❌ 处理失败:${error instanceof Error ? error.message : '未知错误'}
|
||||
|
||||
请检查:
|
||||
- 网络连接是否正常
|
||||
- API Key 是否有效
|
||||
- 配置是否正确`
|
||||
const errInfo: SerializedErrorInfo = {
|
||||
name: error instanceof Error ? error.name : null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? (error.stack ?? null) : null,
|
||||
}
|
||||
const blocks = lastMessage.contentBlocks || []
|
||||
blocks.push({ type: 'error', error: errInfo })
|
||||
lastMessage.contentBlocks = [...blocks]
|
||||
lastMessage.isStreaming = false
|
||||
|
||||
const userMsg = targetBuffer.messages.find((m) => m.role === 'user')
|
||||
if (userMsg) {
|
||||
await saveConversation(resolvedConversationId, userMsg, lastMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, reason: 'error' }
|
||||
|
||||
Reference in New Issue
Block a user