🐛 fix(jvm): 修正 AI 计划映射与页签定向应用

- 为 JVM AI 计划补充显式草稿映射,避免 payload 包装层直接透传到现有变更契约
- 将 updateValue 映射为当前 JVM 写入链路的 put,并限制为 JSON 对象 payload
- 为 AI 聊天消息绑定 JVM 来源上下文,按 tab/connection/provider/resource 定向应用计划
- 补充 JVM AI 计划解析、契约映射和目标页签解析单测
- 更新需求追踪并回填 go test、前端测试、构建与 wails build 验证结果
This commit is contained in:
Syngnat
2026-04-23 13:02:04 +08:00
parent 3cb2d494cc
commit d2c3e3e779
10 changed files with 470 additions and 41 deletions

View File

@@ -26,9 +26,9 @@
- [x] 阶段 2影响分析完成
- [x] 阶段 3方案设计完成已形成正式设计文档
- [x] 阶段 4实施计划完成已形成正式实施计划
- [ ] 阶段 5实现与自检进行中Task 1 至 Task 5 已完成并通过回归Task 6/7 待继续
- [ ] 阶段 6评审与交付
- [ ] 阶段 7发布与观察
- [x] 阶段 5实现与自检完成Task 1 至 Task 7 已完成,代码与构建回归通过
- [x] 阶段 6评审与交付完成(已完成契约复核、上下文隔离修正、文档回填与交付检查)
- [ ] 阶段 7发布与观察未开始
## 4. 变更清单
- 已完成:
@@ -45,10 +45,14 @@
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 已完成 Task 4只读资源浏览与 JVM Tab
- 已完成 Task 5写入预览、Guard 和审计记录
- 进行中:
- Task 6AI 结构化变更计划
- 已完成 Task 6AI 结构化变更计划
- 已完成 Task 7全量回归、文档回填与交付检查
- 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约
- 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环
- 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页
- 待处理:
- Task 7全量回归、文档回填与交付检查
- 真实 provider 的 `PreviewChange` / `ApplyChange` 端到端能力验证
- AI 参与信息写入 JVM 审计记录
## 5. 风险与阻塞
- 风险:
@@ -59,18 +63,29 @@
- 多接入模式会带来能力不一致问题UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
- 当前 JMX / Endpoint provider 的资源浏览与值读取仍是骨架实现Task 4 已打通接口与 UI 链路,但真实资源展开会返回 `not implemented`
- Task 5 已打通 Guard/Preview/Audit 主链路,但真实 provider 的 `PreviewChange` / `ApplyChange` 能力仍依赖后续 provider 细化实现
- 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力
- 当前 MVP 中 `updateValue` 会映射为现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象;非对象值、复杂二进制值和 provider 专有写法不在本期承诺范围
- AI 计划若只提供 `namespace + key`,当前仅按 `namespace/key` 推导资源 ID属于 MVP 级资源定位约定,未承诺适配所有 provider 的资源 ID 规则
- JVM 审计记录当前未显式记录“AI 是否参与”,与理想审计模型仍有差距
- 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划
- 阻塞:
- 目标应用技术栈、缓存框架与接入约束尚未明确
- 当前开发收口阶段无新增阻塞
- 缓解措施:
- 优先收敛到标准接入面JMX / Spring Actuator / Java Agent 三选一)
- 首期只支持白名单对象类型与受控写操作
- 要求变更审计、预览、确认与失败回滚路径
- 在交付说明中明确“AI 只生成草稿,不代表 provider 已具备真实写能力”
- 后续如进入真实接入阶段,优先补齐 provider 端到端验证与 AI 审计字段
## 6. 决策记录
- 决策 1先做可行性评估与方案设计不直接进入实现
- 决策 2默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程
- 决策 3已按完整模式推进后续方案将优先评估通用 Agent 路径是否成立
- 决策 4由于目标服务大概率不允许 agent/attach后续推荐方向转为“多接入模式 + 能力协商”
- 决策 5AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入
- 决策 6AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认
- 决策 7当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象
- 决策 8JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath`
## 7. 验证记录
- 验证项:
@@ -80,6 +95,11 @@
- JVM Connector 实施计划文档自检
- Task 1JVM 共享契约与配置归一化
- Task 2Provider 注册、连接测试与能力探测 API
- Task 6AI 计划解析、资源定位解析、契约映射与页签上下文隔离
- Task 7后端全量测试
- Task 7前端全量测试
- Task 7前端生产构建
- Task 7Wails 生产构建
- 结果:
- 已确认存在可复用的连接桥接与编辑器基础设施
- 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突
@@ -95,6 +115,13 @@
- 已完成 Task 5后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
- 已完成 Task 6AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测
- 已完成 Task 6AI 聊天消息与 JVM 来源页签绑定AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过10 tests
- `go test ./... -count=1` 通过
- `cd frontend && npm test -- --run` 通过61 files258 tests
- `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成
- `wails build -clean` 通过,成功生成 macOS 应用包
- 证据(日志/截图/链接):
- `cmd/optional-driver-agent/main.go`
- `internal/db/database.go`
@@ -160,7 +187,17 @@
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `cd frontend && npm run build`
- `frontend/src/utils/jvmAiPlan.ts`
- `frontend/src/utils/jvmAiPlan.test.ts`
- `frontend/src/components/AIChatPanel.tsx`
- `frontend/src/components/ai/AIMessageBubble.tsx`
- `frontend/src/components/JVMResourceBrowser.tsx`
- `frontend/src/types.ts`
- `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts`
- `go test ./... -count=1`
- `cd frontend && npm test -- --run`
- `wails build -clean`
## 8. 下一步
- 下一步行动:进入 Task 6接入 AI 结构化修改计划能力,并在 Task 7 做全量交付检查
- 下一步行动:进入真实接入阶段,优先补齐 provider 端到端写入验证、AI 参与审计字段和生产环境灰度验证
- 负责人Codex

