490 lines
16 KiB
JavaScript
490 lines
16 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Node-RED Function 节点脚本:准备大模型 HTTP 请求。
|
||
* 将生成 prompt、HTTP 请求参数,并把上下文写入 msg.llmContext。
|
||
*/
|
||
|
||
const DEFAULT_API_KEY = 'sk-lbGrsUPL1iby86h554FaE536C343435dAa9bA65967A840B2';
|
||
const DEFAULT_BASE_URL = 'https://aiproxy.petrotech.cnpc/v1';
|
||
const DEFAULT_ENDPOINT_PATH = '/chat/completions';
|
||
const DEFAULT_MODEL = 'deepseek-v3';
|
||
|
||
function prepareLlmRequest(msg, node) {
|
||
if (!msg.operationId) {
|
||
msg.operationId = 'createResource';
|
||
}
|
||
|
||
try {
|
||
const apiDoc = extractOpenApiDocument(msg);
|
||
const operationCtx = resolveOperationContext(msg, apiDoc);
|
||
|
||
if (!operationCtx.operation) {
|
||
const err = `未找到匹配的接口定义(operationId=${operationCtx.requestedOperationId || '未指定'}, path=${operationCtx.requestedPath || '未指定'}, method=${operationCtx.requestedMethod || '未指定'})`;
|
||
node.error(err, msg);
|
||
msg.error = err;
|
||
return null;
|
||
}
|
||
|
||
const prompt = buildPrompt(operationCtx, apiDoc, msg);
|
||
const requestPayload = buildRequestPayload(prompt, msg);
|
||
|
||
const apiKey = (msg.llm && msg.llm.apiKey) || DEFAULT_API_KEY;
|
||
const baseUrl = (msg.llm && msg.llm.baseUrl) || DEFAULT_BASE_URL;
|
||
const endpointPath = (msg.llm && msg.llm.endpoint) || DEFAULT_ENDPOINT_PATH;
|
||
|
||
const url = buildUrl(baseUrl, endpointPath);
|
||
msg.method = 'POST';
|
||
msg.url = url;
|
||
msg.headers = Object.assign({}, msg.headers, {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
});
|
||
msg.payload = requestPayload;
|
||
msg.rejectUnauthorized = false;
|
||
|
||
msg.llmContext = {
|
||
prompt,
|
||
operationCtx,
|
||
provider: 'dashscope',
|
||
requestConfig: {
|
||
baseUrl,
|
||
endpointPath,
|
||
model: requestPayload.model,
|
||
temperature: requestPayload.temperature,
|
||
},
|
||
};
|
||
delete msg.error;
|
||
return msg;
|
||
} catch (error) {
|
||
node.error(`LLM 请求准备失败:${error.message}`, msg);
|
||
msg.error = error.message;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function extractOpenApiDocument(message) {
|
||
const candidate =
|
||
message && message.oas_def ? message.oas_def :
|
||
message && message.swagger ? message.swagger :
|
||
message && message.payload ? message.payload :
|
||
null;
|
||
|
||
if (!candidate) {
|
||
throw new Error('未提供 OpenAPI 文档(需要 msg.oas_def / msg.swagger / msg.payload)');
|
||
}
|
||
|
||
if (typeof candidate === 'string') {
|
||
try {
|
||
return JSON.parse(candidate);
|
||
} catch (error) {
|
||
throw new Error(`OpenAPI JSON 解析失败:${error.message}`);
|
||
}
|
||
}
|
||
|
||
if (typeof candidate !== 'object') {
|
||
throw new Error('OpenAPI 文档必须是对象或 JSON 字符串');
|
||
}
|
||
|
||
if (!candidate.paths || typeof candidate.paths !== 'object') {
|
||
throw new Error('OpenAPI 文档缺少 paths 字段');
|
||
}
|
||
|
||
return candidate;
|
||
}
|
||
|
||
function resolveOperationContext(message, apiDoc) {
|
||
const requestedOperationId =
|
||
(message && message.operationId) ||
|
||
(message && message.req && message.req.body && message.req.body.operationId) ||
|
||
null;
|
||
|
||
const requestedPath =
|
||
(message && message.path) ||
|
||
(message && message.req && message.req.body && message.req.body.path) ||
|
||
null;
|
||
|
||
const requestedMethodRaw =
|
||
(message && message.method) ||
|
||
(message && message.req && message.req.body && message.req.body.method) ||
|
||
null;
|
||
const requestedMethod = requestedMethodRaw ? String(requestedMethodRaw).toLowerCase() : null;
|
||
|
||
const operations = enumerateOperations(apiDoc);
|
||
|
||
let matched = null;
|
||
if (requestedOperationId) {
|
||
matched = operations.find(op => op.operation.operationId === requestedOperationId);
|
||
}
|
||
|
||
if (!matched && requestedPath && requestedMethod) {
|
||
matched = operations.find(op => op.path === requestedPath && op.method === requestedMethod);
|
||
}
|
||
|
||
let autoSelected = false;
|
||
if (!matched && operations.length > 0) {
|
||
matched = operations[0];
|
||
autoSelected = true;
|
||
}
|
||
|
||
return {
|
||
operation: matched ? matched.operation : undefined,
|
||
method: matched ? matched.method : undefined,
|
||
pathItem: matched ? matched.pathItem : undefined,
|
||
path: matched ? matched.path : undefined,
|
||
autoSelected,
|
||
candidates: operations.map(op => ({
|
||
operationId: op.operation && op.operation.operationId ? op.operation.operationId : '',
|
||
method: op.method ? op.method.toUpperCase() : '',
|
||
path: op.path,
|
||
summary: op.operation && op.operation.summary ? op.operation.summary : '',
|
||
})),
|
||
requestedOperationId,
|
||
requestedPath,
|
||
requestedMethod,
|
||
};
|
||
}
|
||
|
||
function enumerateOperations(apiDoc) {
|
||
const results = [];
|
||
const validMethods = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'];
|
||
for (const path of Object.keys(apiDoc.paths || {})) {
|
||
const pathItem = apiDoc.paths[path];
|
||
if (!pathItem || typeof pathItem !== 'object') {
|
||
continue;
|
||
}
|
||
for (const method of validMethods) {
|
||
if (pathItem[method] && typeof pathItem[method] === 'object') {
|
||
results.push({
|
||
path,
|
||
method,
|
||
operation: pathItem[method],
|
||
pathItem,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
function buildPrompt(operationCtx, apiDoc, message) {
|
||
const operation = operationCtx.operation;
|
||
const method = (operationCtx.method || '').toUpperCase();
|
||
const path = operationCtx.path || '';
|
||
const summary = operation.summary || '';
|
||
const description = operation.description || '';
|
||
|
||
const parameters = gatherParameters(operationCtx, apiDoc);
|
||
const requestBodySchema = gatherRequestBodySchema(operation, apiDoc);
|
||
|
||
const userNotes = message && message.prompt ? String(message.prompt) : '';
|
||
const requiredFields = extractRequiredFields(requestBodySchema, apiDoc);
|
||
const requiredFieldsText = requiredFields.length > 0
|
||
? requiredFields.map(field => `- ${field}`).join('\n')
|
||
: '';
|
||
|
||
const lines = [];
|
||
lines.push('你是一名负责生成 HTTP 接口测试参数的助手,请严格输出以下结构的 JSON:');
|
||
lines.push('{');
|
||
lines.push(' "pathParams": { ... },');
|
||
lines.push(' "query": { ... },');
|
||
lines.push(' "headers": { ... },');
|
||
lines.push(' "cookies": { ... },');
|
||
lines.push(' "body": { ... },');
|
||
lines.push(' "notes": "..."');
|
||
lines.push('}');
|
||
lines.push('未用到的分区请返回空对象,不要在 JSON 外输出任何文字。');
|
||
lines.push('');
|
||
lines.push('body.data[0] 必须遵守:');
|
||
lines.push('- 覆盖 schema 中声明的全部必填字段,并给出符合字段类型/格式的真实感样例值。');
|
||
lines.push('- 非必填字段也尽可能全覆盖');
|
||
lines.push('- 不得遗漏必填字段;若 schema 内有嵌套对象/数组的必填字段,同样要补齐。');
|
||
lines.push('- 保持数值、日期、字符串等格式,只在 schema 无提示时使用 "sample"、"2025-01-01" 等占位值。');
|
||
if (requiredFieldsText) {
|
||
lines.push('必填字段清单(必须全部出现在 body.data[0] 中):');
|
||
lines.push(requiredFieldsText);
|
||
}
|
||
lines.push('');
|
||
lines.push('若 schema 中存在数组元素或复合结构的必填字段,也要为这些子字段提供值。');
|
||
lines.push('');
|
||
lines.push(`Target operation: ${method} ${path}`);
|
||
|
||
if (summary) {
|
||
lines.push(`Summary: ${summary}`);
|
||
}
|
||
if (description) {
|
||
lines.push(`Description: ${description}`);
|
||
}
|
||
if (parameters.length > 0) {
|
||
lines.push('Parameters:');
|
||
for (const param of parameters) {
|
||
lines.push(`- [${param.in}] ${param.name}: ${param.type || 'any'}${param.required ? ' (required)' : ''}${param.description ? ` - ${param.description}` : ''}`);
|
||
}
|
||
} else {
|
||
lines.push('Parameters: none defined.');
|
||
}
|
||
|
||
if (requestBodySchema) {
|
||
lines.push('Request body schema (JSON Schema excerpt):');
|
||
lines.push(indentSnippet(JSON.stringify(requestBodySchema, null, 2), 2));
|
||
} else {
|
||
lines.push('Request body: not defined.');
|
||
}
|
||
|
||
if (userNotes) {
|
||
lines.push('');
|
||
lines.push('User notes:');
|
||
lines.push(userNotes);
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function gatherParameters(operationCtx, apiDoc) {
|
||
const aggregated = [];
|
||
const seen = new Set();
|
||
|
||
const sources = [];
|
||
if (Array.isArray(operationCtx.pathItem && operationCtx.pathItem.parameters)) {
|
||
sources.push(operationCtx.pathItem.parameters);
|
||
}
|
||
if (Array.isArray(operationCtx.operation.parameters)) {
|
||
sources.push(operationCtx.operation.parameters);
|
||
}
|
||
|
||
for (const list of sources) {
|
||
for (const param of list) {
|
||
if (!param || typeof param !== 'object') {
|
||
continue;
|
||
}
|
||
const resolved = resolveMaybeRef(param, apiDoc);
|
||
const key = `${resolved.in}:${resolved.name}`;
|
||
if (!resolved.name || seen.has(key)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
aggregated.push({
|
||
name: resolved.name,
|
||
in: resolved.in || 'query',
|
||
required: !!resolved.required,
|
||
description: resolved.description || '',
|
||
type: resolved.schema ? inferFriendlyType(resolved.schema, apiDoc) : '',
|
||
});
|
||
}
|
||
}
|
||
|
||
return aggregated;
|
||
}
|
||
|
||
function gatherRequestBodySchema(operation, apiDoc) {
|
||
const requestBody = resolveMaybeRef(operation.requestBody, apiDoc);
|
||
if (!requestBody || typeof requestBody !== 'object' || !requestBody.content) {
|
||
return null;
|
||
}
|
||
|
||
const content = requestBody.content;
|
||
const mediaType = Object.keys(content).find(key => key.includes('json')) || Object.keys(content)[0];
|
||
if (!mediaType) {
|
||
return null;
|
||
}
|
||
|
||
const mediaObject = resolveMaybeRef(content[mediaType], apiDoc);
|
||
if (!mediaObject || typeof mediaObject !== 'object' || !mediaObject.schema) {
|
||
return null;
|
||
}
|
||
|
||
const schema = resolveMaybeRef(mediaObject.schema, apiDoc);
|
||
return resolveSchemaDeep(schema, apiDoc);
|
||
}
|
||
|
||
function inferFriendlyType(schema, apiDoc) {
|
||
if (!schema) {
|
||
return '';
|
||
}
|
||
const resolved = resolveMaybeRef(schema, apiDoc);
|
||
if (!resolved || typeof resolved !== 'object') {
|
||
return '';
|
||
}
|
||
|
||
if (resolved.type) {
|
||
if (resolved.type === 'array' && resolved.items) {
|
||
const itemType = inferFriendlyType(resolved.items, apiDoc) || 'any';
|
||
return `[${itemType}]`;
|
||
}
|
||
return resolved.type;
|
||
}
|
||
|
||
if (resolved.enum && Array.isArray(resolved.enum)) {
|
||
return `enum(${resolved.enum.slice(0, 3).join(', ')}${resolved.enum.length > 3 ? ', …' : ''})`;
|
||
}
|
||
|
||
if (resolved.properties) {
|
||
return 'object';
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
function buildRequestPayload(prompt, message) {
|
||
const systemPrompt = (message.llm && message.llm.systemPrompt) ||
|
||
'You write JSON only. Focus on realistic values for testing HTTP APIs.';
|
||
|
||
const model = (message.llm && message.llm.model) || DEFAULT_MODEL;
|
||
const temperature = (message.llm && typeof message.llm.temperature === 'number')
|
||
? message.llm.temperature : 0.2;
|
||
|
||
return {
|
||
model,
|
||
temperature,
|
||
response_format: { type: 'json_object' },
|
||
messages: [
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: prompt },
|
||
],
|
||
};
|
||
}
|
||
|
||
function buildUrl(baseUrl, endpointPath) {
|
||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||
const path = endpointPath.startsWith('/') ? endpointPath.slice(1) : endpointPath;
|
||
return `${normalizedBase}${path}`;
|
||
}
|
||
|
||
function resolveMaybeRef(node, apiDoc) {
|
||
if (!node || typeof node !== 'object') {
|
||
return node;
|
||
}
|
||
if (!node.$ref) {
|
||
return node;
|
||
}
|
||
|
||
const resolved = resolveRef(node.$ref, apiDoc);
|
||
if (!resolved || typeof resolved !== 'object') {
|
||
return node;
|
||
}
|
||
|
||
const remainder = Object.assign({}, node);
|
||
delete remainder.$ref;
|
||
return Object.assign({}, clone(resolved), remainder);
|
||
}
|
||
|
||
function resolveRef(ref, apiDoc) {
|
||
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
|
||
return null;
|
||
}
|
||
|
||
const tokens = ref.slice(2).split('/').map(unescapeRefToken);
|
||
let current = apiDoc;
|
||
for (const token of tokens) {
|
||
if (current && typeof current === 'object' && Object.prototype.hasOwnProperty.call(current, token)) {
|
||
current = current[token];
|
||
} else {
|
||
return null;
|
||
}
|
||
}
|
||
return current;
|
||
}
|
||
|
||
function unescapeRefToken(token) {
|
||
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
||
}
|
||
|
||
function indentSnippet(text, indentLevel, maxLength) {
|
||
const trimmed = maxLength && text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||
const indent = ' '.repeat(indentLevel * 2);
|
||
return trimmed.split('\n').map(line => `${indent}${line}`).join('\n');
|
||
}
|
||
|
||
function clone(value) {
|
||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function extractRequiredFields(schema, apiDoc) {
|
||
const result = new Set();
|
||
collectRequiredFields(schema, apiDoc, result, '');
|
||
return Array.from(result);
|
||
}
|
||
|
||
function collectRequiredFields(schema, apiDoc, result, pathPrefix) {
|
||
if (!schema || typeof schema !== 'object') {
|
||
return;
|
||
}
|
||
|
||
if (schema.$ref) {
|
||
const resolved = resolveRef(schema.$ref, apiDoc);
|
||
if (!resolved) {
|
||
return;
|
||
}
|
||
collectRequiredFields(resolveSchemaDeep(resolved, apiDoc), apiDoc, result, pathPrefix);
|
||
return;
|
||
}
|
||
|
||
if (Array.isArray(schema.required) && schema.properties && typeof schema.properties === 'object') {
|
||
for (const key of schema.required) {
|
||
const nextPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||
result.add(nextPath);
|
||
collectRequiredFields(schema.properties[key], apiDoc, result, nextPath);
|
||
}
|
||
}
|
||
|
||
if (schema.type === 'array' && schema.items) {
|
||
const nextPrefix = pathPrefix ? `${pathPrefix}[]` : '[]';
|
||
collectRequiredFields(schema.items, apiDoc, result, nextPrefix);
|
||
}
|
||
|
||
if (schema.allOf && Array.isArray(schema.allOf)) {
|
||
for (const part of schema.allOf) {
|
||
collectRequiredFields(part, apiDoc, result, pathPrefix);
|
||
}
|
||
}
|
||
}
|
||
|
||
function resolveSchemaDeep(schema, apiDoc, seen = new Set()) {
|
||
if (!schema || typeof schema !== 'object') {
|
||
return schema;
|
||
}
|
||
|
||
if (schema.$ref) {
|
||
const ref = schema.$ref;
|
||
if (seen.has(ref)) {
|
||
return {};
|
||
}
|
||
seen.add(ref);
|
||
const resolved = resolveRef(ref, apiDoc);
|
||
if (!resolved) {
|
||
return schema;
|
||
}
|
||
const merged = Object.assign({}, clone(resolved), clone(schema));
|
||
delete merged.$ref;
|
||
return resolveSchemaDeep(merged, apiDoc, seen);
|
||
}
|
||
|
||
const cloned = clone(schema);
|
||
|
||
if (cloned.properties && typeof cloned.properties === 'object') {
|
||
for (const key of Object.keys(cloned.properties)) {
|
||
cloned.properties[key] = resolveSchemaDeep(cloned.properties[key], apiDoc, new Set(seen));
|
||
}
|
||
}
|
||
|
||
if (cloned.items) {
|
||
cloned.items = resolveSchemaDeep(cloned.items, apiDoc, new Set(seen));
|
||
}
|
||
|
||
if (cloned.allOf && Array.isArray(cloned.allOf)) {
|
||
cloned.allOf = cloned.allOf.map(item => resolveSchemaDeep(item, apiDoc, new Set(seen)));
|
||
}
|
||
|
||
if (cloned.oneOf && Array.isArray(cloned.oneOf)) {
|
||
cloned.oneOf = cloned.oneOf.map(item => resolveSchemaDeep(item, apiDoc, new Set(seen)));
|
||
}
|
||
|
||
if (cloned.anyOf && Array.isArray(cloned.anyOf)) {
|
||
cloned.anyOf = cloned.anyOf.map(item => resolveSchemaDeep(item, apiDoc, new Set(seen)));
|
||
}
|
||
|
||
return cloned;
|
||
}
|
||
|
||
return prepareLlmRequest(msg, node);
|