From 9d3c77755d7189f18fa1914caf98be03c219637b Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 8 Jun 2026 06:45:40 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ai-message):=20?= =?UTF-8?q?=E6=8B=86=E5=88=86=E6=B6=88=E6=81=AF=E6=B0=94=E6=B3=A1=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=B1=95=E7=A4=BA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽离 AIMessageMarkdown 与 AIMessageStatusBlocks,拆分代码块、思考态和探针状态渲染职责 - 优化探针结果查找链路,使用 Map 减少消息渲染时的重复扫描 - 新增消息气泡与 Markdown 代码块测试,并完成 build 与浏览器验证 --- .../components/ai/AIMessageBubble.test.tsx | 55 + .../src/components/ai/AIMessageBubble.tsx | 1208 ++++++----------- .../messageBubble/AIMessageMarkdown.test.tsx | 26 + .../ai/messageBubble/AIMessageMarkdown.tsx | 440 ++++++ .../messageBubble/AIMessageStatusBlocks.tsx | 232 ++++ 5 files changed, 1145 insertions(+), 816 deletions(-) create mode 100644 frontend/src/components/ai/AIMessageBubble.test.tsx create mode 100644 frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx create mode 100644 frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx create mode 100644 frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx diff --git a/frontend/src/components/ai/AIMessageBubble.test.tsx b/frontend/src/components/ai/AIMessageBubble.test.tsx new file mode 100644 index 0000000..3499732 --- /dev/null +++ b/frontend/src/components/ai/AIMessageBubble.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { AIMessageBubble } from './AIMessageBubble'; +import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +describe('AIMessageBubble', () => { + it('renders thinking, tool progress and raw error actions after extracting status blocks', () => { + const markup = renderToStaticMarkup( + {}} + onRetry={() => {}} + onDelete={() => {}} + allMessages={[ + { + id: 'tool-result-1', + role: 'tool', + content: '[{\"fk\":\"orders.customer_id\"}]', + timestamp: Date.now(), + tool_call_id: 'tool-1', + tool_name: 'get_foreign_keys', + }, + ]} + />, + ); + + expect(markup).toContain('GoNavi AI'); + expect(markup).toContain('思考过程'); + expect(markup).toContain('梳理外键关系'); + expect(markup).toContain('复制报错原文'); + expect(markup).toContain('数据探针执行完毕'); + }); +}); diff --git a/frontend/src/components/ai/AIMessageBubble.tsx b/frontend/src/components/ai/AIMessageBubble.tsx index ab7ec5b..e27e457 100644 --- a/frontend/src/components/ai/AIMessageBubble.tsx +++ b/frontend/src/components/ai/AIMessageBubble.tsx @@ -1,838 +1,414 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState } from 'react'; import { Button, Tooltip, message } from 'antd'; -import { UserOutlined, RobotOutlined, EditOutlined, ReloadOutlined, DeleteOutlined, CheckOutlined, CopyOutlined, PlayCircleOutlined, ApiOutlined, LoadingOutlined, CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import mermaid from 'mermaid'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import type { AIChatMessage, AIToolCall } from '../../types'; +import { + CheckOutlined, + CopyOutlined, + DeleteOutlined, + EditOutlined, + ReloadOutlined, + RobotOutlined, + UserOutlined, +} from '@ant-design/icons'; + +import type { AIChatMessage } from '../../types'; import { useStore } from '../../store'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; -import { normalizeAiMarkdown } from '../../utils/aiMarkdown'; import { extractJVMChangePlan, resolveJVMAIPlanTargetTabId } from '../../utils/jvmAiPlan'; import { - parseJVMDiagnosticPlan, - resolveJVMDiagnosticPlanTargetTabId, + parseJVMDiagnosticPlan, + resolveJVMDiagnosticPlanTargetTabId, } from '../../utils/jvmDiagnosticPlan'; -import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit'; -// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins -const remarkPlugins = [remarkGfm]; - -const MemoizedMarkdown = React.memo(({ - content, - darkMode, - overlayTheme, - activeConnectionConfig, - activeConnectionId, - activeDbName -}: { - content: string; - darkMode: boolean; - overlayTheme: OverlayWorkbenchTheme; - activeConnectionConfig?: any; - activeConnectionId?: string; - activeDbName?: string; -}) => { - const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]); - // 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo - const components = React.useMemo(() => ({ - code({ node, inline, className, children, ...props }: any) { - const match = /language-(\w+)/.exec(className || ''); - if (!inline && match && match[1] === 'mermaid') { - return ; - } - return !inline && match ? ( - - ) : ( - - {children} - - ); - } - }), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]); - - return ( - - {normalizedContent} - - ); -}); +import { AIMessageMarkdown } from './messageBubble/AIMessageMarkdown'; +import { AIThinkingBlock, AIToolCallingBlock } from './messageBubble/AIMessageStatusBlocks'; interface AIMessageBubbleProps { - msg: AIChatMessage; - darkMode: boolean; - overlayTheme: OverlayWorkbenchTheme; - textColor: string; - onEdit: (msg: AIChatMessage) => void; - onRetry: (msg: AIChatMessage) => void; - onDelete: (id: string) => void; - activeConnectionId?: string; - activeConnectionConfig?: any; - activeDbName?: string; - allMessages?: AIChatMessage[]; + msg: AIChatMessage; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + textColor: string; + onEdit: (msg: AIChatMessage) => void; + onRetry: (msg: AIChatMessage) => void; + onDelete: (id: string) => void; + activeConnectionId?: string; + activeConnectionConfig?: any; + activeDbName?: string; + allMessages?: AIChatMessage[]; } -const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage, darkMode: boolean, overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => { - const [toolExpanded, setToolExpanded] = useState(false); - const charCount = resultMsg.content ? resultMsg.content.length : 0; - return ( -
-
setToolExpanded(!toolExpanded)} - > - {toolExpanded ? : } - - 探针执行结果 ({resultMsg.tool_name || 'unknown'}) - {charCount > 0 ? `${charCount} 个字符` : '无数据'} -
- {toolExpanded && ( -
- {resultMsg.content} -
- )} -
- ); -}; +interface AIMessageActionBarProps { + msg: AIChatMessage; + isUser: boolean; + isCopied: boolean; + textColor: string; + mutedText: string; + onEdit: (msg: AIChatMessage) => void; + onRetry: (msg: AIChatMessage) => void; + onDelete: (id: string) => void; + onCopy: () => void; +} -const MermaidRenderer = ({ chart, darkMode }: { chart: string, darkMode: boolean }) => { - const containerRef = React.useRef(null); +const AIMessageActionBar: React.FC = ({ + msg, + isUser, + isCopied, + textColor, + mutedText, + onEdit, + onRetry, + onDelete, + onCopy, +}) => ( +
+ + {isCopied ? ( + + ) : ( + { event.currentTarget.style.color = textColor; }} + onMouseLeave={(event) => { event.currentTarget.style.color = mutedText; }} + /> + )} + + {isUser ? ( + + onEdit(msg)} + style={{ cursor: 'pointer', color: mutedText }} + onMouseEnter={(event) => { event.currentTarget.style.color = textColor; }} + onMouseLeave={(event) => { event.currentTarget.style.color = mutedText; }} + /> + + ) : ( + + onRetry(msg)} + style={{ cursor: 'pointer', color: mutedText }} + onMouseEnter={(event) => { event.currentTarget.style.color = textColor; }} + onMouseLeave={(event) => { event.currentTarget.style.color = mutedText; }} + /> + + )} + + onDelete(msg.id)} + style={{ cursor: 'pointer', color: mutedText }} + onMouseEnter={(event) => { event.currentTarget.style.color = '#ef4444'; }} + onMouseLeave={(event) => { event.currentTarget.style.color = mutedText; }} + /> + +
+); - React.useEffect(() => { - if (containerRef.current) { - try { - mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' }); - const id = `mermaid-${Math.random().toString(36).substring(2)}`; - (async () => { - const result: any = await mermaid.render(id, chart); - if (containerRef.current) { - containerRef.current.innerHTML = result.svg || result; - } - })().catch((e: any) => { - if (containerRef.current) { - containerRef.current.innerHTML = `
Mermaid 解析失败: ${e.message}
`; - } - }); - } catch (e: any) { - if (containerRef.current) { - containerRef.current.innerHTML = `
Mermaid 渲染异常: ${e.message}
`; - } - } +const AIRawErrorButton: React.FC<{ + messageId: string; + rawError: string; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; +}> = ({ messageId, rawError, darkMode, overlayTheme }) => ( +
+ +
+); - return
; -}; +export const AIMessageBubble: React.FC = React.memo(({ + msg, + darkMode, + overlayTheme, + textColor, + onEdit, + onRetry, + onDelete, + activeConnectionId, + activeConnectionConfig, + activeDbName, + allMessages, +}) => { + const [isCopied, setIsCopied] = useState(false); + const isUser = msg.role === 'user'; + const toolMessages = allMessages || []; -const CodeCopyBtn = ({ text }: { text: string }) => { - const [copied, setCopied] = useState(false); - return ( - { - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }} - style={{ - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - opacity: copied ? 1 : 0.6, - transition: 'opacity 0.2s', - }} - onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }} - onMouseLeave={(e) => { e.currentTarget.style.opacity = copied ? '1' : '0.6'; }} - > - {copied ? : } - {copied ? '已复制' : '复制代码'} - - ); -}; - -const CodeRunBtn = ({ text, connectionId, dbName }: { text: string; connectionId?: string; dbName?: string }) => { - // 解析 SQL 顶部的 @context 注释,格式:-- @context connectionId=xxx dbName=yyy - const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m); - const resolvedConnId = contextMatch?.[1] || connectionId; - const resolvedDbName = contextMatch?.[2] || dbName; - // 发送给查询编辑器时去掉 @context 注释行 - const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); - const sqlDetail = (runImmediately: boolean) => ({ sql: cleanSql, runImmediately, connectionId: resolvedConnId, dbName: resolvedDbName }); - const handleExecute = async () => { - try { - const Service = (window as any).go?.aiservice?.Service; - if (Service?.AICheckSQL) { - const result = await Service.AICheckSQL(text); - if (!result.allowed) { - message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`); - return; - } - if (result.requiresConfirm) { - const { Modal } = await import('antd'); - Modal.confirm({ - title: '⚠️ 安全确认', - content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`, - okText: '确认执行', - cancelText: '取消', - okButtonProps: { danger: true }, - onOk: () => { - window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); - }, - }); - return; - } - } - // Safety check passed or not available, execute directly - window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); - } catch (e) { - // If safety check fails, still allow manual execution - window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); - } - }; - - return ( -
- - { - window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(false) })); - }} - style={{ - cursor: 'pointer', display: 'flex', alignItems: 'center', - opacity: 0.6, transition: 'opacity 0.2s', padding: '0 4px', color: '#10b981' - }} - onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }} - onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }} - > - - 插入 - - - - { e.currentTarget.style.opacity = '1'; }} - onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; }} - > - - 执行 - - -
- ); -}; - -// 阶段2: 代码块体验升级 (折叠展开、行号显示、内联SQL预览) -const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConnectionConfig, activeConnectionId, activeDbName }: any) => { - const codeText = String(children).replace(/\n$/, ''); - // 将 @context 注释行从显示文本中剔除,用户无需看到内部元数据 - const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); - const [expanded, setExpanded] = useState(false); - const [previewData, setPreviewData] = useState(null); - const [previewCols, setPreviewCols] = useState([]); - const [previewLoading, setPreviewLoading] = useState(false); - const [previewError, setPreviewError] = useState(''); - const [previewExpanded, setPreviewExpanded] = useState(false); - - const MAX_HEIGHT = 300; - const isLongCode = displayText.split('\n').length > 15; - const isSql = match[1] === 'sql'; - const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim()); - - const handleInlineExecute = async () => { - if (!activeConnectionConfig || previewLoading) return; - setPreviewLoading(true); - setPreviewError(''); - setPreviewData(null); - try { - const { DBQuery } = await import('../../../wailsjs/go/app/App'); - const previewSql = buildAIReadonlyPreviewSQL( - activeConnectionConfig?.type || '', - displayText, - 50, - activeConnectionConfig?.driver || '', - ); - const res = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql); - if (res.success && Array.isArray(res.data)) { - const rows = res.data as any[]; - const cols = rows.length > 0 ? Object.keys(rows[0]) : []; - setPreviewCols(cols); - setPreviewData(rows.slice(0, 20)); - setPreviewExpanded(true); - } else { - setPreviewError(res.message || '查询无结果'); - } - } catch (err: any) { - setPreviewError(err?.message || '执行失败'); - } finally { - setPreviewLoading(false); - } - }; - - return ( -
-
- {match[1]} -
- {isSql && } - {isSelectQuery && activeConnectionConfig && ( - - { if (!previewLoading) e.currentTarget.style.opacity = '1'; }} - onMouseLeave={(e) => { if (!previewLoading) e.currentTarget.style.opacity = '0.6'; }} - > - {previewLoading ? '⏳' : '👁'} - {previewLoading ? '执行中...' : '预览'} - - - )} - -
-
- -
- - {displayText} - - - {!expanded && isLongCode && ( -
setExpanded(true)} - > - - 展开全部代码 - -
- )} - {expanded && isLongCode && ( -
setExpanded(false)} - > - 收起代码 -
- )} -
- - {/* Inline SQL Preview Results */} - {previewError && ( -
- ❌ {previewError} -
- )} - {previewExpanded && previewData && previewData.length > 0 && ( -
-
- 📊 预览结果({previewData.length} 行 × {previewCols.length} 列) - setPreviewExpanded(false)}>收起 ▴ -
-
- - - - {previewCols.map(col => ( - - ))} - - - - {previewData.map((row, ri) => ( - - {previewCols.map(col => ( - - ))} - - ))} - -
- {col} -
- {row[col] === null ? NULL : String(row[col])} -
-
-
- )} - {!previewExpanded && previewData && previewData.length > 0 && ( -
setPreviewExpanded(true)} - > - 📊 查看结果({previewData.length} 行)▾ -
- )} -
- ); -}; - -// 可折叠思考过程组件 -const ThinkingBlock: React.FC<{ displayThinking: string; totalLen: number; isTyping: boolean; isGlobalLoading: boolean; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ displayThinking, totalLen, isTyping, isGlobalLoading, darkMode, overlayTheme, hasContent }) => { - // 如果整体在loading,且尚未吐出content,我们认为真正的思考还在进行;如果吐出content了,思考框就算告一段落 - const isActivelyThinking = isGlobalLoading && !hasContent; - const [expanded, setExpanded] = useState(isActivelyThinking); - const contentRef = React.useRef(null); - - React.useEffect(() => { if (isActivelyThinking) setExpanded(true); }, [isActivelyThinking]); - - // 断开连接或思考结束时,若已有内容且不再产生新内容则默认收起 - React.useEffect(() => { - if (!isGlobalLoading) setExpanded(false); - }, [isGlobalLoading]); - - // 自动滚动到思考内容底部 - React.useEffect(() => { - if (expanded && isTyping && contentRef.current) { - contentRef.current.scrollTop = contentRef.current.scrollHeight; - } - }, [displayThinking, expanded, isTyping]); - - return ( -
-
setExpanded(e => !e)} - style={{ - display: 'flex', alignItems: 'center', gap: 6, - padding: '6px 10px', cursor: 'pointer', - background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)', - fontSize: 12, color: overlayTheme.mutedText, userSelect: 'none', - }} - > - - 💭 思考过程 - {isActivelyThinking && 思考中...} - {!isActivelyThinking && ({displayThinking.length} 字)} -
-
-
- {displayThinking} - {isTyping && } -
-
-
- ); -}; - -// 工具调用进度面板聚合展示组件 -const AIToolCallingBlock: React.FC<{ tool_calls: AIToolCall[]; loading: boolean; allMessages: AIChatMessage[]; darkMode: boolean; overlayTheme: any; hasContent: boolean }> = ({ tool_calls, loading, allMessages, darkMode, overlayTheme, hasContent }) => { - const totalCalls = tool_calls.length; - const allDone = tool_calls.every(tc => allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id)); - const [expanded, setExpanded] = useState(!allDone && loading); - - // 断开连接或执行完毕时,若已完成则默认收起 - React.useEffect(() => { - if (allDone || !loading) setExpanded(false); - }, [allDone, loading]); - - // 显示友好的人类可读动作名 - const getHumanActionName = (fname: string) => { - if (fname === 'get_connections') return '获取可用连接信息'; - if (fname === 'get_databases') return '扫描数据库列表'; - if (fname === 'get_tables') return '分析表结构信息'; - if (fname === 'get_columns') return '核对真实字段定义'; - if (fname === 'get_indexes') return '检查索引定义'; - if (fname === 'get_foreign_keys') return '梳理外键关系'; - if (fname === 'get_triggers') return '检查触发器逻辑'; - if (fname === 'get_table_ddl') return '提取建表语句'; - if (fname === 'execute_sql') return '执行只读 SQL 验证'; - return fname; - }; - - return ( -
-
setExpanded(!expanded)} - style={{ - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '8px 12px', cursor: 'pointer', userSelect: 'none', - background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)', - }} - > -
- {!allDone && loading ? ( -
- ) : ( - - )} - {!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${totalCalls} 项)`} -
- -
-
-
- {tool_calls.map((tc, idx) => { - const resultMsg = allMessages?.find(m => m.role === 'tool' && m.tool_call_id === tc.id); - const isDone = !!resultMsg; - const actionName = getHumanActionName(tc.function.name); - return ( -
-
- {isDone - ? - : (loading ?
: ) - } - {actionName} -
- {resultMsg && } -
- ); - })} -
-
-
- ); -}; - -export const AIMessageBubble: React.FC = React.memo(({ msg, darkMode, overlayTheme, textColor, onEdit, onRetry, onDelete, activeConnectionId, activeConnectionConfig, activeDbName, allMessages }) => { - const [isCopied, setIsCopied] = useState(false); - const isUser = msg.role === 'user'; - - // 从 content 中提取 ... 标签内容(部分模型如 MiniMax、DeepSeek 会以文本形式返回思考过程) - const { displayContent, parsedThinking } = React.useMemo(() => { - const content = msg.content || ''; - // 优先使用后端已结构化的 thinking 字段(如 Claude API 原生 thinking) - if (msg.thinking) { - return { displayContent: content, parsedThinking: msg.thinking }; - } - // 尝试从 content 中提取 ... 标签 - const thinkRegex = /([\s\S]*?)(?:<\/think>|$)/g; - let thinkParts: string[] = []; - let cleanContent = content; - let match; - while ((match = thinkRegex.exec(content)) !== null) { - thinkParts.push(match[1].trim()); - } - if (thinkParts.length > 0) { - // 移除所有 ... 标签(含未闭合的) - cleanContent = content.replace(/[\s\S]*?(?:<\/think>|$)/g, '').trim(); - return { displayContent: cleanContent, parsedThinking: thinkParts.join('\n\n') }; - } - return { displayContent: content, parsedThinking: '' }; - }, [msg.content, msg.thinking]); - const jvmPlan = React.useMemo(() => { - if (isUser) { - return null; - } - return extractJVMChangePlan(displayContent); - }, [displayContent, isUser]); - const jvmDiagnosticPlan = React.useMemo(() => { - if (isUser) { - return null; - } - return parseJVMDiagnosticPlan(displayContent); - }, [displayContent, isUser]); - const isTypingThinking = !!(msg.loading && msg.phase === 'thinking'); - - if (msg.role === 'tool') return null; - - // 如果是纯空壳的加载状态(connecting,或还在思考/工具阶段但还没吐出一个字的 content) - const isWaitState = msg.phase === 'connecting' || - (msg.loading && !msg.content && (msg.phase === 'thinking' || msg.phase === 'tool_calling')); - - if (isWaitState) { - return ( -
-
-
-
- -
- {msg.content || '正在建立连接'}... -
- - {/* 即使在波纹过渡态,如果有 thinking / tool_calls 也要显示出来,只是把它们压在波纹下面 */} -
0) ? 12 : 0 }}> - {!isUser && parsedThinking && ( - - )} - {!isUser && msg.tool_calls && msg.tool_calls.length > 0 && ( - - )} -
-
-
- ); + const { displayContent, parsedThinking } = React.useMemo(() => { + const content = msg.content || ''; + if (msg.thinking) { + return { displayContent: content, parsedThinking: msg.thinking }; } + const thinkRegex = /([\s\S]*?)(?:<\/think>|$)/g; + const thinkParts: string[] = []; + let match: RegExpExecArray | null; + while ((match = thinkRegex.exec(content)) !== null) { + thinkParts.push(match[1].trim()); + } + if (thinkParts.length > 0) { + return { + displayContent: content.replace(/[\s\S]*?(?:<\/think>|$)/g, '').trim(), + parsedThinking: thinkParts.join('\n\n'), + }; + } + return { displayContent: content, parsedThinking: '' }; + }, [msg.content, msg.thinking]); + const jvmPlan = React.useMemo(() => { + if (isUser) { + return null; + } + return extractJVMChangePlan(displayContent); + }, [displayContent, isUser]); + + const jvmDiagnosticPlan = React.useMemo(() => { + if (isUser) { + return null; + } + return parseJVMDiagnosticPlan(displayContent); + }, [displayContent, isUser]); + + const isTypingThinking = Boolean(msg.loading && msg.phase === 'thinking'); + + if (msg.role === 'tool') { + return null; + } + + const isWaitState = msg.phase === 'connecting' + || (msg.loading && !msg.content && (msg.phase === 'thinking' || msg.phase === 'tool_calling')); + + if (isWaitState) { return ( -
-
-
-
- {isUser - ? <> You - : <> GoNavi AI} -
- {/* 气泡操作栏 */} -
- - {isCopied ? ( - - ) : ( - { - navigator.clipboard.writeText(msg.content); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - }} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> - )} - - {isUser ? ( - - onEdit(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> - - ) : ( - - onRetry(msg)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = textColor} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> - - )} - - onDelete(msg.id)} style={{ cursor: 'pointer', color: overlayTheme.mutedText }} onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText} /> - -
-
-
- {msg.images && msg.images.length > 0 && ( -
- {msg.images.map((img, i) => ( - {`Attached - ))} -
- )} - {/* 可折叠思考过程 */} - {!isUser && parsedThinking && ( - - )} - {isUser ? ( -
{msg.content}
- ) : ( - - )} - {!isUser && jvmPlan && ( -
- -
- )} - {!isUser && jvmDiagnosticPlan && ( -
- -
- )} - {/* 错误原文复制按钮 */} - {!isUser && msg.rawError && ( -
- -
- )} - {/* 工具调用进度展示 */} - {!isUser && msg.tool_calls && msg.tool_calls.length > 0 && ( - - )} - {msg.loading && msg.phase !== 'tool_calling' && msg.content && ( - - )} -
+
+
+
+
+
+ {msg.content || '正在建立连接'}... +
+ +
0) ? 12 : 0 }}> + {!isUser && parsedThinking && ( + + )} + {!isUser && msg.tool_calls && msg.tool_calls.length > 0 && ( + + )} +
+
); + } + + return ( +
+
+
+
+ {isUser + ? <> You + : <> GoNavi AI} +
+ { + navigator.clipboard.writeText(msg.content); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }} + /> +
+ +
+ {msg.images && msg.images.length > 0 && ( +
+ {msg.images.map((image, index) => ( + {`Attached + ))} +
+ )} + + {!isUser && parsedThinking && ( + + )} + + {isUser ? ( +
{msg.content}
+ ) : ( + + )} + + {!isUser && jvmPlan && ( +
+ +
+ )} + + {!isUser && jvmDiagnosticPlan && ( +
+ +
+ )} + + {!isUser && msg.rawError && ( + + )} + + {!isUser && msg.tool_calls && msg.tool_calls.length > 0 && ( + + )} + + {msg.loading && msg.phase !== 'tool_calling' && msg.content && ( + + )} +
+
+
+ ); }); diff --git a/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx new file mode 100644 index 0000000..2e48aa9 --- /dev/null +++ b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { AIMessageMarkdown } from './AIMessageMarkdown'; +import { buildOverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; + +describe('AIMessageMarkdown', () => { + it('keeps SQL code block actions after extracting markdown rendering', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('复制代码'); + expect(markup).toContain('插入'); + expect(markup).toContain('执行'); + expect(markup).toContain('预览'); + }); +}); diff --git a/frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx new file mode 100644 index 0000000..28f9cb8 --- /dev/null +++ b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.tsx @@ -0,0 +1,440 @@ +import React, { useState } from 'react'; +import { Tooltip, message } from 'antd'; +import { CheckOutlined, CopyOutlined, PlayCircleOutlined } from '@ant-design/icons'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import mermaid from 'mermaid'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; +import { normalizeAiMarkdown } from '../../../utils/aiMarkdown'; +import { buildAIReadonlyPreviewSQL } from '../../../utils/aiSqlLimit'; + +const remarkPlugins = [remarkGfm]; + +interface AIMessageMarkdownProps { + content: string; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + activeConnectionConfig?: any; + activeConnectionId?: string; + activeDbName?: string; +} + +interface AICodeBlockProps { + className?: string; + inline?: boolean; + children?: React.ReactNode; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + activeConnectionConfig?: any; + activeConnectionId?: string; + activeDbName?: string; +} + +const MermaidRenderer: React.FC<{ chart: string; darkMode: boolean }> = ({ chart, darkMode }) => { + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (!containerRef.current) { + return; + } + try { + mermaid.initialize({ startOnLoad: false, theme: darkMode ? 'dark' : 'default' }); + const id = `mermaid-${Math.random().toString(36).slice(2)}`; + (async () => { + const result: any = await mermaid.render(id, chart); + if (containerRef.current) { + containerRef.current.innerHTML = result.svg || result; + } + })().catch((error: any) => { + if (containerRef.current) { + containerRef.current.innerHTML = `
Mermaid 解析失败: ${error.message}
`; + } + }); + } catch (error: any) { + if (containerRef.current) { + containerRef.current.innerHTML = `
Mermaid 渲染异常: ${error.message}
`; + } + } + }, [chart, darkMode]); + + return
; +}; + +const CodeCopyButton: React.FC<{ text: string }> = ({ text }) => { + const [copied, setCopied] = useState(false); + + return ( + { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }} + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + opacity: copied ? 1 : 0.6, + transition: 'opacity 0.2s', + }} + onMouseEnter={(event) => { event.currentTarget.style.opacity = '1'; }} + onMouseLeave={(event) => { event.currentTarget.style.opacity = copied ? '1' : '0.6'; }} + > + {copied ? : } + {copied ? '已复制' : '复制代码'} + + ); +}; + +const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: string }> = ({ text, connectionId, dbName }) => { + const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m); + const resolvedConnId = contextMatch?.[1] || connectionId; + const resolvedDbName = contextMatch?.[2] || dbName; + const cleanSql = text.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); + const sqlDetail = (runImmediately: boolean) => ({ + sql: cleanSql, + runImmediately, + connectionId: resolvedConnId, + dbName: resolvedDbName, + }); + + const handleExecute = async () => { + try { + const Service = (window as any).go?.aiservice?.Service; + if (Service?.AICheckSQL) { + const result = await Service.AICheckSQL(text); + if (!result.allowed) { + message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`); + return; + } + if (result.requiresConfirm) { + const { Modal } = await import('antd'); + Modal.confirm({ + title: '⚠️ 安全确认', + content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`, + okText: '确认执行', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: () => { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + }, + }); + return; + } + } + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + } catch { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); + } + }; + + return ( +
+ + { + window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(false) })); + }} + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + opacity: 0.6, + transition: 'opacity 0.2s', + padding: '0 4px', + color: '#10b981', + }} + onMouseEnter={(event) => { event.currentTarget.style.opacity = '1'; }} + onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }} + > + + 插入 + + + + { event.currentTarget.style.opacity = '1'; }} + onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }} + > + + 执行 + + +
+ ); +}; + +const AICodeBlock: React.FC = ({ + className, + inline, + children, + darkMode, + overlayTheme, + activeConnectionConfig, + activeConnectionId, + activeDbName, +}) => { + const match = /language-(\w+)/.exec(className || ''); + if (!inline && match && match[1] === 'mermaid') { + return ; + } + + if (!inline && match) { + const codeText = String(children).replace(/\n$/, ''); + const displayText = codeText.replace(/^--\s*@context\s+.*\n?/gm, '').trim(); + const [expanded, setExpanded] = useState(false); + const [previewData, setPreviewData] = useState(null); + const [previewCols, setPreviewCols] = useState([]); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(''); + const [previewExpanded, setPreviewExpanded] = useState(false); + const isLongCode = displayText.split('\n').length > 15; + const isSql = match[1] === 'sql'; + const isSelectQuery = isSql && /^\s*(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i.test(displayText.trim()); + + const handleInlineExecute = async () => { + if (!activeConnectionConfig || previewLoading) { + return; + } + setPreviewLoading(true); + setPreviewError(''); + setPreviewData(null); + try { + const { DBQuery } = await import('../../../../wailsjs/go/app/App'); + const previewSql = buildAIReadonlyPreviewSQL( + activeConnectionConfig?.type || '', + displayText, + 50, + activeConnectionConfig?.driver || '', + ); + const response = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql); + if (response.success && Array.isArray(response.data)) { + const rows = response.data as any[]; + setPreviewCols(rows.length > 0 ? Object.keys(rows[0]) : []); + setPreviewData(rows.slice(0, 20)); + setPreviewExpanded(true); + } else { + setPreviewError(response.message || '查询无结果'); + } + } catch (error: any) { + setPreviewError(error?.message || '执行失败'); + } finally { + setPreviewLoading(false); + } + }; + + return ( +
+
+ {match[1]} +
+ {isSql && } + {isSelectQuery && activeConnectionConfig && ( + + { + if (!previewLoading) { + event.currentTarget.style.opacity = '1'; + } + }} + onMouseLeave={(event) => { + if (!previewLoading) { + event.currentTarget.style.opacity = '0.6'; + } + }} + > + {previewLoading ? '⏳' : '👁'} + {previewLoading ? '执行中...' : '预览'} + + + )} + +
+
+ +
+ + {displayText} + + + {!expanded && isLongCode && ( +
setExpanded(true)} + > + + 展开全部代码 + +
+ )} + {expanded && isLongCode && ( +
setExpanded(false)} + > + 收起代码 +
+ )} +
+ + {previewError && ( +
+ ❌ {previewError} +
+ )} + {previewExpanded && previewData && previewData.length > 0 && ( +
+
+ 📊 预览结果({previewData.length} 行 × {previewCols.length} 列) + setPreviewExpanded(false)}>收起 ▴ +
+
+ + + + {previewCols.map((column) => ( + + ))} + + + + {previewData.map((row, rowIndex) => ( + + {previewCols.map((column) => ( + + ))} + + ))} + +
+ {column} +
+ {row[column] === null ? NULL : String(row[column])} +
+
+
+ )} + {!previewExpanded && previewData && previewData.length > 0 && ( +
setPreviewExpanded(true)} + > + 📊 查看结果({previewData.length} 行)▾ +
+ )} +
+ ); + } + + return {children}; +}; + +export const AIMessageMarkdown: React.FC = React.memo(({ + content, + darkMode, + overlayTheme, + activeConnectionConfig, + activeConnectionId, + activeDbName, +}) => { + const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]); + const components = React.useMemo(() => ({ + code({ inline, className, children }: any) { + return ( + + {children} + + ); + }, + }), [darkMode, overlayTheme, activeConnectionConfig, activeConnectionId, activeDbName]); + + return ( + + {normalizedContent} + + ); +}); diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx new file mode 100644 index 0000000..dbcc2fa --- /dev/null +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ApiOutlined, CaretDownOutlined, CaretRightOutlined, CheckOutlined } from '@ant-design/icons'; + +import type { AIChatMessage, AIToolCall } from '../../../types'; +import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; + +interface AIThinkingBlockProps { + displayThinking: string; + isTyping: boolean; + isGlobalLoading: boolean; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + hasContent: boolean; +} + +interface AIToolCallingBlockProps { + toolCalls: AIToolCall[]; + loading: boolean; + allMessages: AIChatMessage[]; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; + hasContent: boolean; +} + +const TOOL_ACTION_LABELS: Record = { + get_connections: '获取可用连接信息', + get_databases: '扫描数据库列表', + get_tables: '分析表结构信息', + get_columns: '核对真实字段定义', + get_indexes: '检查索引定义', + get_foreign_keys: '梳理外键关系', + get_triggers: '检查触发器逻辑', + get_table_ddl: '提取建表语句', + execute_sql: '执行只读 SQL 验证', +}; + +const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage; darkMode: boolean; overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => { + const [toolExpanded, setToolExpanded] = useState(false); + const charCount = resultMsg.content ? resultMsg.content.length : 0; + + return ( +
+
setToolExpanded((prev) => !prev)} + > + {toolExpanded ? : } + + 探针执行结果 ({resultMsg.tool_name || 'unknown'}) + {charCount > 0 ? `${charCount} 个字符` : '无数据'} +
+ {toolExpanded && ( +
+ {resultMsg.content} +
+ )} +
+ ); +}; + +export const AIThinkingBlock: React.FC = ({ + displayThinking, + isTyping, + isGlobalLoading, + darkMode, + overlayTheme, + hasContent, +}) => { + const isActivelyThinking = isGlobalLoading && !hasContent; + const [expanded, setExpanded] = useState(isActivelyThinking); + const contentRef = React.useRef(null); + + useEffect(() => { + if (isActivelyThinking) { + setExpanded(true); + } + }, [isActivelyThinking]); + + useEffect(() => { + if (!isGlobalLoading) { + setExpanded(false); + } + }, [isGlobalLoading]); + + useEffect(() => { + if (expanded && isTyping && contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight; + } + }, [displayThinking, expanded, isTyping]); + + return ( +
+
setExpanded((prev) => !prev)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '6px 10px', + cursor: 'pointer', + background: darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)', + fontSize: 12, + color: overlayTheme.mutedText, + userSelect: 'none', + }} + > + + 💭 思考过程 + {isActivelyThinking && 思考中...} + {!isActivelyThinking && ({displayThinking.length} 字)} +
+
+
+ {displayThinking} + {isTyping && } +
+
+
+ ); +}; + +export const AIToolCallingBlock: React.FC = ({ + toolCalls, + loading, + allMessages, + darkMode, + overlayTheme, + hasContent, +}) => { + const toolResultsById = useMemo(() => { + return new Map( + allMessages + .filter((message) => message.role === 'tool' && message.tool_call_id) + .map((message) => [message.tool_call_id as string, message]), + ); + }, [allMessages]); + const allDone = toolCalls.every((toolCall) => toolResultsById.has(toolCall.id)); + const [expanded, setExpanded] = useState(!allDone && loading); + + useEffect(() => { + if (allDone || !loading) { + setExpanded(false); + } + }, [allDone, loading]); + + return ( +
+
setExpanded((prev) => !prev)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 12px', + cursor: 'pointer', + userSelect: 'none', + background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)', + }} + > +
+ {!allDone && loading ? ( +
+ ) : ( + + )} + {!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${toolCalls.length} 项)`} +
+ +
+
+
+ {toolCalls.map((toolCall) => { + const resultMsg = toolResultsById.get(toolCall.id); + const isDone = Boolean(resultMsg); + const actionName = TOOL_ACTION_LABELS[toolCall.function.name] || toolCall.function.name; + return ( +
+
+ {isDone + ? + : (loading ?
: )} + {actionName} +
+ {resultMsg && } +
+ ); + })} +
+
+
+ ); +};