diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css index a3ac309..6f41cdf 100644 --- a/apps/ui/css/wizard.css +++ b/apps/ui/css/wizard.css @@ -189,6 +189,137 @@ color: var(--text-primary); } +.mapping-builder-card .config-card-header { + align-items: flex-start; +} + +.mapping-table { + display: grid; + gap: 8px; +} + +.mapping-row { + display: grid; + grid-template-columns: + minmax(130px, 1fr) + minmax(105px, 0.65fr) + minmax(130px, 1fr) + minmax(120px, 0.8fr) + minmax(150px, 0.9fr) + 34px; + gap: 8px; + align-items: center; + padding: 10px; + border: 1px solid var(--border-subtle); + border-radius: 10px; + background: var(--bg-overlay); +} + +.response-mapping-row { + grid-template-columns: minmax(160px, 1fr) minmax(140px, 1fr) 34px; +} + +.mapping-row-remove { + width: 32px; + height: 32px; + font-size: 18px; + line-height: 1; +} + +.mapping-builder-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.advanced-mapping-details { + border: 1px solid var(--border-subtle); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} + +.advanced-mapping-details summary { + padding: 10px 12px; + cursor: pointer; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; +} + +.advanced-mapping-details[open] summary { + border-bottom: 1px solid var(--border-subtle); +} + +.mapping-response-layout { + display: grid; + grid-template-columns: minmax(220px, 0.85fr) minmax(320px, 1.15fr); + gap: 14px; + align-items: start; +} + +.json-tree-picker { + display: grid; + gap: 6px; + max-height: 320px; + overflow: auto; + padding: 10px; + border: 1px solid var(--border-subtle); + border-radius: 10px; + background: var(--bg-overlay); +} + +.json-tree-node { + width: 100%; + text-align: left; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + padding: 7px 9px; + cursor: pointer; +} + +.json-tree-node:hover { + border-color: var(--border); + background: var(--bg-surface); + color: var(--text-primary); +} + +.mapping-warnings { + display: grid; + gap: 8px; + margin-bottom: 20px; +} + +.mapping-warning { + padding: 10px 12px; + border: 1px solid rgba(245, 158, 11, 0.34); + border-radius: 10px; + background: rgba(245, 158, 11, 0.1); + color: var(--text-secondary); + font-size: 13px; + line-height: 1.45; +} + +.mapping-warning strong { + color: var(--text-primary); +} + +@media (max-width: 900px) { + .mapping-row, + .response-mapping-row, + .mapping-response-layout { + grid-template-columns: 1fr; + } + + .mapping-row-remove { + width: 100%; + } +} + /* ══════════════════════════════════════════════════ STEP SIDEBAR ══════════════════════════════════════════════════ */ @@ -1527,6 +1658,16 @@ margin-top: 6px; } +.quality-finding-jump { + margin-top: 8px; +} + +.quality-focus-target { + outline: 2px solid rgba(13, 148, 136, 0.85); + box-shadow: 0 0 0 4px rgba(13, 148, 136, 0.14); + transition: box-shadow 0.2s ease, outline-color 0.2s ease; +} + @media (max-width: 760px) { .agent-preview-summary { grid-template-columns: 1fr; diff --git a/apps/ui/html/wizard/step5.html b/apps/ui/html/wizard/step5.html index 5166c93..1899738 100644 --- a/apps/ui/html/wizard/step5.html +++ b/apps/ui/html/wizard/step5.html @@ -13,20 +13,35 @@
-
-
-
-
-
- yaml / request-fields -
- -
+
+
@@ -35,18 +50,42 @@ source: "$.input.source"
-
-
-
-
-
- yaml / response-fields +
+
+
+
Конструктор ответа инструмента
+
Выберите поля из ответа API, которые нужно вернуть MCP клиенту.
+
+ +
+
+
+
+
Поля ответа API
+
- -
+
+
@@ -103,6 +142,8 @@ tls:
+ +
@@ -196,6 +237,7 @@ tls: "last_name": "Lovelace", "email": "ada@example.com" } +
@@ -203,9 +245,13 @@ tls: "id": "lead_123", "status": "created" } +
+ + + 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' }, }, ]); });