mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-10 17:43:15 +08:00
✨ feat(jvm): 接入 AI 结构化变更计划
- 新增 JVM AI 计划解析器与 fenced json 契约测试 - 为 JVM 资源页注入 AI 计划生成 prompt 并支持回填草稿 - 在 AI 对话上下文中补充 JVM 资源约束与应用入口
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
42
frontend/src/utils/jvmAiPlan.test.ts
Normal file
42
frontend/src/utils/jvmAiPlan.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractJVMChangePlan } from './jvmAiPlan';
|
||||
|
||||
describe('extractJVMChangePlan', () => {
|
||||
it('parses fenced json plan with namespace and key selector', () => {
|
||||
const message = [
|
||||
'建议先预览再执行:',
|
||||
'```json',
|
||||
'{"targetType":"cacheEntry","selector":{"namespace":"orders","key":"user:1"},"action":"updateValue","payload":{"format":"json","value":{"status":"ACTIVE"}},"reason":"修复缓存脏值"}',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const plan = extractJVMChangePlan(message);
|
||||
expect(plan?.action).toBe('updateValue');
|
||||
expect(plan?.selector.namespace).toBe('orders');
|
||||
expect(plan?.selector.key).toBe('user:1');
|
||||
});
|
||||
|
||||
it('parses fenced json plan with explicit resource path', () => {
|
||||
const message = [
|
||||
'```json',
|
||||
'{"targetType":"managedBean","selector":{"resourcePath":"/cache/orders/user:1"},"action":"clear","reason":"触发受控清理"}',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const plan = extractJVMChangePlan(message);
|
||||
expect(plan?.targetType).toBe('managedBean');
|
||||
expect(plan?.selector.resourcePath).toBe('/cache/orders/user:1');
|
||||
expect(plan?.action).toBe('clear');
|
||||
});
|
||||
|
||||
it('returns null for malformed plan', () => {
|
||||
expect(extractJVMChangePlan('```json\n{"action":1}\n```')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when selector is missing', () => {
|
||||
expect(
|
||||
extractJVMChangePlan('```json\n{"targetType":"cacheEntry","action":"evict","reason":"修复缓存脏值"}\n```'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
196
frontend/src/utils/jvmAiPlan.ts
Normal file
196
frontend/src/utils/jvmAiPlan.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { JVMValueSnapshot } from '../types';
|
||||
|
||||
export type JVMAIChangePlan = {
|
||||
targetType: 'cacheEntry' | 'managedBean';
|
||||
selector: {
|
||||
namespace?: string;
|
||||
key?: string;
|
||||
resourcePath?: string;
|
||||
};
|
||||
action: 'updateValue' | 'evict' | 'clear';
|
||||
payload?: {
|
||||
format: 'json' | 'text';
|
||||
value: unknown;
|
||||
};
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type JVMAIPlanPromptContext = {
|
||||
connectionName: string;
|
||||
host?: string;
|
||||
providerMode: 'jmx' | 'endpoint' | 'agent';
|
||||
resourcePath: string;
|
||||
readOnly: boolean;
|
||||
environment?: string;
|
||||
snapshot?: JVMValueSnapshot | null;
|
||||
};
|
||||
|
||||
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
|
||||
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean']);
|
||||
const allowedActions = new Set<JVMAIChangePlan['action']>(['updateValue', 'evict', 'clear']);
|
||||
const allowedPayloadFormats = new Set<NonNullable<JVMAIChangePlan['payload']>['format']>(['json', 'text']);
|
||||
|
||||
const asTrimmedString = (value: unknown): string => String(value ?? '').trim();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const normalizeSelector = (value: unknown): JVMAIChangePlan['selector'] | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selector: JVMAIChangePlan['selector'] = {};
|
||||
const namespace = asTrimmedString(value.namespace);
|
||||
const key = asTrimmedString(value.key);
|
||||
const resourcePath = asTrimmedString(value.resourcePath);
|
||||
|
||||
if (namespace) {
|
||||
selector.namespace = namespace;
|
||||
}
|
||||
if (key) {
|
||||
selector.key = key;
|
||||
}
|
||||
if (resourcePath) {
|
||||
selector.resourcePath = resourcePath;
|
||||
}
|
||||
|
||||
return selector.namespace || selector.key || selector.resourcePath ? selector : null;
|
||||
};
|
||||
|
||||
const normalizePayload = (value: unknown): JVMAIChangePlan['payload'] | undefined => {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const format = asTrimmedString(value.format) as NonNullable<JVMAIChangePlan['payload']>['format'];
|
||||
if (!allowedPayloadFormats.has(format)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
format,
|
||||
value: value.value,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlan = (value: unknown): JVMAIChangePlan | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetType = asTrimmedString(value.targetType) as JVMAIChangePlan['targetType'];
|
||||
const action = asTrimmedString(value.action) as JVMAIChangePlan['action'];
|
||||
const reason = asTrimmedString(value.reason);
|
||||
const selector = normalizeSelector(value.selector);
|
||||
const payload = normalizePayload(value.payload);
|
||||
|
||||
if (!allowedTargetTypes.has(targetType) || !allowedActions.has(action) || !reason || !selector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetType,
|
||||
selector,
|
||||
action,
|
||||
payload,
|
||||
reason,
|
||||
};
|
||||
};
|
||||
|
||||
const formatSnapshotValue = (snapshot?: JVMValueSnapshot | null): string => {
|
||||
if (!snapshot) {
|
||||
return '当前资源快照尚未加载成功。';
|
||||
}
|
||||
if (typeof snapshot.value === 'string') {
|
||||
return snapshot.value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(snapshot.value ?? null, null, 2);
|
||||
} catch {
|
||||
return String(snapshot.value);
|
||||
}
|
||||
};
|
||||
|
||||
export const extractJVMChangePlan = (content: string): JVMAIChangePlan | null => {
|
||||
const source = String(content || '');
|
||||
planFencePattern.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = planFencePattern.exec(source)) !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
const normalized = normalizePlan(parsed);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed JSON blocks and continue scanning.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildJVMAIPlanPrompt = ({
|
||||
connectionName,
|
||||
host,
|
||||
providerMode,
|
||||
resourcePath,
|
||||
readOnly,
|
||||
environment,
|
||||
snapshot,
|
||||
}: JVMAIPlanPromptContext): string => {
|
||||
const normalizedPath = asTrimmedString(resourcePath) || '(未提供资源路径)';
|
||||
const snapshotFormat = asTrimmedString(snapshot?.format) || 'json';
|
||||
const environmentLabel = asTrimmedString(environment) || 'unknown';
|
||||
|
||||
return [
|
||||
'请分析下面这个 JVM 资源,并生成一个可用于 GoNavi “预览变更” 的结构化修改计划。',
|
||||
'',
|
||||
`连接名称:${connectionName}`,
|
||||
`目标主机:${asTrimmedString(host) || '-'}`,
|
||||
`Provider 模式:${providerMode}`,
|
||||
`运行环境:${environmentLabel}`,
|
||||
`连接策略:${readOnly ? '只读连接,当前只能生成计划和风险分析,不能假设已执行' : '可写连接,但仍必须先预览再人工确认'}`,
|
||||
`当前资源路径:${normalizedPath}`,
|
||||
'',
|
||||
'当前资源快照:',
|
||||
`\`\`\`${snapshotFormat}`,
|
||||
formatSnapshotValue(snapshot),
|
||||
'```',
|
||||
'',
|
||||
'输出要求:',
|
||||
'1. 可以先给一小段分析,但必须包含且只包含一个 ```json 代码块。',
|
||||
'2. 代码块里的 JSON 字段必须严格是:targetType、selector、action、payload、reason。',
|
||||
`3. selector.resourcePath 优先使用当前资源路径 ${normalizedPath},不要凭空编造其他路径。`,
|
||||
'4. action 只能使用 updateValue、evict、clear 之一。',
|
||||
'5. payload 必须保持对象结构,例如 {"format":"json","value":{"status":"ACTIVE"}}。',
|
||||
'6. 不要声称已经执行修改,也不要输出脚本或命令。',
|
||||
'',
|
||||
'JSON 示例:',
|
||||
'```json',
|
||||
JSON.stringify(
|
||||
{
|
||||
targetType: 'cacheEntry',
|
||||
selector: {
|
||||
resourcePath: normalizedPath,
|
||||
},
|
||||
action: 'updateValue',
|
||||
payload: {
|
||||
format: 'json',
|
||||
value: {
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
},
|
||||
reason: '修复缓存脏值',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'```',
|
||||
].join('\n');
|
||||
};
|
||||
Reference in New Issue
Block a user