'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);