mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-05 05:59:43 +08:00
326 lines
9.7 KiB
TypeScript
326 lines
9.7 KiB
TypeScript
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
|
||
import { JVM_SENSITIVE_VALUE_MASK } from './jvmResourcePresentation';
|
||
|
||
export type JVMAIChangePlan = {
|
||
targetType: 'cacheEntry' | 'managedBean' | 'attribute' | 'operation';
|
||
selector: {
|
||
namespace?: string;
|
||
key?: string;
|
||
resourcePath?: string;
|
||
};
|
||
action: string;
|
||
payload?: {
|
||
format: 'json' | 'text';
|
||
value: unknown;
|
||
};
|
||
reason: string;
|
||
};
|
||
|
||
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'source' | 'payload'>;
|
||
|
||
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', 'attribute', 'operation']);
|
||
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) || !action || !reason || !selector) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
targetType,
|
||
selector,
|
||
action,
|
||
payload,
|
||
reason,
|
||
};
|
||
};
|
||
|
||
const formatSnapshotValue = (snapshot?: JVMValueSnapshot | null): string => {
|
||
if (!snapshot) {
|
||
return '当前资源快照尚未加载成功。';
|
||
}
|
||
if (snapshot.sensitive) {
|
||
return JVM_SENSITIVE_VALUE_MASK;
|
||
}
|
||
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 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 计划缺少变更原因');
|
||
}
|
||
|
||
const action = asTrimmedString(plan.action);
|
||
if (!action) {
|
||
throw new Error('AI 计划缺少可执行 action');
|
||
}
|
||
|
||
if (plan.action === 'updateValue') {
|
||
const value = plan.payload?.value;
|
||
if (plan.payload?.format !== 'json' || !isRecord(value)) {
|
||
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
|
||
}
|
||
return {
|
||
resourceId,
|
||
action: 'put',
|
||
reason,
|
||
source: 'ai-plan',
|
||
payload: value as Record<string, any>,
|
||
};
|
||
}
|
||
|
||
const payloadValue = plan.payload?.value;
|
||
if (plan.payload && plan.payload.format === 'json') {
|
||
if (!isRecord(payloadValue)) {
|
||
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
|
||
}
|
||
return {
|
||
resourceId,
|
||
action,
|
||
reason,
|
||
source: 'ai-plan',
|
||
payload: payloadValue as Record<string, any>,
|
||
};
|
||
}
|
||
|
||
if (plan.payload && plan.payload.format === 'text') {
|
||
return {
|
||
resourceId,
|
||
action,
|
||
reason,
|
||
source: 'ai-plan',
|
||
payload: {
|
||
value: payloadValue == null ? '' : String(payloadValue),
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
resourceId,
|
||
action,
|
||
reason,
|
||
source: 'ai-plan',
|
||
payload: {},
|
||
};
|
||
};
|
||
|
||
const formatSupportedActions = (actions?: JVMActionDefinition[]): string => {
|
||
if (!actions || actions.length === 0) {
|
||
return '当前资源未声明支持动作。若要生成计划,请仅在你能从快照内容中明确推断时给出 action,并保持 payload 为 JSON 对象。';
|
||
}
|
||
return actions
|
||
.map((item) => {
|
||
const payloadFields = Array.isArray(item.payloadFields) && item.payloadFields.length > 0
|
||
? `;payload 字段:${item.payloadFields.map((field) => `${field.name}${field.required ? '(required)' : ''}`).join('、')}`
|
||
: '';
|
||
return `- ${item.action}${item.label ? ` (${item.label})` : ''}${item.description ? `:${item.description}` : ''}${payloadFields}`;
|
||
})
|
||
.join('\n');
|
||
};
|
||
|
||
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';
|
||
const supportedActionsText = formatSupportedActions(snapshot?.supportedActions);
|
||
|
||
return [
|
||
'请分析下面这个 JVM 资源,并生成一个可用于 GoNavi “预览变更” 的结构化修改计划。',
|
||
'',
|
||
`连接名称:${connectionName}`,
|
||
`目标主机:${asTrimmedString(host) || '-'}`,
|
||
`Provider 模式:${providerMode}`,
|
||
`运行环境:${environmentLabel}`,
|
||
`连接策略:${readOnly ? '只读连接,当前只能生成计划和风险分析,不能假设已执行' : '可写连接,但仍必须先预览再人工确认'}`,
|
||
`当前资源路径:${normalizedPath}`,
|
||
'',
|
||
'当前资源快照:',
|
||
`\`\`\`${snapshotFormat}`,
|
||
formatSnapshotValue(snapshot),
|
||
'```',
|
||
'',
|
||
'当前资源支持动作:',
|
||
supportedActionsText,
|
||
'',
|
||
'输出要求:',
|
||
'1. 可以先给一小段分析,但必须包含且只包含一个 ```json 代码块。',
|
||
'2. 代码块里的 JSON 字段必须严格是:targetType、selector、action、payload、reason。',
|
||
`3. selector.resourcePath 优先使用当前资源路径 ${normalizedPath},不要凭空编造其他路径。`,
|
||
'4. action 优先从“当前资源支持动作”里选择;如果当前资源未声明支持动作,才允许基于快照内容推断。',
|
||
'5. payload 只能使用 JSON 对象包装,不要输出脚本、命令或原始二进制。若需要纯文本值,也请包装成 {"format":"text","value":"..."}。',
|
||
'6. 不要声称已经执行修改,也不要输出脚本或命令。',
|
||
'',
|
||
'JSON 示例:',
|
||
'```json',
|
||
JSON.stringify(
|
||
{
|
||
targetType: 'cacheEntry',
|
||
selector: {
|
||
resourcePath: normalizedPath,
|
||
},
|
||
action: 'put',
|
||
payload: {
|
||
format: 'json',
|
||
value: {
|
||
status: 'ACTIVE',
|
||
},
|
||
},
|
||
reason: '修复缓存脏值',
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
'```',
|
||
].join('\n');
|
||
};
|