Files
crank/apps/ui/js/wizard-mapping.js
T
github-ops 9ba2aa3f38
CI / Rust Checks (push) Successful in 5m37s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 3m28s
CI / Deploy (push) Successful in 1m31s
Clarify request mapping UI
2026-06-25 04:59:12 +00:00

753 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function() {
var REQUEST_TARGETS = ['path', 'query', 'headers', 'body'];
var SCALAR_TYPES = ['string', 'number', 'integer', 'boolean', 'null'];
var suspendSync = false;
function field(id) {
return document.getElementById(id);
}
function readText(id) {
var element = field(id);
return element ? element.value.trim() : '';
}
function writeText(id, value) {
var element = field(id);
if (element) element.value = value || '';
}
function parseText(id) {
if (typeof parseStructuredText === 'function') {
return parseStructuredText(readText(id));
}
return JSON.parse(readText(id));
}
function dumpYaml(object) {
return window.jsyaml
? window.jsyaml.dump(object, { lineWidth: -1, noRefs: true })
: JSON.stringify(object, null, 2);
}
function safeJson(id) {
try {
return parseText(id);
} catch (_error) {
return null;
}
}
function scalarType(value) {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
var typeName = typeof value;
if (typeName === 'number') return Number.isInteger(value) ? 'integer' : 'number';
if (typeName === 'boolean') return 'boolean';
if (typeName === 'object') return 'object';
return 'string';
}
function inferJsonSchema(value) {
var typeName = scalarType(value);
if (typeName === 'object') {
var properties = {};
var required = [];
Object.keys(value || {}).forEach(function(name) {
properties[name] = inferJsonSchema(value[name]);
if (value[name] !== null && value[name] !== undefined) required.push(name);
});
var schema = {
type: 'object',
properties: properties,
additionalProperties: false,
};
if (required.length) schema.required = required;
return schema;
}
if (typeName === 'array') {
return {
type: 'array',
items: value.length ? inferJsonSchema(value[0]) : { type: 'string' },
};
}
return { type: typeName };
}
function collectSchemaFields(schema, prefix) {
var result = [];
var node = schema || {};
if (node.type === 'object' && node.properties) {
Object.keys(node.properties).forEach(function(name) {
var child = node.properties[name] || {};
var path = prefix ? prefix + '.' + name : name;
if (child.type === 'object' && child.properties) {
result = result.concat(collectSchemaFields(child, path));
} else {
result.push({
name: path,
required: Array.isArray(node.required) && node.required.indexOf(name) >= 0,
});
}
});
}
return result;
}
function requiredSchemaFields(schema) {
return collectSchemaFields(schema, '').filter(function(item) {
return item.required;
}).map(function(item) {
return item.name;
});
}
function collectJsonLeaves(value, prefix) {
var result = [];
if (Array.isArray(value)) {
if (!value.length) {
if (prefix) result.push({ name: prefix, required: true, array: true });
return result;
}
var samplePath = prefix ? prefix + '[0]' : '[0]';
return collectJsonLeaves(value[0], samplePath).map(function(item) {
item.array = true;
return item;
});
}
if (value && typeof value === 'object') {
Object.keys(value).forEach(function(name) {
var path = prefix ? prefix + '.' + name : name;
var child = value[name];
if (child && typeof child === 'object') {
result = result.concat(collectJsonLeaves(child, path));
} else {
result.push({ name: path, required: child !== null && child !== undefined });
}
});
return result;
}
if (prefix) result.push({ name: prefix, required: true });
return result;
}
function pathParams() {
var path = readText('endpoint-path') || '';
var params = [];
path.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, function(_match, name) {
params.push(name);
return _match;
});
path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, function(_match, name) {
params.push(name);
return _match;
});
return Array.from(new Set(params));
}
function currentMethod() {
var active = document.querySelector('.method-card.active');
return active ? active.dataset.method || 'GET' : 'GET';
}
function defaultTargetKind() {
var method = currentMethod().toUpperCase();
return method === 'GET' || method === 'DELETE' ? 'query' : 'body';
}
function splitRequestTarget(target) {
var value = String(target || '').replace(/^\$\.request\./, '').replace(/^request\./, '');
var parts = value.split('.');
var kind = parts.shift() || defaultTargetKind();
if (REQUEST_TARGETS.indexOf(kind) < 0) kind = defaultTargetKind();
return { kind: kind, name: parts.join('.') };
}
function sourceToField(source) {
return String(source || '')
.replace(/^\$\.mcp\.?/, '')
.replace(/^\$\.input\.?/, '');
}
function responseSourceToPath(source) {
return String(source || '')
.replace(/^\$\.response\.body\.?/, '')
.replace(/^\$\.response\.data\.?/, '');
}
function outputTargetToField(target) {
return String(target || '').replace(/^\$\.output\.?/, '');
}
function mappingEntrySource(entry) {
return entry && typeof entry === 'object' && !Array.isArray(entry)
? entry.source
: entry;
}
function mappingEntryDefault(entry) {
return entry && typeof entry === 'object' && !Array.isArray(entry) && Object.prototype.hasOwnProperty.call(entry, 'default_value')
? entry.default_value
: undefined;
}
function mappingEntryTransform(entry) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || entry.transform === undefined || entry.transform === null) {
return '';
}
var value = typeof entry.transform === 'object' ? entry.transform.kind : entry.transform;
value = String(value || '').trim();
return transformKinds().indexOf(value) >= 0 ? value : '';
}
function defaultValueToText(value) {
if (value === undefined) return '';
if (typeof value === 'string') return value;
return JSON.stringify(value);
}
function parseDefaultValue(text) {
var value = String(text || '').trim();
if (!value) return undefined;
if (/^(true|false|null)$/i.test(value) || /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(value) || /^[\[{]/.test(value)) {
try {
return JSON.parse(value);
} catch (_error) {
return value;
}
}
return value;
}
function transformKinds() {
return [
'to_string',
'to_number',
'to_boolean',
'join',
'split',
'wrap_array',
'unwrap_singleton',
];
}
function requestRowsFromEditor() {
var parsed = safeJson('tool-input-mapping') || {};
return Object.keys(parsed).map(function(target) {
var entry = parsed[target];
var source = mappingEntrySource(entry);
var split = splitRequestTarget(target);
return {
input: sourceToField(source) || split.name,
target: split.kind,
apiName: split.name || sourceToField(source),
defaultValue: defaultValueToText(mappingEntryDefault(entry)),
transform: mappingEntryTransform(entry),
};
});
}
function responseRowsFromEditor() {
var parsed = safeJson('tool-output-mapping') || {};
return Object.keys(parsed).map(function(outputField) {
var source = mappingEntrySource(parsed[outputField]);
return {
output: outputField,
responsePath: responseSourceToPath(source) || outputField,
};
});
}
function suggestedRequestRows() {
var rows = [];
pathParams().forEach(function(name) {
rows.push({ input: name, target: 'path', apiName: name });
});
var sample = safeJson('wizard-input-sample');
var fields = [];
if (sample && typeof sample === 'object') {
fields = collectJsonLeaves(sample, '');
}
if (!fields.length) {
fields = collectSchemaFields(safeJson('tool-input-schema'), '');
}
var pathSet = new Set(rows.map(function(row) { return row.input; }));
fields.forEach(function(item) {
if (pathSet.has(item.name)) return;
rows.push({
input: item.name,
target: defaultTargetKind(),
apiName: item.name,
});
});
return rows;
}
function suggestedResponseRows() {
var sample = safeJson('wizard-output-sample');
var fields = sample && typeof sample === 'object'
? collectJsonLeaves(sample, '')
: collectSchemaFields(safeJson('tool-output-schema'), '');
return fields.map(function(item) {
return {
output: outputNameFromPath(item.name),
responsePath: item.name,
};
});
}
function outputNameFromPath(path) {
return String(path || 'value')
.replace(/^\[0\]\.?/, 'item_')
.replace(/\[([0-9]+)\]/g, '_$1')
.replace(/[^A-Za-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
|| 'value';
}
function joinJsonPath(root, relativePath) {
var path = String(relativePath || '').trim();
if (!path) return root;
return path.charAt(0) === '[' ? root + path : root + '.' + path;
}
function makeOption(value, label, selected) {
var option = document.createElement('option');
option.value = value;
option.textContent = label;
option.selected = selected;
return option;
}
function makeInput(value, className, placeholder) {
var input = document.createElement('input');
input.className = className || 'form-input input-mono';
input.value = value === undefined || value === null ? '' : String(value);
input.placeholder = placeholder || '';
input.autocomplete = 'off';
input.spellcheck = false;
input.addEventListener('input', syncVisualMappingsToYaml);
input.addEventListener('change', syncVisualMappingsToYaml);
return input;
}
function makeRemoveButton(row) {
var button = document.createElement('button');
button.type = 'button';
button.className = 'row-btn row-btn-delete mapping-row-remove';
button.title = 'Удалить строку';
button.textContent = '×';
button.addEventListener('click', function() {
row.remove();
syncVisualMappingsToYaml();
});
return button;
}
function wrapMappingControl(labelText, control) {
var wrapper = document.createElement('label');
wrapper.className = 'mapping-field';
var label = document.createElement('span');
label.className = 'mapping-field-label';
label.textContent = labelText;
wrapper.appendChild(label);
wrapper.appendChild(control);
return wrapper;
}
function makeMappingArrow() {
var arrow = document.createElement('span');
arrow.className = 'mapping-arrow';
arrow.setAttribute('aria-hidden', 'true');
arrow.textContent = '→';
return arrow;
}
function renderRequestRows(rows) {
var root = field('wizard-request-mapping-rows');
if (!root) return;
root.replaceChildren();
if (!rows.length) rows = suggestedRequestRows();
rows.forEach(function(row) {
root.appendChild(createRequestRow(row));
});
if (!suspendSync) syncVisualMappingsToYaml();
}
function createRequestRow(row) {
var item = document.createElement('div');
item.className = 'mapping-row request-mapping-row';
item.dataset.mappingRow = 'request';
var input = makeInput(row.input, 'form-input input-mono mapping-source', 'base');
input.dataset.role = 'input';
input.title = 'Поле, которое MCP клиент передает инструменту';
item.appendChild(wrapMappingControl('Из инструмента', input));
item.appendChild(makeMappingArrow());
var select = document.createElement('select');
select.className = 'form-select mapping-target';
select.dataset.role = 'target';
[
['path', 'Path'],
['query', 'Query'],
['headers', 'Header'],
['body', 'Body'],
].forEach(function(entry) {
select.appendChild(makeOption(entry[0], entry[1], entry[0] === row.target));
});
select.addEventListener('change', syncVisualMappingsToYaml);
item.appendChild(wrapMappingControl('Куда в API', select));
var apiName = makeInput(row.apiName || row.input, 'form-input input-mono mapping-api-name', 'base');
apiName.dataset.role = 'apiName';
apiName.title = 'Имя path, query, header или body-поля в API-запросе';
item.appendChild(wrapMappingControl('Имя в API', apiName));
var defaultValue = makeInput(row.defaultValue, 'form-input input-mono mapping-default-value', 'если не передано');
defaultValue.dataset.role = 'defaultValue';
defaultValue.title = 'Необязательно. Это значение уйдет в API, если агент не передал поле инструмента.';
item.appendChild(wrapMappingControl('Если пусто', defaultValue));
var transform = document.createElement('select');
transform.className = 'form-select mapping-transform';
transform.dataset.role = 'transform';
transform.appendChild(makeOption('', 'Без преобразования', !row.transform));
transformKinds().forEach(function(kind) {
transform.appendChild(makeOption(kind, kind, kind === row.transform));
});
transform.title = 'Простое преобразование перед отправкой в API';
transform.addEventListener('change', syncVisualMappingsToYaml);
item.appendChild(wrapMappingControl('Преобразование', transform));
item.appendChild(makeRemoveButton(item));
return item;
}
function renderResponseRows(rows) {
var root = field('wizard-response-mapping-rows');
if (!root) return;
root.replaceChildren();
if (!rows.length) rows = suggestedResponseRows();
rows.forEach(function(row) {
root.appendChild(createResponseRow(row));
});
if (!suspendSync) syncVisualMappingsToYaml();
}
function createResponseRow(row) {
var item = document.createElement('div');
item.className = 'mapping-row response-mapping-row';
item.dataset.mappingRow = 'response';
var responsePath = makeInput(row.responsePath, 'form-input input-mono mapping-response-path', 'rates.EUR');
responsePath.dataset.role = 'responsePath';
responsePath.title = 'Поле из ответа API';
item.appendChild(wrapMappingControl('Из ответа API', responsePath));
item.appendChild(makeMappingArrow());
var output = makeInput(row.output, 'form-input input-mono mapping-output-field', 'rate');
output.dataset.role = 'output';
output.title = 'Поле результата, которое получит MCP клиент';
item.appendChild(wrapMappingControl('В результат инструмента', output));
item.appendChild(makeRemoveButton(item));
return item;
}
function valueIn(row, role) {
var input = row.querySelector('[data-role="' + role + '"]');
return input ? input.value.trim() : '';
}
function syncVisualMappingsToYaml() {
var requestRows = document.querySelectorAll('[data-mapping-row="request"]');
if (requestRows.length) {
var requestMapping = {};
requestRows.forEach(function(row) {
var input = valueIn(row, 'input');
var target = valueIn(row, 'target');
var apiName = valueIn(row, 'apiName') || input;
var defaultText = valueIn(row, 'defaultValue');
var transform = valueIn(row, 'transform');
if (!input || !target || !apiName) return;
if (defaultText || transform) {
var entry = { source: '$.input.' + input };
if (defaultText) entry.default_value = parseDefaultValue(defaultText);
if (transform) entry.transform = transform;
requestMapping[target + '.' + apiName] = entry;
return;
}
requestMapping[target + '.' + apiName] = '$.input.' + input;
});
writeText('tool-input-mapping', dumpYaml(requestMapping));
}
var responseRows = document.querySelectorAll('[data-mapping-row="response"]');
if (responseRows.length) {
var responseMapping = {};
responseRows.forEach(function(row) {
var responsePath = valueIn(row, 'responsePath');
var output = valueIn(row, 'output') || outputNameFromPath(responsePath);
if (!responsePath || !output) return;
responseMapping[output] = joinJsonPath('$.response.body', responsePath);
});
writeText('tool-output-mapping', dumpYaml(responseMapping));
}
renderMappingWarnings();
}
function currentRequestMappedInputs() {
return Array.from(document.querySelectorAll('[data-mapping-row="request"]')).map(function(row) {
return valueIn(row, 'input');
}).filter(Boolean);
}
function currentResponseMappedOutputs() {
return Array.from(document.querySelectorAll('[data-mapping-row="response"]')).map(function(row) {
return valueIn(row, 'output');
}).filter(Boolean);
}
function jsonParseWarning(id, label) {
var raw = readText(id);
if (!raw) return null;
try {
parseText(id);
return null;
} catch (error) {
return label + ': JSON не разобран. ' + error.message;
}
}
function buildMappingWarnings() {
var warnings = [];
var inputJsonWarning = jsonParseWarning('wizard-input-sample', 'Пример входных данных');
var outputJsonWarning = jsonParseWarning('wizard-output-sample', 'Пример ответа');
if (inputJsonWarning) warnings.push(inputJsonWarning);
if (outputJsonWarning) warnings.push(outputJsonWarning);
var required = requiredSchemaFields(safeJson('tool-input-schema'));
var mapped = new Set(currentRequestMappedInputs());
var missing = required.filter(function(name) { return !mapped.has(name); });
if (missing.length) {
warnings.push('Обязательные поля входной схемы не отправляются в API: ' + missing.join(', ') + '.');
}
var outputRows = currentResponseMappedOutputs();
if (!outputRows.length) {
warnings.push('Ответ инструмента пустой. Выберите хотя бы одно поле из ответа API.');
}
if (outputRows.length > 12) {
warnings.push('В ответ инструмента выбрано много полей: ' + outputRows.length + '. Лучше вернуть только данные, нужные агенту.');
}
var hasArrayIndex = Array.from(document.querySelectorAll('[data-mapping-row="response"]')).some(function(row) {
return /\[[0-9]+\]/.test(valueIn(row, 'responsePath'));
});
if (hasArrayIndex) {
warnings.push('В ответе выбрано поле из одного элемента массива. Если агенту нужен весь список, верните массив целиком через расширенный YAML-маппинг.');
}
return warnings;
}
function renderMappingWarnings() {
var root = field('wizard-mapping-warnings');
if (!root) return;
var warnings = buildMappingWarnings();
root.replaceChildren();
root.hidden = warnings.length === 0;
warnings.forEach(function(message) {
var item = document.createElement('div');
item.className = 'mapping-warning';
var title = document.createElement('strong');
title.textContent = 'Проверьте маппинг. ';
item.appendChild(title);
item.appendChild(document.createTextNode(message));
root.appendChild(item);
});
}
function renderResponseTree() {
var root = field('wizard-response-json-tree');
if (!root) return;
root.replaceChildren();
var sample = safeJson('wizard-output-sample');
var leaves = sample && typeof sample === 'object' ? collectJsonLeaves(sample, '') : [];
if (!leaves.length) {
var empty = document.createElement('div');
empty.className = 'form-hint';
empty.textContent = 'Добавьте пример ответа JSON, чтобы выбрать поля кликом.';
root.appendChild(empty);
return;
}
leaves.forEach(function(leaf) {
var button = document.createElement('button');
button.type = 'button';
button.className = 'json-tree-node';
button.textContent = leaf.name;
button.addEventListener('click', function() {
var output = outputNameFromPath(leaf.name);
field('wizard-response-mapping-rows').appendChild(createResponseRow({
responsePath: leaf.name,
output: output,
}));
syncVisualMappingsToYaml();
});
root.appendChild(button);
});
}
function formatSamplesAndInferSchemas() {
var input = parseText('wizard-input-sample');
var output = parseText('wizard-output-sample');
writeText('wizard-input-sample', JSON.stringify(input, null, 2));
writeText('wizard-test-input', JSON.stringify(input, null, 2));
writeText('wizard-output-sample', JSON.stringify(output, null, 2));
writeText('tool-input-schema', JSON.stringify(inferJsonSchema(input), null, 2));
writeText('tool-output-schema', JSON.stringify(inferJsonSchema(output), null, 2));
renderRequestRows(suggestedRequestRows());
renderResponseRows(suggestedResponseRows());
renderResponseTree();
if (window.CrankWizardLive && typeof window.CrankWizardLive.renderAgentFacingPreview === 'function') {
window.CrankWizardLive.renderAgentFacingPreview();
}
}
function loadJsonFile(inputId, targetTextareaId, afterLoad) {
var input = field(inputId);
if (!input) return;
var file = input.files && input.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(event) {
try {
var parsed = JSON.parse(event.target.result || '{}');
writeText(targetTextareaId, JSON.stringify(parsed, null, 2));
if (afterLoad) afterLoad();
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message, 'JSON не прочитан');
}
} finally {
input.value = '';
}
};
reader.readAsText(file);
}
function bindFileButton(buttonId, inputId) {
var button = field(buttonId);
var input = field(inputId);
if (!button || !input || button.dataset.mappingBound === 'true') return;
button.dataset.mappingBound = 'true';
button.addEventListener('click', function(event) {
event.preventDefault();
input.click();
});
}
function bindOnce(id, eventName, handler) {
var element = field(id);
if (!element || element.dataset.mappingBound === 'true') return;
element.dataset.mappingBound = 'true';
element.addEventListener(eventName, handler);
}
function initializeMappingBuilder() {
bindOnce('wizard-add-request-mapping-row', 'click', function(event) {
event.preventDefault();
field('wizard-request-mapping-rows').appendChild(createRequestRow({
input: '',
target: defaultTargetKind(),
apiName: '',
}));
});
bindOnce('wizard-add-response-mapping-row', 'click', function(event) {
event.preventDefault();
field('wizard-response-mapping-rows').appendChild(createResponseRow({
responsePath: '',
output: '',
}));
});
bindOnce('wizard-auto-request-mapping', 'click', function(event) {
event.preventDefault();
renderRequestRows(suggestedRequestRows());
});
bindOnce('wizard-auto-response-mapping', 'click', function(event) {
event.preventDefault();
renderResponseRows(suggestedResponseRows());
renderResponseTree();
});
bindOnce('wizard-sync-request-mapping', 'click', function(event) {
event.preventDefault();
syncVisualMappingsToYaml();
});
bindOnce('wizard-sync-response-mapping', 'click', function(event) {
event.preventDefault();
syncVisualMappingsToYaml();
});
bindOnce('wizard-format-samples', 'click', function(event) {
event.preventDefault();
formatSamplesAndInferSchemas();
});
bindFileButton('wizard-load-input-sample-file', 'wizard-input-sample-file');
bindFileButton('wizard-load-output-sample-file', 'wizard-output-sample-file');
bindOnce('wizard-input-sample-file', 'change', function() {
loadJsonFile('wizard-input-sample-file', 'wizard-input-sample', function() {
renderRequestRows(suggestedRequestRows());
});
});
bindOnce('wizard-output-sample-file', 'change', function() {
loadJsonFile('wizard-output-sample-file', 'wizard-output-sample', function() {
renderResponseRows(suggestedResponseRows());
renderResponseTree();
});
});
['wizard-input-sample', 'tool-input-schema', 'endpoint-path'].forEach(function(id) {
var element = field(id);
if (!element || element.dataset.mappingRefreshBound === 'true') return;
element.dataset.mappingRefreshBound = 'true';
element.addEventListener('change', function() {
renderRequestRows(requestRowsFromEditor());
renderMappingWarnings();
});
});
['wizard-output-sample', 'tool-output-schema'].forEach(function(id) {
var element = field(id);
if (!element || element.dataset.mappingRefreshBound === 'true') return;
element.dataset.mappingRefreshBound = 'true';
element.addEventListener('change', function() {
renderResponseRows(responseRowsFromEditor());
renderResponseTree();
renderMappingWarnings();
});
});
renderRequestRows(requestRowsFromEditor());
renderResponseRows(responseRowsFromEditor());
renderResponseTree();
renderMappingWarnings();
}
window.CrankWizardMapping = {
initialize: initializeMappingBuilder,
sync: syncVisualMappingsToYaml,
renderFromEditors: function() {
suspendSync = true;
renderRequestRows(requestRowsFromEditor());
renderResponseRows(responseRowsFromEditor());
suspendSync = false;
syncVisualMappingsToYaml();
renderResponseTree();
renderMappingWarnings();
},
formatSamplesAndInferSchemas: formatSamplesAndInferSchemas,
renderWarnings: renderMappingWarnings,
};
})();