feat(jvm): 接入 AI 结构化变更计划

- 新增 JVM AI 计划解析器与 fenced json 契约测试
- 为 JVM 资源页注入 AI 计划生成 prompt 并支持回填草稿
- 在 AI 对话上下文中补充 JVM 资源约束与应用入口
This commit is contained in:
Syngnat
2026-04-23 12:42:02 +08:00
parent 9a61622568
commit 3cb2d494cc
5 changed files with 376 additions and 6 deletions

View File

@@ -306,10 +306,15 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const messages = aiChatHistory[sid] || [];
const getConnectionName = useCallback(() => {
if (!activeContext?.connectionId) return '';
const conn = connections.find(c => c.id === activeContext.connectionId);
let connectionId = activeContext?.connectionId;
if (!connectionId) {
const activeTab = tabs.find(t => t.id === activeTabId);
connectionId = activeTab?.connectionId;
}
if (!connectionId) return '';
const conn = connections.find(c => c.id === connectionId);
return conn ? conn.name : '';
}, [activeContext, connections]);
}, [activeContext, activeTabId, connections, tabs]);
const activeConnName = getConnectionName();
@@ -735,11 +740,45 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const connectionKey = ctx?.connectionId ? `${ctx.connectionId}:${ctx.dbName || ''}` : 'default';
const activeContextItems = ctxMap[connectionKey] || [];
const systemMessages: { role: string; content: string; images?: string[] }[] = [];
const activeTab = allTabs.find(t => t.id === tabId);
const activeConnection = activeTab?.connectionId
? conns.find(c => c.id === activeTab.connectionId)
: undefined;
if (
activeTab &&
(activeTab.type === 'jvm-resource' || activeTab.type === 'jvm-overview' || activeTab.type === 'jvm-audit') &&
activeConnection?.config?.type === 'jvm'
) {
const providerMode = activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx';
const resourcePath = activeTab.resourcePath || '';
const readOnly = activeConnection.config.jvm?.readOnly !== false;
const environment = activeConnection.config.jvm?.environment || 'unknown';
systemMessages.push({
role: 'system',
content: `你是 GoNavi 的 JVM 运行时分析助手。当前上下文不是 SQL而是 JVM 资源工作台。
当前连接:${activeConnection.name}
目标主机:${activeConnection.config.host || '-'}
Provider 模式:${providerMode}
运行环境:${environment}
连接策略:${readOnly ? '只读连接,只能分析和生成变更计划,绝不能假设已执行写入。' : '可写连接,但任何修改都必须先生成预览并等待人工确认。'}
${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体资源路径。'}
回答规则:
1. 你可以解释资源结构、风险、修改建议和回滚建议。
2. 如果用户要求生成 JVM 修改方案,必须输出一个唯一的 \`\`\`json 代码块,并且 JSON 字段严格限定为 targetType、selector、action、payload、reason。
3. action 只能使用 updateValue、evict、clear 之一。
4. selector.resourcePath 优先使用当前资源路径;如果当前路径未知,就明确说明无法精确定位,不要编造路径。
5. payload 必须保持对象结构,例如 {"format":"json","value":{"status":"ACTIVE"}}。
6. 不要输出脚本、命令或“已经执行成功”之类的表述。`
});
return systemMessages;
}
let targetConnId = ctx?.connectionId;
let targetDbName = ctx?.dbName;
if (!targetConnId || !targetDbName) {
const activeTab = allTabs.find(t => t.id === tabId);
if (activeTab && activeTab.connectionId && activeTab.dbName) {
targetConnId = activeTab.connectionId;
targetDbName = activeTab.dbName;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Descriptions, Empty, Input, Skeleton, Space, Tag, Typography } from 'antd';
import { FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { FileSearchOutlined, ReloadOutlined, RobotOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import type {
@@ -12,6 +12,7 @@ import type {
TabData,
} from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { buildJVMAIPlanPrompt, type JVMAIChangePlan } from '../utils/jvmAiPlan';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import JVMModeBadge from './jvm/JVMModeBadge';
import JVMChangePreviewModal from './jvm/JVMChangePreviewModal';
@@ -46,6 +47,14 @@ const formatValue = (value: unknown): string => {
}
};
const formatPlanPayload = (plan: JVMAIChangePlan): string => {
try {
return JSON.stringify(plan.payload ?? {}, null, 2);
} catch {
return '{}';
}
};
const normalizePreviewResult = (value: any): JVMChangePreview | null => {
if (value && typeof value === 'object' && typeof value.allowed === 'boolean') {
return value as JVMChangePreview;
@@ -145,6 +154,34 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
setPreviewResult(null);
}, [providerMode, resourcePath, tab.connectionId]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail as { plan?: JVMAIChangePlan; targetTabId?: string } | undefined;
const plan = detail?.plan;
if (!plan || (detail?.targetTabId && detail.targetTabId !== tab.id)) {
return;
}
const targetPath = String(plan.selector.resourcePath || '').trim();
if (targetPath && targetPath !== resourcePath) {
setDraftError(`AI 计划指向资源 ${targetPath},当前页签是 ${resourcePath || '-'},请切换到对应资源后再应用。`);
setApplyMessage('');
return;
}
setAction(plan.action);
setReason(plan.reason);
setPayloadText(formatPlanPayload(plan));
setDraftError('');
setApplyMessage('已从 AI 计划填充草稿,请先执行“预览变更”再确认写入。');
setPreviewOpen(false);
setPreviewResult(null);
};
window.addEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener);
return () => window.removeEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener);
}, [resourcePath, tab.id]);
const buildDraftPlan = (): JVMChangeRequest => {
const trimmedAction = String(action || '').trim() || 'update';
const trimmedReason = String(reason || '').trim();
@@ -191,6 +228,32 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
});
};
const handleAskAIForPlan = () => {
if (!connection) {
setDraftError('连接不存在或已被删除');
return;
}
const prompt = buildJVMAIPlanPrompt({
connectionName: connection.name,
host: connection.config.host,
providerMode,
resourcePath,
readOnly,
environment: connection.config.jvm?.environment,
snapshot,
});
const store = useStore.getState();
const wasClosed = !store.aiPanelVisible;
if (wasClosed) {
store.setAIPanelVisible(true);
}
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
}, wasClosed ? 350 : 0);
};
const handlePreview = async () => {
if (!connection) {
setDraftError('连接不存在或已被删除');
@@ -312,6 +375,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
<Button size="small" icon={<FileSearchOutlined />} onClick={handleOpenAudit}>
</Button>
<Button size="small" icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
AI
</Button>
</Space>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
@@ -415,6 +481,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
<Button type="primary" loading={previewLoading} onClick={() => void handlePreview()}>
</Button>
<Button icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
AI
</Button>
</Space>
</Space>
</Card>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { Tooltip, message } from 'antd';
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';
@@ -7,8 +7,10 @@ import mermaid from 'mermaid';
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 { useStore } from '../../store';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
import { extractJVMChangePlan } from '../../utils/jvmAiPlan';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -568,6 +570,12 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
}
return { displayContent: content, parsedThinking: '' };
}, [msg.content, msg.thinking]);
const jvmPlan = React.useMemo(() => {
if (isUser) {
return null;
}
return extractJVMChangePlan(displayContent);
}, [displayContent, isUser]);
const isTypingThinking = !!(msg.loading && msg.phase === 'thinking');
if (msg.role === 'tool') return null;
@@ -695,6 +703,22 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
activeDbName={activeDbName}
/>
)}
{!isUser && jvmPlan && (
<div style={{ marginTop: 12 }}>
<Button
size="small"
type="primary"
onClick={() => {
const targetTabId = useStore.getState().activeTabId;
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-ai-plan', {
detail: { plan: jvmPlan, targetTabId },
}));
}}
>
JVM
</Button>
</div>
)}
{/* 错误原文复制按钮 */}
{!isUser && msg.rawError && (
<div style={{ marginTop: 8 }}>