View File

@@ -4,7 +4,7 @@ import { useStore, loadAISessionsFromBackend, loadAISessionFromBackend } from '.
import { EventsOn, EventsOff } from '../../wailsjs/runtime';
import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { AIChatMessage, AIToolCall } from '../types';
import type { AIChatMessage, AIToolCall, JVMAIPlanContext } from '../types';
import { DownOutlined } from '@ant-design/icons';
import './AIChatPanel.css';
@@ -231,6 +231,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const nudgeCountRef = useRef(0); // 催促模型使用 function call 的次数
const panelRef = useRef<HTMLDivElement>(null); // 面板 DOM ref用于拖拽时直接操作宽度
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
const pendingJVMPlanContextRef = useRef<JVMAIPlanContext | undefined>(undefined);
const aiChatHistory = useStore(state => state.aiChatHistory);
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
@@ -248,6 +249,31 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const activeTabId = useStore(state => state.activeTabId);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => {
const state = useStore.getState();
const activeTab = state.tabs.find(t => t.id === state.activeTabId);
if (!activeTab || activeTab.type !== 'jvm-resource') {
return undefined;
}
const activeConnection = state.connections.find(c => c.id === activeTab.connectionId);
if (activeConnection?.config?.type !== 'jvm') {
return undefined;
}
const resourcePath = String(activeTab.resourcePath || '').trim();
if (!resourcePath) {
return undefined;
}
return {
tabId: activeTab.id,
connectionId: activeTab.connectionId,
providerMode: (activeTab.providerMode || activeConnection.config.jvm?.preferredMode || 'jmx') as JVMAIPlanContext['providerMode'],
resourcePath,
};
}, []);
// Auto-Context Injection Hook
useEffect(() => {
if (!aiPanelVisible) return;
@@ -498,7 +524,15 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (assistantMsgId) {
updateAIChatMessage(sid, assistantMsgId, { content: `❌ 错误: ${cleanErr}`, phase: 'idle', loading: false, rawError: rawErr });
} else {
addAIChatMessage(sid, { id: genId(), role: 'assistant', phase: 'idle', content: `❌ 错误: ${cleanErr}`, rawError: rawErr, timestamp: Date.now() });
addAIChatMessage(sid, {
id: genId(),
role: 'assistant',
phase: 'idle',
content: `❌ 错误: ${cleanErr}`,
rawError: rawErr,
timestamp: Date.now(),
jvmPlanContext: pendingJVMPlanContextRef.current,
});
}
assistantMsgId = '';
setSending(false);
@@ -510,7 +544,16 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
updateAIChatMessage(sid, assistantMsgId, { tool_calls: data.tool_calls, phase: 'tool_calling' });
} else {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'tool_calling', content: '', tool_calls: data.tool_calls, timestamp: Date.now(), loading: true });
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'tool_calling',
content: '',
tool_calls: data.tool_calls,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
});
}
}
@@ -518,7 +561,16 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (data.thinking) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'thinking', content: '', thinking: data.thinking, timestamp: Date.now(), loading: true });
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'thinking',
content: '',
thinking: data.thinking,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
});
if (sending) setSending(false);
} else {
streamBuffer.thinking += data.thinking;
@@ -529,7 +581,15 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (data.content) {
if (!assistantMsgId) {
assistantMsgId = genId();
addAIChatMessage(sid, { id: assistantMsgId, role: 'assistant', phase: 'generating', content: data.content, timestamp: Date.now(), loading: true });
addAIChatMessage(sid, {
id: assistantMsgId,
role: 'assistant',
phase: 'generating',
content: data.content,
timestamp: Date.now(),
loading: true,
jvmPlanContext: pendingJVMPlanContextRef.current,
});
setSending(false);
const currentHistory = useStore.getState().aiChatHistory[sid] || [];
if (currentHistory.length <= 1) isFirstCompletion = true;
@@ -770,7 +830,7 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体
2. 如果用户要求生成 JVM 修改方案,必须输出一个唯一的 \`\`\`json 代码块,并且 JSON 字段严格限定为 targetType、selector、action、payload、reason。
3. action 只能使用 updateValue、evict、clear 之一。
4. selector.resourcePath 优先使用当前资源路径;如果当前路径未知,就明确说明无法精确定位,不要编造路径。
5. payload 必须保持对象结构,例如 {"format":"json","value":{"status":"ACTIVE"}}
5. 当前 JVM 预览 MVP 只支持 JSON 对象变更:如果 action=updateValue payload 必须 {"format":"json","value":{...}},且 value 必须是 JSON 对象evict/clear 时 payload 可以省略
6. 不要输出脚本、命令或“已经执行成功”之类的表述。`
});
return systemMessages;
@@ -843,6 +903,10 @@ SELECT * FROM users WHERE status = 1;
const toolContextMapRef = useRef<Map<string, { connectionId: string; dbName: string; tables: string[] }>>(new Map());
const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => {
const currentAsstMsg = (useStore.getState().aiChatHistory[sid] || []).find(m => m.id === currentAsstMsgId);
const inheritedJVMPlanContext = currentAsstMsg?.jvmPlanContext || pendingJVMPlanContextRef.current;
pendingJVMPlanContextRef.current = inheritedJVMPlanContext;
// 【全局轮次熔断】防止模型(如 DeepSeek在已生成答案后仍无限循环调用工具
const MAX_TOOL_CALL_ROUNDS = 15;
totalToolRoundRef.current += 1;
@@ -852,6 +916,7 @@ SELECT * FROM users WHERE status = 1;
id: genId(), role: 'assistant',
content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`,
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
});
setSending(false);
return;
@@ -1040,6 +1105,7 @@ SELECT * FROM users WHERE status = 1;
id: genId(), role: 'assistant',
content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。',
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
});
setSending(false);
return;
@@ -1053,7 +1119,8 @@ SELECT * FROM users WHERE status = 1;
const chainConnectingMsg: AIChatMessage = {
id: genId(), role: 'assistant', phase: 'connecting',
content: '汇总探针执行结果中',
timestamp: Date.now(), loading: true
timestamp: Date.now(), loading: true,
jvmPlanContext: inheritedJVMPlanContext,
};
useStore.getState().addAIChatMessage(sid, chainConnectingMsg);
@@ -1118,6 +1185,7 @@ SELECT * FROM users WHERE status = 1;
content: result?.success ? result.content : `${errC}`,
rawError: (!result?.success && errC !== errR) ? errR : undefined,
timestamp: Date.now(),
jvmPlanContext: inheritedJVMPlanContext,
});
setSending(false);
}
@@ -1145,6 +1213,8 @@ SELECT * FROM users WHERE status = 1;
toolCallRoundRef.current = 0; // 重置工具调用轮次计数
totalToolRoundRef.current = 0; // 重置总轮次计数
nudgeCountRef.current = 0; // 重置催促计数
const currentJVMPlanContext = getCurrentJVMPlanContext();
pendingJVMPlanContextRef.current = currentJVMPlanContext;
const currentImages = [...draftImages];
setInput('');
@@ -1163,7 +1233,8 @@ SELECT * FROM users WHERE status = 1;
const connectingMsg: AIChatMessage = {
id: genId(), role: 'assistant', phase: 'connecting', content: '',
timestamp: Date.now(), loading: true
timestamp: Date.now(), loading: true,
jvmPlanContext: currentJVMPlanContext,
};
addAIChatMessage(sid, connectingMsg);
@@ -1215,6 +1286,7 @@ SELECT * FROM users WHERE status = 1;
content: result?.success ? result.content : `${errC2}`,
rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined,
timestamp: Date.now(),
jvmPlanContext: currentJVMPlanContext,
};
addAIChatMessage(sid, assistantMsg);
setSending(false);
@@ -1233,7 +1305,7 @@ SELECT * FROM users WHERE status = 1;
addAIChatMessage(sid, { id: genId(), role: 'assistant', content: `❌ 发送失败: ${cleanE2}`, rawError: cleanE2 !== rawE2 ? rawE2 : undefined, timestamp: Date.now() });
setSending(false);
}
}, [input, draftImages, sending, messages, addAIChatMessage, sid, activeProvider]);
}, [input, draftImages, sending, messages, addAIChatMessage, sid, activeProvider, getCurrentJVMPlanContext]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {

View File

@@ -7,12 +7,19 @@ import type {
JVMApplyResult,
JVMChangePreview,
JVMChangeRequest,
JVMAIPlanContext,
JVMValueSnapshot,
SavedConnection,
TabData,
} from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { buildJVMAIPlanPrompt, type JVMAIChangePlan } from '../utils/jvmAiPlan';
import {
buildJVMChangeDraftFromAIPlan,
buildJVMAIPlanPrompt,
matchesJVMAIPlanTargetTab,
type JVMAIChangeDraft,
type JVMAIChangePlan,
} from '../utils/jvmAiPlan';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import JVMModeBadge from './jvm/JVMModeBadge';
import JVMChangePreviewModal from './jvm/JVMChangePreviewModal';
@@ -47,9 +54,9 @@ const formatValue = (value: unknown): string => {
}
};
const formatPlanPayload = (plan: JVMAIChangePlan): string => {
const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
try {
return JSON.stringify(plan.payload ?? {}, null, 2);
return JSON.stringify(draft.payload ?? {}, null, 2);
} catch {
return '{}';
}
@@ -84,9 +91,10 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
const [loading, setLoading] = useState(true);
const [snapshot, setSnapshot] = useState<JVMValueSnapshot | null>(null);
const [error, setError] = useState('');
const [action, setAction] = useState('update');
const [action, setAction] = useState('put');
const [reason, setReason] = useState('');
const [payloadText, setPayloadText] = useState(DEFAULT_PAYLOAD_TEXT);
const [draftResourceId, setDraftResourceId] = useState('');
const [draftError, setDraftError] = useState('');
const [applyMessage, setApplyMessage] = useState('');
const [previewLoading, setPreviewLoading] = useState(false);
@@ -145,9 +153,10 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
}, [connection, providerMode, resourcePath, tab.connectionId]);
useEffect(() => {
setAction('update');
setAction('put');
setReason('');
setPayloadText(DEFAULT_PAYLOAD_TEXT);
setDraftResourceId('');
setDraftError('');
setApplyMessage('');
setPreviewOpen(false);
@@ -156,24 +165,63 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail as { plan?: JVMAIChangePlan; targetTabId?: string } | undefined;
const detail = (event as CustomEvent).detail as
| {
plan?: JVMAIChangePlan;
targetTabId?: string;
connectionId?: string;
providerMode?: JVMAIPlanContext['providerMode'];
resourcePath?: 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 || '-'},请切换到对应资源后再应用。`);
const planContext =
detail?.targetTabId && detail?.connectionId && detail?.providerMode && detail?.resourcePath
? {
tabId: detail.targetTabId,
connectionId: detail.connectionId,
providerMode: detail.providerMode,
resourcePath: detail.resourcePath,
}
: undefined;
if (!planContext) {
setDraftError('AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。');
setApplyMessage('');
setPreviewOpen(false);
setPreviewResult(null);
return;
}
setAction(plan.action);
setReason(plan.reason);
setPayloadText(formatPlanPayload(plan));
if (!matchesJVMAIPlanTargetTab(tab, planContext)) {
setDraftError('当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。');
setApplyMessage('');
setPreviewOpen(false);
setPreviewResult(null);
return;
}
let draftFromPlan: JVMAIChangeDraft;
try {
draftFromPlan = buildJVMChangeDraftFromAIPlan(plan);
} catch (err: any) {
setDraftError(err?.message || 'AI 计划暂时无法转换为 JVM 预览草稿');
setApplyMessage('');
setPreviewOpen(false);
setPreviewResult(null);
return;
}
setDraftResourceId(draftFromPlan.resourceId);
setAction(draftFromPlan.action);
setReason(draftFromPlan.reason);
setPayloadText(formatDraftPayload(draftFromPlan));
setDraftError('');
setApplyMessage('已从 AI 计划填充草稿,请先执行“预览变更”再确认写入。');
setApplyMessage(`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId}请先执行“预览变更”再确认写入。`);
setPreviewOpen(false);
setPreviewResult(null);
};
@@ -183,7 +231,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
}, [resourcePath, tab.id]);
const buildDraftPlan = (): JVMChangeRequest => {
const trimmedAction = String(action || '').trim() || 'update';
const trimmedAction = String(action || '').trim() || 'put';
const trimmedReason = String(reason || '').trim();
if (!trimmedReason) {
throw new Error('请填写变更原因');
@@ -199,7 +247,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
payload = parsed as Record<string, any>;
}
const resourceId = String(snapshot?.resourceId || resourcePath).trim();
const resourceId = String(draftResourceId || snapshot?.resourceId || resourcePath).trim();
if (!resourceId) {
throw new Error('资源 ID 为空,无法生成变更草稿');
}
@@ -447,6 +495,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
{applyMessage ? <Alert type="success" showIcon message={applyMessage} /> : null}
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="资源路径">{resourcePath || '-'}</Descriptions.Item>
<Descriptions.Item label="目标资源">{draftResourceId || resourcePath || '-'}</Descriptions.Item>
<Descriptions.Item label="资源版本">{snapshot?.version || '-'}</Descriptions.Item>
</Descriptions>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
@@ -454,7 +503,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
<Input
value={action}
onChange={(event) => setAction(event.target.value)}
placeholder="例如 update"
placeholder="例如 put"
maxLength={64}
/>
</Space>

View File

@@ -6,11 +6,11 @@ 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 { AIChatMessage, AIToolCall } from '../../types';
import type { AIChatMessage, AIToolCall } from '../../types';
import { useStore } from '../../store';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
import { extractJVMChangePlan } from '../../utils/jvmAiPlan';
import { extractJVMChangePlan, resolveJVMAIPlanTargetTabId } from '../../utils/jvmAiPlan';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -709,9 +709,27 @@ export const AIMessageBubble: React.FC<AIMessageBubbleProps> = React.memo(({ msg
size="small"
type="primary"
onClick={() => {
const targetTabId = useStore.getState().activeTabId;
const targetContext = msg.jvmPlanContext;
if (!targetContext) {
message.warning('这条 JVM 计划缺少来源页签上下文,请在目标 JVM 资源页重新生成。');
return;
}
const store = useStore.getState();
const targetTabId = resolveJVMAIPlanTargetTabId(store.tabs, targetContext);
if (!targetTabId) {
message.warning('未找到与该 JVM 计划匹配的资源页签,请先打开原目标资源后再应用。');
return;
}
window.dispatchEvent(new CustomEvent('gonavi:jvm-apply-ai-plan', {
detail: { plan: jvmPlan, targetTabId },
detail: {
plan: jvmPlan,
targetTabId,
connectionId: targetContext.connectionId,
providerMode: targetContext.providerMode,
resourcePath: targetContext.resourcePath,
},
}));
}}
>

View File

@@ -244,6 +244,13 @@ export interface TabData {
savedQueryId?: string; // Saved query identity for quick-save behavior
}
export interface JVMAIPlanContext {
tabId: string;
connectionId: string;
providerMode: 'jmx' | 'endpoint' | 'agent';
resourcePath: string;
}
export interface DatabaseNode {
title: string;
key: string;
@@ -364,6 +371,7 @@ export interface AIChatMessage {
tool_name?: string; // used for UI display
rawError?: string; // 存储未清洗的原始错误信息,用于用户复制排查
success?: boolean; // 标记探针执行是否成功
jvmPlanContext?: JVMAIPlanContext;
}
export interface AISafetyResult {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { extractJVMChangePlan } from './jvmAiPlan';
import { buildJVMChangeDraftFromAIPlan, extractJVMChangePlan, resolveJVMAIPlanResourceId, resolveJVMAIPlanTargetTabId } from './jvmAiPlan';
describe('extractJVMChangePlan', () => {
it('parses fenced json plan with namespace and key selector', () => {
@@ -15,6 +15,7 @@ describe('extractJVMChangePlan', () => {
expect(plan?.action).toBe('updateValue');
expect(plan?.selector.namespace).toBe('orders');
expect(plan?.selector.key).toBe('user:1');
expect(plan ? resolveJVMAIPlanResourceId(plan) : '').toBe('orders/user:1');
});
it('parses fenced json plan with explicit resource path', () => {
@@ -40,3 +41,115 @@ describe('extractJVMChangePlan', () => {
).toBeNull();
});
});
describe('buildJVMChangeDraftFromAIPlan', () => {
it('maps updateValue plan to current JVM change contract', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"cacheEntry","selector":{"namespace":"orders","key":"user:1"},"action":"updateValue","payload":{"format":"json","value":{"status":"ACTIVE"}},"reason":"修复缓存脏值"}\n```',
);
expect(plan).not.toBeNull();
expect(buildJVMChangeDraftFromAIPlan(plan!)).toEqual({
resourceId: 'orders/user:1',
action: 'put',
reason: '修复缓存脏值',
payload: {
status: 'ACTIVE',
},
});
});
it('maps clear plan without leaking wrapper payload fields', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"managedBean","selector":{"resourcePath":"/cache/orders"},"action":"clear","reason":"受控清理"}\n```',
);
expect(plan).not.toBeNull();
expect(buildJVMChangeDraftFromAIPlan(plan!)).toEqual({
resourceId: '/cache/orders',
action: 'clear',
reason: '受控清理',
payload: {},
});
});
it('rejects non-object update payload values for current preview contract', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"cacheEntry","selector":{"resourcePath":"/cache/orders"},"action":"updateValue","payload":{"format":"text","value":"ACTIVE"},"reason":"修复缓存脏值"}\n```',
);
expect(plan).not.toBeNull();
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览仅支持 JSON 对象作为变更 payload');
});
});
describe('resolveJVMAIPlanTargetTabId', () => {
it('prefers the original tab when message context still matches', () => {
expect(
resolveJVMAIPlanTargetTabId(
[
{
id: 'tab-orders',
title: 'orders',
type: 'jvm-resource',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
],
{
tabId: 'tab-orders',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
),
).toBe('tab-orders');
});
it('falls back to a reopened tab with the same JVM context', () => {
expect(
resolveJVMAIPlanTargetTabId(
[
{
id: 'tab-orders-reopened',
title: 'orders',
type: 'jvm-resource',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
],
{
tabId: 'tab-orders-old',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
),
).toBe('tab-orders-reopened');
});
it('rejects tabs that only match the current session but not the original JVM context', () => {
expect(
resolveJVMAIPlanTargetTabId(
[
{
id: 'tab-other-resource',
title: 'orders-other',
type: 'jvm-resource',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:2',
},
],
{
tabId: 'tab-orders',
connectionId: 'conn-orders',
providerMode: 'endpoint',
resourcePath: '/cache/orders/user:1',
},
),
).toBe('');
});
});

View File

@@ -1,4 +1,4 @@
import type { JVMValueSnapshot } from '../types';
import type { JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
export type JVMAIChangePlan = {
targetType: 'cacheEntry' | 'managedBean';
@@ -15,6 +15,8 @@ export type JVMAIChangePlan = {
reason: string;
};
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'payload'>;
type JVMAIPlanPromptContext = {
connectionName: string;
host?: string;
@@ -135,6 +137,79 @@ export const extractJVMChangePlan = (content: string): JVMAIChangePlan | null =>
return null;
};
export const resolveJVMAIPlanResourceId = (plan: JVMAIChangePlan): string => {
const resourcePath = asTrimmedString(plan.selector.resourcePath);
if (resourcePath) {
return resourcePath;
}
const namespace = asTrimmedString(plan.selector.namespace);
const key = asTrimmedString(plan.selector.key);
return [namespace, key].filter(Boolean).join('/');
};
export const matchesJVMAIPlanTargetTab = (
tab: Pick<TabData, 'type' | 'connectionId' | 'providerMode' | 'resourcePath'>,
context?: JVMAIPlanContext,
): boolean => {
if (!context || tab.type !== 'jvm-resource') {
return false;
}
const providerMode = (tab.providerMode || 'jmx') as JVMAIPlanContext['providerMode'];
return (
tab.connectionId === context.connectionId &&
providerMode === context.providerMode &&
asTrimmedString(tab.resourcePath) === asTrimmedString(context.resourcePath)
);
};
export const resolveJVMAIPlanTargetTabId = (tabs: TabData[], context?: JVMAIPlanContext): string => {
if (!context) {
return '';
}
const exactMatch = tabs.find((tab) => tab.id === context.tabId && matchesJVMAIPlanTargetTab(tab, context));
if (exactMatch) {
return exactMatch.id;
}
const fallbackMatch = tabs.find((tab) => matchesJVMAIPlanTargetTab(tab, context));
return fallbackMatch?.id || '';
};
export const buildJVMChangeDraftFromAIPlan = (plan: JVMAIChangePlan): JVMAIChangeDraft => {
const resourceId = resolveJVMAIPlanResourceId(plan);
if (!resourceId) {
throw new Error('AI 计划缺少可用的资源定位信息');
}
const reason = asTrimmedString(plan.reason);
if (!reason) {
throw new Error('AI 计划缺少变更原因');
}
if (plan.action === 'updateValue') {
const value = plan.payload?.value;
if (plan.payload?.format !== 'json' || !isRecord(value)) {
throw new Error('当前 JVM 预览仅支持 JSON 对象作为变更 payload');
}
return {
resourceId,
action: 'put',
reason,
payload: value as Record<string, any>,
};
}
return {
resourceId,
action: plan.action,
reason,
payload: {},
};
};
export const buildJVMAIPlanPrompt = ({
connectionName,
host,
@@ -168,7 +243,7 @@ export const buildJVMAIPlanPrompt = ({
'2. 代码块里的 JSON 字段必须严格是targetType、selector、action、payload、reason。',
`3. selector.resourcePath 优先使用当前资源路径 ${normalizedPath},不要凭空编造其他路径。`,
'4. action 只能使用 updateValue、evict、clear 之一。',
'5. payload 必须保持对象结构,例如 {"format":"json","value":{"status":"ACTIVE"}}。',
'5. 当前 MVP 只支持 JSON 对象变更:如果 action=updateValue payload 必须 {"format":"json","value":{...}},且 value 必须是 JSON 对象evict/clear 时 payload 可以省略。',
'6. 不要声称已经执行修改,也不要输出脚本或命令。',
'',
'JSON 示例:',

View File

@@ -3,6 +3,7 @@
import {connection} from '../models';
import {sync} from '../models';
import {app} from '../models';
import {jvm} from '../models';
import {redis} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
@@ -127,6 +128,16 @@ export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,ar
export function InstallUpdateAndRestart():Promise<connection.QueryResult>;
export function JVMApplyChange(arg1:connection.ConnectionConfig,arg2:jvm.ChangeRequest):Promise<connection.QueryResult>;
export function JVMGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function JVMListAuditRecords(arg1:string,arg2:number):Promise<connection.QueryResult>;
export function JVMListResources(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function JVMPreviewChange(arg1:connection.ConnectionConfig,arg2:jvm.ChangeRequest):Promise<connection.QueryResult>;
export function JVMProbeCapabilities(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function ListSQLDirectory(arg1:string):Promise<connection.QueryResult>;

View File

@@ -246,6 +246,26 @@ export function InstallUpdateAndRestart() {
return window['go']['app']['App']['InstallUpdateAndRestart']();
}
export function JVMApplyChange(arg1, arg2) {
return window['go']['app']['App']['JVMApplyChange'](arg1, arg2);
}
export function JVMGetValue(arg1, arg2) {
return window['go']['app']['App']['JVMGetValue'](arg1, arg2);
}
export function JVMListAuditRecords(arg1, arg2) {
return window['go']['app']['App']['JVMListAuditRecords'](arg1, arg2);
}
export function JVMListResources(arg1, arg2) {
return window['go']['app']['App']['JVMListResources'](arg1, arg2);
}
export function JVMPreviewChange(arg1, arg2) {
return window['go']['app']['App']['JVMPreviewChange'](arg1, arg2);
}
export function JVMProbeCapabilities(arg1) {
return window['go']['app']['App']['JVMProbeCapabilities'](arg1);
}

View File

@@ -887,6 +887,33 @@ export namespace connection {
}
export namespace jvm {
export class ChangeRequest {
providerMode: string;
resourceId: string;
action: string;
reason: string;
expectedVersion?: string;
payload?: Record<string, any>;
static createFrom(source: any = {}) {
return new ChangeRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.providerMode = source["providerMode"];
this.resourceId = source["resourceId"];
this.action = source["action"];
this.reason = source["reason"];
this.expectedVersion = source["expectedVersion"];
this.payload = source["payload"];
}
}
}
export namespace redis {
export class ZSetMember {
@@ -1008,4 +1035,3 @@ export namespace sync {
}
}