🐛 fix(ai): 修正 SQL 代码块 Markdown 换行渲染

- 为 AI markdown 渲染补充 fenced code block 预处理
- 修正 opening/closing fence 缺少换行时的代码块解析失败
- 补充回归测试并更新 issue backlog 记录

Fixes #369
This commit is contained in:
Syngnat
2026-04-17 14:37:36 +08:00
parent 7cb46f9f69
commit f3193f0933
4 changed files with 34 additions and 1 deletions

View File

@@ -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 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。

View File

@@ -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 (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{content}
{normalizedContent}
</ReactMarkdown>
);
});

View File

@@ -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```',
);
});
});

View File

@@ -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;
};