diff --git a/src/components/ChatBot/MarkdownWithCharts.js b/src/components/ChatBot/MarkdownWithCharts.js index 6ae44607..c5c1bda0 100644 --- a/src/components/ChatBot/MarkdownWithCharts.js +++ b/src/components/ChatBot/MarkdownWithCharts.js @@ -39,6 +39,7 @@ const cleanBrokenJson = (text) => { /** * 解析 Markdown 内容,提取 ECharts 代码块 + * 支持处理不完整的代码块(LLM 输出被截断的情况) * @param {string} markdown - Markdown 文本 * @returns {Array} - 包含文本和图表的数组 */ @@ -49,11 +50,17 @@ const parseMarkdownWithCharts = (markdown) => { const cleanedMarkdown = cleanBrokenJson(markdown); const parts = []; - const echartsRegex = /```echarts\s*\n([\s\S]*?)```/g; + + // 方案1: 匹配完整的 echarts 代码块 + const completeEchartsRegex = /```echarts\s*\n([\s\S]*?)```/g; + // 方案2: 匹配不完整的 echarts 代码块(没有结束的 ```) + const incompleteEchartsRegex = /```echarts\s*\n([\s\S]*?)$/; + let lastIndex = 0; let match; - while ((match = echartsRegex.exec(cleanedMarkdown)) !== null) { + // 首先尝试匹配完整的代码块 + while ((match = completeEchartsRegex.exec(cleanedMarkdown)) !== null) { // 添加代码块前的文本 if (match.index > lastIndex) { const textBefore = cleanedMarkdown.substring(lastIndex, match.index).trim(); @@ -69,15 +76,36 @@ const parseMarkdownWithCharts = (markdown) => { lastIndex = match.index + match[0].length; } - // 添加剩余文本 + // 检查剩余内容是否包含不完整的 echarts 代码块 if (lastIndex < cleanedMarkdown.length) { - const textAfter = cleanedMarkdown.substring(lastIndex).trim(); - if (textAfter) { - parts.push({ type: 'text', content: textAfter }); + const remainingText = cleanedMarkdown.substring(lastIndex); + const incompleteMatch = remainingText.match(incompleteEchartsRegex); + + if (incompleteMatch) { + // 提取不完整代码块之前的文本 + const textBeforeIncomplete = remainingText.substring(0, incompleteMatch.index).trim(); + if (textBeforeIncomplete) { + parts.push({ type: 'text', content: textBeforeIncomplete }); + } + + // 提取不完整的 echarts 内容 + const incompleteChartConfig = incompleteMatch[1].trim(); + if (incompleteChartConfig) { + logger.warn('检测到不完整的 echarts 代码块(缺少结束符)', { + contentPreview: incompleteChartConfig.substring(0, 100), + }); + parts.push({ type: 'chart', content: incompleteChartConfig }); + } + } else { + // 普通剩余文本 + const textAfter = remainingText.trim(); + if (textAfter) { + parts.push({ type: 'text', content: textAfter }); + } } } - // 如果没有找到图表,返回整个 markdown 作为文本 + // 如果没有找到任何图表,返回整个 markdown 作为文本 if (parts.length === 0) { parts.push({ type: 'text', content: cleanedMarkdown }); } @@ -240,11 +268,12 @@ export const MarkdownWithCharts = ({ content, variant = 'auto' }) => { // 移除可能的前后空白和不可见字符 cleanContent = cleanContent.replace(/^\s+|\s+$/g, ''); - // 检查 JSON 是否完整并尝试修复 - // 使用栈来跟踪括号,确保正确的闭合顺序 + // ========== 增强的 JSON 修复逻辑 ========== + // 使用栈来跟踪括号和字符串状态 const stack = []; let inString = false; let escape = false; + let stringStartPos = -1; for (let i = 0; i < cleanContent.length; i++) { const char = cleanContent[i]; @@ -260,7 +289,13 @@ export const MarkdownWithCharts = ({ content, variant = 'auto' }) => { } if (char === '"') { - inString = !inString; + if (inString) { + inString = false; + stringStartPos = -1; + } else { + inString = true; + stringStartPos = i; + } continue; } @@ -279,7 +314,22 @@ export const MarkdownWithCharts = ({ content, variant = 'auto' }) => { } } - // 如果栈不为空,说明有未闭合的括号,需要补全 + // 修复1: 如果字符串未闭合,需要关闭字符串 + if (inString) { + logger.warn('检测到未闭合的字符串,尝试修复', { + position: stringStartPos, + }); + // 找到最后一个有意义的位置(非空白) + let lastMeaningful = cleanContent.length - 1; + while (lastMeaningful > stringStartPos && /\s/.test(cleanContent[lastMeaningful])) { + lastMeaningful--; + } + // 截取到最后一个有意义的字符,然后闭合字符串 + cleanContent = cleanContent.substring(0, lastMeaningful + 1) + '"'; + logger.info('字符串已修复(添加闭合引号)'); + } + + // 修复2: 如果栈不为空,说明有未闭合的括号,需要补全 if (stack.length > 0) { logger.warn('检测到不完整的 ECharts JSON,尝试修复', { unclosed: stack.join(''), @@ -291,11 +341,67 @@ export const MarkdownWithCharts = ({ content, variant = 'auto' }) => { cleanContent += open === '{' ? '}' : ']'; } - logger.info('ECharts JSON 已修复'); + logger.info('ECharts JSON 括号已修复'); + } + + // 修复3: 尝试清理可能的尾部垃圾字符(如截断导致的无效字符) + // 找到最后一个有效的 JSON 结束字符 + const lastValidEnd = Math.max( + cleanContent.lastIndexOf('}'), + cleanContent.lastIndexOf(']'), + cleanContent.lastIndexOf('"') + ); + if (lastValidEnd > 0 && lastValidEnd < cleanContent.length - 1) { + const tail = cleanContent.substring(lastValidEnd + 1).trim(); + // 如果尾部不是有效的 JSON 字符,则截断 + if (tail && !/^[,\}\]\s]*$/.test(tail)) { + logger.warn('检测到尾部垃圾字符,截断处理', { tail: tail.substring(0, 50) }); + cleanContent = cleanContent.substring(0, lastValidEnd + 1); + } } // 尝试解析 JSON - const chartOption = JSON.parse(cleanContent); + let chartOption; + try { + chartOption = JSON.parse(cleanContent); + } catch (parseError) { + // 如果解析失败,尝试更激进的修复 + logger.warn('首次 JSON 解析失败,尝试更激进的修复', { error: parseError.message }); + + // 尝试找到 JSON 的有效开始和结束 + const jsonStart = cleanContent.indexOf('{'); + if (jsonStart >= 0) { + let fixedContent = cleanContent.substring(jsonStart); + // 重新计算并补全括号 + const fixStack = []; + let fixInString = false; + let fixEscape = false; + + for (let i = 0; i < fixedContent.length; i++) { + const char = fixedContent[i]; + if (fixEscape) { fixEscape = false; continue; } + if (char === '\\' && fixInString) { fixEscape = true; continue; } + if (char === '"') { fixInString = !fixInString; continue; } + if (fixInString) continue; + if (char === '{' || char === '[') fixStack.push(char); + else if (char === '}' && fixStack.length > 0 && fixStack[fixStack.length - 1] === '{') fixStack.pop(); + else if (char === ']' && fixStack.length > 0 && fixStack[fixStack.length - 1] === '[') fixStack.pop(); + } + + // 如果仍在字符串中,关闭字符串 + if (fixInString) fixedContent += '"'; + // 补全括号 + while (fixStack.length > 0) { + const open = fixStack.pop(); + fixedContent += open === '{' ? '}' : ']'; + } + + chartOption = JSON.parse(fixedContent); + logger.info('激进修复成功'); + } else { + throw parseError; + } + } // 验证是否是有效的 ECharts 配置 if (!chartOption || typeof chartOption !== 'object') {