🐛 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

@@ -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 示例:',