+
+
+
diff --git a/apps/ui/js/wizard-live.js b/apps/ui/js/wizard-live.js
index bc56f4b..f9ebfd2 100644
--- a/apps/ui/js/wizard-live.js
+++ b/apps/ui/js/wizard-live.js
@@ -13,10 +13,17 @@ function buildToolDescription() {
}
function buildWizardState() {
+ var snapshot = wizardCurrentVersion && wizardCurrentVersion.snapshot
+ ? wizardCurrentVersion.snapshot
+ : wizardCurrentVersion;
+ var existingState = snapshot && snapshot.wizard_state ? snapshot.wizard_state : {};
return {
input_sample: parseStructuredText(textValue('wizard-input-sample')),
output_sample: parseStructuredText(textValue('wizard-output-sample')),
test_input: parseStructuredText(textValue('wizard-test-input')),
+ import_findings: Array.isArray(existingState.import_findings)
+ ? existingState.import_findings.slice()
+ : [],
};
}
@@ -24,6 +31,10 @@ function collectWizardPayload() {
var name = textValue('tool-name');
if (!name) throw new Error(tKey('wizard.error.tool_name'));
+ if (window.CrankWizardMapping && typeof window.CrankWizardMapping.sync === 'function') {
+ window.CrankWizardMapping.sync();
+ }
+
var inputSchemaValue = parseStructuredText(textValue('tool-input-schema'));
var outputSchemaValue = parseStructuredText(textValue('tool-output-schema'));
var inputMappingValue = parseStructuredText(textValue('tool-input-mapping'));
@@ -69,6 +80,7 @@ async function loadOperationForEdit() {
wizardCurrentVersion = draftVersion;
prefillWizardFromEdit(detail, draftVersion);
renderAgentFacingPreview();
+ renderImportQualityFindings(draftVersion);
}
function setEditModePresentation() {
@@ -111,6 +123,9 @@ function bindWizardLiveActions() {
var yamlInput = document.getElementById('wizard-import-yaml-file');
if (yamlInput) yamlInput.addEventListener('change', handleImportYamlFileSelection);
+ if (window.CrankWizardMapping && typeof window.CrankWizardMapping.initialize === 'function') {
+ window.CrankWizardMapping.initialize();
+ }
bindAgentFacingPreview();
}
@@ -421,6 +436,50 @@ function severityLabel(severity) {
return tKey('wizard.quality.severity_info');
}
+function qualityTargetForFinding(finding) {
+ var code = String(finding && finding.code || '');
+ var path = String(finding && finding.field_path || '');
+
+ if (code.indexOf('tool_name') >= 0 || code.indexOf('weak_tool_name') >= 0) {
+ return { step: 4, selector: '#tool-name', label: 'Перейти к имени инструмента' };
+ }
+ if (code.indexOf('description') >= 0 || path.indexOf('tool_description') >= 0) {
+ return { step: 4, selector: '#tool-description', label: 'Перейти к описанию' };
+ }
+ if (code.indexOf('input') >= 0 || code.indexOf('parameter') >= 0 || path.indexOf('input_schema') >= 0) {
+ return { step: 4, selector: '#tool-input-schema', label: 'Перейти к входной схеме' };
+ }
+ if (code.indexOf('output') >= 0 || code.indexOf('response_schema') >= 0 || path.indexOf('output_schema') >= 0) {
+ return { step: 4, selector: '#tool-output-schema', label: 'Перейти к схеме ответа' };
+ }
+ if (code.indexOf('response_projection') >= 0 || code.indexOf('mapping') >= 0 || path.indexOf('output_mapping') >= 0) {
+ return { step: 5, selector: '#tool-output-mapping', label: 'Перейти к маппингу ответа' };
+ }
+ return null;
+}
+
+function focusWizardQualityTarget(target) {
+ if (!target || !window.CrankWizardShell) return;
+ var load = typeof window.CrankWizardShell.loadWizardPanels === 'function'
+ ? window.CrankWizardShell.loadWizardPanels([target.step])
+ : Promise.resolve();
+ load.then(function() {
+ if (typeof window.CrankWizardShell.doGoToStep === 'function') {
+ window.CrankWizardShell.doGoToStep(target.step);
+ }
+ window.setTimeout(function() {
+ var element = document.querySelector(target.selector);
+ if (!element) return;
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ if (typeof element.focus === 'function') element.focus();
+ element.classList.add('quality-focus-target');
+ window.setTimeout(function() {
+ element.classList.remove('quality-focus-target');
+ }, 1600);
+ }, 80);
+ });
+}
+
function renderQualityFindings(report) {
var empty = document.getElementById('wizard-quality-empty');
var list = document.getElementById('wizard-quality-findings');
@@ -454,6 +513,19 @@ function renderQualityFindings(report) {
item.appendChild(action);
}
+ var target = qualityTargetForFinding(finding);
+ if (target) {
+ var button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'btn-ghost-sm quality-finding-jump';
+ button.textContent = target.label;
+ button.addEventListener('click', function(event) {
+ event.preventDefault();
+ focusWizardQualityTarget(target);
+ });
+ item.appendChild(button);
+ }
+
if (finding.field_path) {
var path = document.createElement('div');
path.className = 'quality-finding-path';
@@ -465,6 +537,26 @@ function renderQualityFindings(report) {
});
}
+function renderImportQualityFindings(versionDocument) {
+ var snapshot = versionDocument && versionDocument.snapshot
+ ? versionDocument.snapshot
+ : versionDocument;
+ var state = snapshot && snapshot.wizard_state ? snapshot.wizard_state : {};
+ var findings = Array.isArray(state.import_findings) ? state.import_findings : [];
+ if (!findings.length) return;
+
+ renderQualityFindings({
+ blocking: findings.some(function(finding) { return finding.severity === 'error'; }),
+ findings: findings,
+ });
+
+ var empty = document.getElementById('wizard-quality-empty');
+ if (empty) {
+ empty.textContent = 'Рекомендации из OpenAPI import. Запустите проверку качества, чтобы пересчитать их по текущему черновику.';
+ empty.hidden = false;
+ }
+}
+
async function analyzeWizardQuality() {
if (!wizardWorkspaceId) {
throw new Error(tKey('wizard.error.no_workspace'));
@@ -574,6 +666,9 @@ async function generateDraftFromWizard() {
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(generated.output_schema), null, 2));
setValue('tool-input-mapping', mappingSetToEditorValue(generated.input_mapping, 'input', wizardProtocol));
setValue('tool-output-mapping', mappingSetToEditorValue(generated.output_mapping, 'output', wizardProtocol));
+ if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
+ window.CrankWizardMapping.renderFromEditors();
+ }
renderAgentFacingPreview();
showWizardLiveStatus(tKey('wizard.sample.generated'), tKey('wizard.sample.generated_body'));
}
diff --git a/apps/ui/js/wizard-mapping.js b/apps/ui/js/wizard-mapping.js
new file mode 100644
index 0000000..84cb26c
--- /dev/null
+++ b/apps/ui/js/wizard-mapping.js
@@ -0,0 +1,725 @@
+(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 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';
+ item.appendChild(input);
+
+ 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(select);
+
+ var apiName = makeInput(row.apiName || row.input, 'form-input input-mono mapping-api-name', 'base');
+ apiName.dataset.role = 'apiName';
+ item.appendChild(apiName);
+
+ var defaultValue = makeInput(row.defaultValue, 'form-input input-mono mapping-default-value', 'по умолчанию');
+ defaultValue.dataset.role = 'defaultValue';
+ defaultValue.title = 'Значение по умолчанию, если поле не передано';
+ item.appendChild(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(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';
+ item.appendChild(responsePath);
+
+ var output = makeInput(row.output, 'form-input input-mono mapping-output-field', 'rate');
+ output.dataset.role = 'output';
+ item.appendChild(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,
+ };
+})();
diff --git a/apps/ui/js/wizard-model.js b/apps/ui/js/wizard-model.js
index da8617a..5e3b4ee 100644
--- a/apps/ui/js/wizard-model.js
+++ b/apps/ui/js/wizard-model.js
@@ -122,26 +122,76 @@ function preserveMappingRuleMetadata(rule, existingRule) {
return rule;
}
+function mappingEntrySource(entry) {
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
+ return entry.source;
+ }
+ return entry;
+}
+
+function normalizeTransformKind(value) {
+ if (!value) return null;
+ var kind = value && typeof value === 'object' ? value.kind : value;
+ kind = String(kind || '').trim();
+ var allowed = [
+ 'identity',
+ 'to_string',
+ 'to_number',
+ 'to_boolean',
+ 'join',
+ 'split',
+ 'wrap_array',
+ 'unwrap_singleton',
+ ];
+ return allowed.indexOf(kind) >= 0 ? kind : null;
+}
+
+function applyMappingEntryMetadata(rule, entry, existingRule) {
+ preserveMappingRuleMetadata(rule, existingRule);
+
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ return rule;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(entry, 'required')) {
+ rule.required = entry.required !== false;
+ }
+ if (Object.prototype.hasOwnProperty.call(entry, 'default_value')) {
+ rule.default_value = entry.default_value;
+ }
+ if (entry.transform !== undefined && entry.transform !== null) {
+ var transformKind = normalizeTransformKind(entry.transform);
+ if (transformKind) rule.transform = { kind: transformKind };
+ }
+ ['condition', 'notes'].forEach(function(field) {
+ if (entry[field] !== undefined && entry[field] !== null) {
+ rule[field] = entry[field];
+ }
+ });
+ return rule;
+}
+
function buildMappingSet(rawValue, mode) {
var value = rawValue || {};
var rules = [];
Object.keys(value).forEach(function(target) {
- var source = value[target];
+ var entry = value[target];
+ var source = mappingEntrySource(entry);
if (mode === 'input') {
var inputTarget = normalizeInputTarget(target);
- rules.push(preserveMappingRuleMetadata({
+ rules.push(applyMappingEntryMetadata({
source: normalizeInputSource(source),
target: inputTarget,
- }, existingMappingRuleByTarget(mode, inputTarget)));
+ }, entry, existingMappingRuleByTarget(mode, inputTarget)));
return;
}
var outputTarget = normalizeOutputTarget(target);
- rules.push(preserveMappingRuleMetadata({
+ rules.push(applyMappingEntryMetadata({
source: normalizeOutputSource(source),
target: outputTarget,
- }, existingMappingRuleByTarget(mode, outputTarget)));
+ }, entry, existingMappingRuleByTarget(mode, outputTarget)));
});
return { rules: rules };
@@ -313,11 +363,52 @@ function mappingSetToEditorValue(mappingSet, mode, protocol) {
var key = rule.target.indexOf(mappingRootForProtocol(protocol) + '.') === 0
? rule.target.replace(mappingRootForProtocol(protocol) + '.', '')
: rule.target.replace('$.request.', '');
- object[key] = rule.source.replace('$.mcp.', '$.input.');
+ var inputSource = rule.source.replace('$.mcp.', '$.input.');
+ var inputEntry = { source: inputSource };
+ var hasMetadata = false;
+ if (rule.required === false) {
+ inputEntry.required = false;
+ hasMetadata = true;
+ }
+ if (rule.default_value !== undefined && rule.default_value !== null) {
+ inputEntry.default_value = rule.default_value;
+ hasMetadata = true;
+ }
+ if (rule.transform && rule.transform.kind) {
+ inputEntry.transform = rule.transform.kind;
+ hasMetadata = true;
+ }
+ ['condition', 'notes'].forEach(function(field) {
+ if (rule[field] !== undefined && rule[field] !== null) {
+ inputEntry[field] = rule[field];
+ hasMetadata = true;
+ }
+ });
+ object[key] = hasMetadata ? inputEntry : inputSource;
return;
}
var outputKey = rule.target.replace('$.output.', '');
- object[outputKey] = rule.source;
+ var outputEntry = { source: rule.source };
+ var outputHasMetadata = false;
+ if (rule.required === false) {
+ outputEntry.required = false;
+ outputHasMetadata = true;
+ }
+ if (rule.default_value !== undefined && rule.default_value !== null) {
+ outputEntry.default_value = rule.default_value;
+ outputHasMetadata = true;
+ }
+ if (rule.transform && rule.transform.kind) {
+ outputEntry.transform = rule.transform.kind;
+ outputHasMetadata = true;
+ }
+ ['condition', 'notes'].forEach(function(field) {
+ if (rule[field] !== undefined && rule[field] !== null) {
+ outputEntry[field] = rule[field];
+ outputHasMetadata = true;
+ }
+ });
+ object[outputKey] = outputHasMetadata ? outputEntry : rule.source;
});
return window.jsyaml ? window.jsyaml.dump(object, { lineWidth: -1 }) : JSON.stringify(object, null, 2);
}
@@ -397,6 +488,9 @@ function prefillWizardFromEdit(detail, versionDocument) {
setValue('tool-output-mapping', mappingSetToEditorValue(snapshot.output_mapping, 'output', snapshot.protocol || detail.protocol));
setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {}));
prefillWizardSamples(snapshot);
+ if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
+ window.CrankWizardMapping.renderFromEditors();
+ }
var toolNameInput = document.getElementById('tool-name');
if (toolNameInput) toolNameInput.disabled = true;
diff --git a/apps/ui/scripts/build.js b/apps/ui/scripts/build.js
index 0850329..5b4f047 100644
--- a/apps/ui/scripts/build.js
+++ b/apps/ui/scripts/build.js
@@ -41,6 +41,7 @@ const BUNDLES = {
operations: {
files: [
'node_modules/alpinejs/dist/cdn.min.js',
+ 'js/openapi-import.js',
'js/catalog.js',
],
},
@@ -93,6 +94,7 @@ const BUNDLES = {
'js/wizard-shell.js',
'js/wizard-upstreams.js',
'js/wizard-model.js',
+ 'js/wizard-mapping.js',
'js/wizard-live.js',
'js/wizard.js',
'js/nav.js',
diff --git a/apps/ui/tests/e2e/wizard.spec.js b/apps/ui/tests/e2e/wizard.spec.js
index 1160eb2..055d934 100644
--- a/apps/ui/tests/e2e/wizard.spec.js
+++ b/apps/ui/tests/e2e/wizard.spec.js
@@ -122,6 +122,7 @@ test('wizard loads reusable upstreams from backend', async ({ page }) => {
);
await page.goto('/wizard/');
+ await expect(page.locator('#step-panel-1')).toBeVisible();
await page.locator('[data-testid="wizard-protocol-rest"]').click();
await page.locator('.btn-continue').click();
await expect(page.locator('#step-panel-2')).toBeVisible();
@@ -132,6 +133,69 @@ test('wizard loads reusable upstreams from backend', async ({ page }) => {
await expect(page.locator('#upstream-dropdown-list')).not.toContainText('Open Meteo');
});
+test('wizard builds visual request mappings from JSON sample and path params', async ({ page }) => {
+ await login(page);
+
+ await page.goto('/wizard/');
+ await page.locator('[data-testid="wizard-protocol-rest"]').click();
+ await page.evaluate(() => window.CrankWizardShell.doGoToStep(2));
+ await expect(page.locator('#step-panel-2')).toBeVisible();
+ await page.locator('#endpoint-path').fill('/rates/{date}');
+
+ await page.evaluate(() => window.CrankWizardShell.doGoToStep(3));
+ await expect(page.locator('#step-panel-3-rest')).toBeVisible();
+ await page.locator('.method-card[data-method="GET"]').click();
+
+ await page.evaluate(() => window.CrankWizardShell.doGoToStep(5));
+ await expect(page.locator('#step-panel-5')).toBeVisible();
+
+ await page.locator('#wizard-input-sample').fill(JSON.stringify({
+ date: '2024-01-01',
+ base: 'USD',
+ symbols: 'EUR',
+ }, null, 2));
+ await page.locator('#wizard-output-sample').fill(JSON.stringify({
+ base: 'USD',
+ rates: {
+ EUR: 0.91,
+ },
+ providers: [
+ {
+ name: 'ecb',
+ priority: 1,
+ },
+ ],
+ }, null, 2));
+
+ await page.locator('#wizard-format-samples').click();
+
+ await expect(page.locator('[data-testid="wizard-request-mapping-rows"] [data-role="input"]').first()).toHaveValue('date');
+ await expect(page.locator('[data-testid="wizard-request-mapping-rows"] [data-role="input"]').nth(1)).toHaveValue('base');
+ await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/);
+ await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/);
+ await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.symbols/);
+ await expect(page.locator('#tool-input-schema')).toHaveValue(/"date"/);
+ await expect(page.locator('#tool-output-schema')).toHaveValue(/"rates"/);
+ await expect(page.locator('[data-testid="wizard-response-json-tree"]')).toContainText('rates.EUR');
+ await expect(page.locator('[data-testid="wizard-response-json-tree"]')).toContainText('providers[0].name');
+ await expect(page.locator('#tool-output-mapping')).toHaveValue(/providers_0_name: \$\.response\.body\.providers\[0\]\.name/);
+ await expect(page.locator('[data-testid="wizard-mapping-warnings"]')).toContainText(/одного элемента массива/);
+
+ await page.locator('#wizard-add-request-mapping-row').click();
+ const defaultRow = page.locator('[data-testid="wizard-request-mapping-rows"] [data-mapping-row="request"]').last();
+ await defaultRow.locator('[data-role="input"]').fill('group');
+ await defaultRow.locator('[data-role="target"]').selectOption('query');
+ await defaultRow.locator('[data-role="apiName"]').fill('group');
+ await defaultRow.locator('[data-role="defaultValue"]').fill('month');
+ await defaultRow.locator('[data-role="transform"]').selectOption('to_string');
+ await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.group:/);
+ await expect(page.locator('#tool-input-mapping')).toHaveValue(/default_value: month/);
+ await expect(page.locator('#tool-input-mapping')).toHaveValue(/transform: to_string/);
+
+ await page.locator('[data-testid="wizard-request-mapping-rows"] .mapping-row-remove').first().click();
+ await expect(page.locator('[data-testid="wizard-mapping-warnings"]')).toContainText(/date/);
+});
+
test('wizard edit mode hydrates fields from operation version snapshot', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
@@ -301,6 +365,15 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
from: 'GBP',
to: 'USD',
},
+ import_findings: [
+ {
+ severity: 'warning',
+ code: 'openapi_import.weak_tool_description',
+ message: 'Описание инструмента слишком короткое или техническое.',
+ suggested_action: 'Уточните, когда агент должен вызывать инструмент.',
+ field_path: 'GET /rates',
+ },
+ ],
},
samples: [],
generated_draft: null,
@@ -338,6 +411,13 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
await expect(page.locator('#wizard-test-input')).toHaveValue(/"from": "GBP"/);
await expect(page.locator('#wizard-test-input')).toHaveValue(/"to": "USD"/);
await expect(page.locator('#wizard-test-input')).not.toHaveValue(/Ada/);
+
+ await page.evaluate(() => window.CrankWizardShell.doGoToStep(5));
+ await expect(page.locator('#wizard-quality-findings')).toContainText('Описание инструмента слишком короткое');
+ await expect(page.locator('#wizard-quality-findings')).toContainText('GET /rates');
+ await page.getByRole('button', { name: 'Перейти к описанию' }).click();
+ await expect(page.locator('#step-panel-4')).toBeVisible();
+ await expect(page.locator('#tool-description')).toBeFocused();
});
test('wizard shows agent-facing MCP preview from current draft fields', async ({ page }) => {
@@ -669,6 +749,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
target: '$.request.query.group',
required: false,
default_value: 'month',
+ transform: { kind: 'to_string' },
},
],
},
@@ -706,6 +787,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
base: 'CHF',
symbols: 'JPY',
},
+ import_findings: [
+ {
+ severity: 'warning',
+ code: 'openapi_import.weak_tool_description',
+ message: 'Описание инструмента слишком короткое или техническое.',
+ suggested_action: 'Уточните описание.',
+ field_path: 'GET /latest',
+ },
+ ],
},
samples: [],
generated_draft: null,
@@ -719,6 +809,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
await page.goto(`/wizard/?mode=edit&operationId=${operationId}`);
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/);
await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/);
+ await expect(page.locator('#tool-input-mapping')).toHaveValue(/transform: to_string/);
await page.locator('.btn-save-draft').click();
await expect.poll(() => updatePayload).not.toBeNull();
@@ -766,6 +857,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
base: 'CHF',
symbols: 'JPY',
},
+ import_findings: [
+ {
+ severity: 'warning',
+ code: 'openapi_import.weak_tool_description',
+ message: 'Описание инструмента слишком короткое или техническое.',
+ suggested_action: 'Уточните описание.',
+ field_path: 'GET /latest',
+ },
+ ],
});
expect(updatePayload.input_mapping.rules).toEqual([
{ source: '$.mcp.base', target: '$.request.query.base', required: true },
@@ -776,6 +876,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
target: '$.request.query.group',
required: false,
default_value: 'month',
+ transform: { kind: 'to_string' },
},
]);
});