diff --git a/docs/issues/2026-04-11-issue-backlog-tracking.md b/docs/issues/2026-04-11-issue-backlog-tracking.md index f47fa61..b299b23 100644 --- a/docs/issues/2026-04-11-issue-backlog-tracking.md +++ b/docs/issues/2026-04-11-issue-backlog-tracking.md @@ -40,6 +40,7 @@ | #349 | [Bug] postgres对于表名大小写敏感,且为大写时,通过选中表右键新建查询时生成的sql语句没有自动带上引号"" | Fixed | Pending | | #363 | [Bug] 日期字段无法设置值 | Fixed | Pending | | #368 | [Bug] 窗口状态问题 | Fixed | Pending | +| #369 | [Bug] AI回复Sql语句时 md 没有正常渲染 | Fixed | Pending | | #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending | ## Notes @@ -158,6 +159,12 @@ - 处理:抽出 `windowStateUi` 规则 helper,将“最大化窗口的 scale-fix toggle”收敛为仅在 `ratio-change` 且确实存在 drift 时才执行;激活返回时只广播 `resize`,不再重做最大化动画。并让标题栏按钮根据 `windowState` 动态切换 maximize/restore 图标,同时在标题栏切换动作后立即同步 store 中的窗口状态。 - 验证:新增 `frontend/src/utils/windowStateUi.test.ts`,覆盖“activation 不应重 toggle 最大化窗口”和“maximized 状态切换为 restore 图标”两条规则,并执行 `frontend` 下 `npm exec vitest run src/utils/windowStateUi.test.ts` 与 `npm run build`。 +### #369 + +- 根因:AI 消息 markdown 渲染链路把模型返回内容原样交给 `react-markdown`,没有对损坏的 fenced code block 做任何预处理。当模型输出 ` ```sqlSELECT ...` 这类“语言标记后缺少换行”的内容时,markdown 解析器会把整段当普通文本/行内代码处理,导致 SQL 代码块和换行都失效。 +- 处理:新增 `aiMarkdown` 预处理 helper,在渲染前补齐 opening fence 后缺失的换行,并为 closing fence 缺少前置换行的场景补齐收尾;`AIMessageBubble` 统一使用规范化后的内容喂给 `react-markdown`,恢复 SQL/代码块的正常渲染。 +- 验证:新增 `frontend/src/utils/aiMarkdown.test.ts`,覆盖 ` ```sqlSELECT ...` 自动归一化为 ` ```sql\\nSELECT ...` 的坏样例,并执行 `frontend` 下 `npm exec vitest run src/utils/aiMarkdown.test.ts` 与 `npm run build`。 + ### #330 - 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。 diff --git a/frontend/src/components/ai/AIMessageBubble.tsx b/frontend/src/components/ai/AIMessageBubble.tsx index 93348b5..af6011a 100644 --- a/frontend/src/components/ai/AIMessageBubble.tsx +++ b/frontend/src/components/ai/AIMessageBubble.tsx @@ -8,6 +8,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { AIChatMessage, AIToolCall } from '../../types'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import { normalizeAiMarkdown } from '../../utils/aiMarkdown'; // 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins const remarkPlugins = [remarkGfm]; @@ -27,6 +28,7 @@ const MemoizedMarkdown = React.memo(({ activeConnectionId?: string; activeDbName?: string; }) => { + const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]); // 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo const components = React.useMemo(() => ({ code({ node, inline, className, children, ...props }: any) { @@ -46,7 +48,7 @@ const MemoizedMarkdown = React.memo(({ return ( - {content} + {normalizedContent} ); }); diff --git a/frontend/src/utils/aiMarkdown.test.ts b/frontend/src/utils/aiMarkdown.test.ts new file mode 100644 index 0000000..bad2670 --- /dev/null +++ b/frontend/src/utils/aiMarkdown.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeAiMarkdown } from './aiMarkdown'; + +describe('normalizeAiMarkdown', () => { + it('inserts a missing newline after the fenced code language marker', () => { + expect(normalizeAiMarkdown('```sqlSELECT COUNT(*) AS order_count\nFROM customer_order;\n```')).toBe( + '```sql\nSELECT COUNT(*) AS order_count\nFROM customer_order;\n```', + ); + }); +}); diff --git a/frontend/src/utils/aiMarkdown.ts b/frontend/src/utils/aiMarkdown.ts new file mode 100644 index 0000000..3a1ecf5 --- /dev/null +++ b/frontend/src/utils/aiMarkdown.ts @@ -0,0 +1,13 @@ +export const normalizeAiMarkdown = (content: string): string => { + let text = String(content || '').replace(/\r\n/g, '\n'); + const knownFenceLanguages = [ + 'sql', 'mermaid', 'json', 'javascript', 'typescript', 'ts', 'js', 'tsx', 'jsx', + 'bash', 'sh', 'shell', 'python', 'py', 'go', 'java', 'yaml', 'yml', 'html', 'css', + 'xml', 'markdown', 'md', 'text', 'plaintext', 'vue', 'php', 'ruby', 'rust', 'toml', + 'ini', 'diff', + ]; + const fencePattern = new RegExp(`(^|\\n)\`\`\`(${knownFenceLanguages.join('|')})([^\\n])`, 'gi'); + text = text.replace(fencePattern, '$1```$2\n$3'); + text = text.replace(/([^\n])```(?=\n|$)/g, '$1\n```'); + return text; +};