diff --git a/CHATGPT-SETUP.md b/CHATGPT-SETUP.md index 90c6a04..0f9e762 100644 --- a/CHATGPT-SETUP.md +++ b/CHATGPT-SETUP.md @@ -42,8 +42,9 @@ The expected flow is: `shd_render_acl_matrix_widget`, `shd_render_inventory_warnings_widget`, `shd_render_gitea_board_widget`, `shd_render_schemes_progress_widget`, `shd_render_notes_tree_widget` or `shd_render_project_db_preview_widget`. -3. The selected widget renders a read-only register and can call the matching - detail tool for one selected row. +3. The selected widget renders a register, can call the matching detail tool for + one selected row and exposes controlled write actions when declared by the + render metadata. The widget resources are versioned at `ui://shd/active-projects/v1.html`, `ui://shd/documents/v1.html`, `ui://shd/tasks/v1.html`, diff --git a/README.md b/README.md index 26fc145..d7867d5 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,12 @@ a custom app and set the MCP URL to `https://shd.xyz.su/mcp`. Complete the SHD OAuth flow and refresh the app after server metadata changes. The active-projects result can render an inline MCP Apps widget. It is a small -read-only dashboard with: +dashboard with: - `Проект → Дата завершения → Статус`; - sorting by the nearest deadline; - local search by project name/code; -- a refresh action and a read-only project-details panel. +- a refresh action, a project-details panel and controlled project actions. If the host does not support MCP Apps UI, the data tool and the plain Markdown table remain fully usable. @@ -62,7 +62,7 @@ The backend serves the checked-in widget artifact at `ui://shd/active-projects/v1.html`; its manifest and `SHA256SUMS` are kept next to the HTML resource under `plugins/shd-mcp-plugin/widgets/active-projects/v1/`. -The same read-only data/render contract is available for Documents, Tasks, +The same data/render contract is available for Documents, Tasks, Finance, CRM, Discussions, Notifications, Scheduling, Inventory, Agents, Status Page, Project DB, Notes, Project overview, Files, Proposals, Terms, Activity, Organizations and access, Gitea Issues and Schemes: diff --git a/plugins/shd-mcp-plugin/README.md b/plugins/shd-mcp-plugin/README.md index 73a4cf7..c55abc7 100644 --- a/plugins/shd-mcp-plugin/README.md +++ b/plugins/shd-mcp-plugin/README.md @@ -34,10 +34,14 @@ modules. Skills do not grant permissions or bypass server-side ACLs. - safe bulk-change planning and validation rules. When the connected MCP host supports MCP Apps UI, the 36 SHD render tools have -inline read-only widgets: active projects, module registers and specialized +inline interactive widgets: active projects, module registers and specialized Kanban, workload, timeline, funnel, dashboard, calendar, matrix, tree and Project DB views. Each widget sorts or filters normalized records, shows -status/value fields and can load one selected record’s details where supported. +status/value fields, can load one selected record’s details where supported and +exposes only the write actions declared by its render metadata. A write always +requires an explicit form submission; destructive actions also require a +confirmation checkbox. After a successful mutation, the widget refreshes its +source list. The same tools still return normal structured data for hosts that do not render widgets. diff --git a/plugins/shd-mcp-plugin/widgets/_shared/write-actions.css b/plugins/shd-mcp-plugin/widgets/_shared/write-actions.css new file mode 100644 index 0000000..d39a3d2 --- /dev/null +++ b/plugins/shd-mcp-plugin/widgets/_shared/write-actions.css @@ -0,0 +1,20 @@ +.shd-widget-actions{margin-top:14px;padding:14px;border:1px solid var(--border,rgba(31,41,55,.16));border-radius:11px;background:var(--surface,#e8edf2);color:var(--text,#1d1f25)} +.shd-widget-actions[hidden]{display:none} +.shd-widget-actions-header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} +.shd-widget-actions-selection{overflow:hidden;color:var(--muted,#616670);font-size:12px;text-overflow:ellipsis;white-space:nowrap} +.shd-widget-actions-select,.shd-widget-actions-input{width:100%;border:1px solid var(--border,rgba(31,41,55,.16));border-radius:8px;padding:8px 10px;background:var(--bg,#f8fafc);color:var(--text,#1d1f25)} +.shd-widget-actions-select:focus,.shd-widget-actions-input:focus{border-color:var(--accent,#535963);outline:0;box-shadow:0 0 0 3px var(--accent-soft,rgba(75,85,99,.12))} +.shd-widget-actions-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin-top:10px} +.shd-widget-actions-field{display:grid;gap:4px;color:var(--muted,#616670);font-size:11px} +.shd-widget-actions-field:has(textarea){grid-column:span 2} +.shd-widget-actions-field input[type=checkbox]{width:18px;height:18px;accent-color:var(--accent,#535963)} +.shd-widget-actions-confirm{display:flex;grid-column:1/-1;align-items:center;gap:7px;color:var(--text,#1d1f25);font-size:12px} +.shd-widget-actions-warning,.shd-widget-actions-hint{grid-column:1/-1;color:var(--muted,#616670);font-size:12px} +.shd-widget-actions-danger{color:var(--danger,#b42318)} +.shd-widget-actions-status{min-height:18px;margin-top:9px;color:var(--muted,#616670);font-size:12px} +.shd-widget-actions-status-error{color:var(--danger,#b42318)} +.shd-widget-actions-buttons{display:flex;justify-content:flex-end;gap:8px;margin-top:10px} +.shd-widget-actions-buttons button{border:1px solid var(--border,rgba(31,41,55,.16));border-radius:8px;padding:8px 11px;background:var(--bg,#f8fafc);color:var(--text,#1d1f25);cursor:pointer} +.shd-widget-actions-buttons button:hover{background:var(--surface-hover,#e1e8ef)} +.shd-widget-actions-buttons button:disabled{cursor:wait;opacity:.62} +@media(max-width:600px){.shd-widget-actions-field:has(textarea){grid-column:auto}.shd-widget-actions-header{display:block}.shd-widget-actions-selection{display:block;margin-top:3px}} diff --git a/plugins/shd-mcp-plugin/widgets/_shared/write-actions.js b/plugins/shd-mcp-plugin/widgets/_shared/write-actions.js new file mode 100644 index 0000000..cbc4da2 --- /dev/null +++ b/plugins/shd-mcp-plugin/widgets/_shared/write-actions.js @@ -0,0 +1,429 @@ +(function () { + 'use strict'; + + if (window.__SHD_WIDGET_WRITE_ACTIONS__) return; + window.__SHD_WIDGET_WRITE_ACTIONS__ = true; + + var copy = { + ru: { + actions: 'Действия', + chooseAction: 'Выберите действие', + selectRecord: 'Сначала выберите запись.', + submit: 'Сохранить', + cancel: 'Отмена', + confirm: 'Подтверждаю действие', + warning: 'Проверьте данные перед записью.', + destructive: 'Операция может изменить или удалить данные.', + required: 'Заполните обязательное поле.', + invalidJson: 'Введите корректный JSON.', + success: 'Изменения сохранены.', + failure: 'Не удалось сохранить изменения.', + loading: 'Сохраняю…', + noActions: 'Для этого виджета нет доступных действий.', + refreshed: 'Список обновлён.', + fieldValue: 'Значение', + }, + en: { + actions: 'Actions', + chooseAction: 'Choose an action', + selectRecord: 'Select a record first.', + submit: 'Save', + cancel: 'Cancel', + confirm: 'I confirm this action', + warning: 'Review the values before writing.', + destructive: 'This operation may change or remove data.', + required: 'Fill in the required field.', + invalidJson: 'Enter valid JSON.', + success: 'Changes saved.', + failure: 'Could not save changes.', + loading: 'Saving…', + noActions: 'No actions are available for this widget.', + refreshed: 'List refreshed.', + fieldValue: 'Value', + }, + }; + + var state = { + widget: {}, + records: [], + selected: null, + action: null, + inputs: {}, + busy: false, + lang: String(document.documentElement.lang || 'ru').toLowerCase().indexOf('ru') === 0 ? 'ru' : 'en', + }; + var pending = new Map(); + var nextRequestId = 1; + var elements = {}; + + function t(key) { + return (copy[state.lang] && copy[state.lang][key]) || copy.en[key] || key; + } + + function object(value) { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); + } + + function normalize(value) { + if (!value) return null; + if (value.structuredContent) return normalize(value.structuredContent); + if (value.toolOutput) return normalize(value.toolOutput); + if (value.toolInput) return normalize(value.toolInput); + if (value.arguments && !value.data && !value.items && !value.projects) return normalize(value.arguments); + return value; + } + + function widgetFrom(value) { + var payload = normalize(value) || {}; + var meta = object(payload.meta) ? payload.meta : {}; + return object(meta.widget) ? meta.widget : {}; + } + + function recordsFrom(value) { + var payload = normalize(value) || {}; + var data = Array.isArray(payload.items) ? payload.items : payload.projects; + if (!Array.isArray(data)) data = payload.data; + if (data && !Array.isArray(data) && Array.isArray(data.data)) data = data.data; + return Array.isArray(data) ? data.filter(function (item) { return object(item); }) : []; + } + + function readPath(value, path) { + if (!value || path === undefined || path === null) return undefined; + var parts = String(path).split('.'); + var current = value; + for (var index = 0; index < parts.length; index += 1) { + if (current === undefined || current === null) return undefined; + current = current[parts[index]]; + } + return current; + } + + function firstValue(paths) { + var list = Array.isArray(paths) ? paths : [paths]; + var sources = [state.selected, state.widget.sourceArgs, state.widget.listArgs]; + for (var sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) { + var sourceObject = sources[sourceIndex]; + if (!sourceObject) continue; + for (var pathIndex = 0; pathIndex < list.length; pathIndex += 1) { + var value = readPath(sourceObject, list[pathIndex]); + if (value !== undefined && value !== null && value !== '') return value; + } + } + return undefined; + } + + function text(value) { + if (value === undefined || value === null) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + } + + function label(value) { + if (object(value)) return value[state.lang] || value.ru || value.en || t('fieldValue'); + return String(value || t('fieldValue')); + } + + function actionLabel(action) { + return label(action && action.label) || action.id || action.tool || t('actions'); + } + + function hasTargets(action) { + return Boolean(action && ((Array.isArray(action.targets) && action.targets.length) || action.target)); + } + + function actionList() { + return Array.isArray(state.widget.actions) ? state.widget.actions.filter(function (action) { + return action && action.tool; + }) : []; + } + + function request(method, params) { + var openai = typeof window !== 'undefined' ? window.openai : undefined; + if (method === 'tools/call' && openai && typeof openai.callTool === 'function') { + return openai.callTool(params.name, params.arguments || {}); + } + var id = nextRequestId++; + window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params || {} }, '*'); + return new Promise(function (resolve, reject) { + var timeout = window.setTimeout(function () { + pending.delete(id); + reject(new Error(t('failure'))); + }, 15000); + pending.set(id, { resolve: resolve, reject: reject, timeout: timeout }); + }); + } + + function unwrap(value) { + var payload = normalize(value); + if (value && value.isError) throw new Error(payload && payload.error ? text(payload.error) : t('failure')); + if (payload && payload.error) throw new Error(text(payload.error)); + return payload || value; + } + + function make(tag, value, className) { + var node = document.createElement(tag); + if (className) node.className = className; + if (value !== undefined) node.textContent = value; + return node; + } + + function ensurePanel() { + if (elements.panel) return; + var root = document.querySelector('main') || document.body; + var panel = make('section', undefined, 'shd-widget-actions'); + panel.hidden = true; + panel.setAttribute('aria-live', 'polite'); + var header = make('div', undefined, 'shd-widget-actions-header'); + header.append(make('strong', t('actions')), make('span', '', 'shd-widget-actions-selection')); + var select = document.createElement('select'); + select.className = 'shd-widget-actions-select'; + select.setAttribute('aria-label', t('actions')); + var form = make('form', undefined, 'shd-widget-actions-form'); + var status = make('div', '', 'shd-widget-actions-status'); + var buttons = make('div', undefined, 'shd-widget-actions-buttons'); + var submit = make('button', t('submit'), 'shd-widget-actions-submit'); + submit.type = 'submit'; + var cancel = make('button', t('cancel'), 'shd-widget-actions-cancel'); + cancel.type = 'button'; + buttons.append(submit, cancel); + panel.append(header, select, form, status, buttons); + root.append(panel); + elements = { panel: panel, selection: header.lastChild, select: select, form: form, status: status, submit: submit, cancel: cancel }; + select.addEventListener('change', function () { + var selected = actionList().find(function (action) { return action.id === select.value; }); + state.action = selected || null; + renderForm(); + }); + cancel.addEventListener('click', function () { + state.action = null; + select.value = ''; + renderForm(); + }); + form.addEventListener('submit', function (event) { + event.preventDefault(); + submitAction(); + }); + } + + function inputValue(fieldConfig) { + var value = firstValue(fieldConfig.source || []); + if (value === undefined && fieldConfig.default !== undefined) value = fieldConfig.default; + return value; + } + + function createInput(fieldConfig) { + var type = fieldConfig.type || 'text'; + var input; + if (type === 'textarea' || type === 'json') { + input = document.createElement('textarea'); + input.rows = type === 'json' ? 4 : 3; + if (type === 'json') input.placeholder = '{ }'; + } else if (type === 'select') { + input = document.createElement('select'); + (fieldConfig.options || []).forEach(function (option) { + var item = document.createElement('option'); + item.value = String(option[0]); + item.textContent = state.lang === 'ru' ? String(option[1]) : String(option[2] || option[1]); + input.append(item); + }); + } else { + input = document.createElement('input'); + input.type = type === 'number' || type === 'email' ? type : 'text'; + } + input.className = 'shd-widget-actions-input'; + input.name = fieldConfig.name; + var value = inputValue(fieldConfig); + if (type === 'checkbox') { + input.type = 'checkbox'; + input.checked = value === true || value === 'true' || value === 1 || value === '1'; + } else if (value !== undefined && value !== null) { + input.value = type === 'json' && typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value); + } + if (fieldConfig.required) input.required = true; + return input; + } + + function renderForm() { + ensurePanel(); + var selectedActionId = state.action ? state.action.id : ''; + elements.select.replaceChildren(); + var placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = t('chooseAction'); + elements.select.append(placeholder); + actionList().forEach(function (action) { + var option = document.createElement('option'); + option.value = action.id; + option.textContent = actionLabel(action); + elements.select.append(option); + }); + elements.select.value = selectedActionId; + elements.select.hidden = actionList().length === 0; + elements.form.replaceChildren(); + elements.status.textContent = ''; + elements.submit.disabled = state.busy; + var action = state.action; + if (!action) { + elements.selection.textContent = state.selected ? text(state.selected.title || state.selected.name || state.selected.id || '') : ''; + elements.submit.hidden = true; + elements.cancel.hidden = true; + if (actionList().length && !state.selected && actionList().every(hasTargets)) { + elements.form.append(make('div', t('selectRecord'), 'shd-widget-actions-hint')); + } + return; + } + elements.selection.textContent = state.selected ? text(state.selected.title || state.selected.name || state.selected.id || '') : ''; + elements.submit.hidden = false; + elements.cancel.hidden = false; + if (hasTargets(action) && !state.selected) elements.form.append(make('div', t('selectRecord'), 'shd-widget-actions-hint')); + (action.fields || []).forEach(function (fieldConfig) { + if (!fieldConfig || !fieldConfig.name || fieldConfig.generated || fieldConfig.type === 'hidden') return; + var wrapper = make('label', undefined, 'shd-widget-actions-field'); + wrapper.append(make('span', label(fieldConfig.label))); + wrapper.append(createInput(fieldConfig)); + elements.form.append(wrapper); + }); + if (action.confirm) { + var confirmLabel = make('label', undefined, 'shd-widget-actions-confirm'); + var confirm = document.createElement('input'); + confirm.type = 'checkbox'; + confirm.name = '__confirm'; + confirm.required = true; + confirmLabel.append(confirm, make('span', t('confirm'))); + elements.form.append(confirmLabel); + } + var notice = make('div', action.destructive ? t('destructive') : t('warning'), action.destructive ? 'shd-widget-actions-warning shd-widget-actions-danger' : 'shd-widget-actions-warning'); + elements.form.append(notice); + } + + function coerce(value, type) { + if (type === 'number') { + var number = Number(value); + return Number.isFinite(number) ? number : value; + } + if (type === 'checkbox') return Boolean(value); + if (type === 'json') { + try { return JSON.parse(value); } catch (error) { throw new Error(t('invalidJson')); } + } + return value; + } + + function targetType(name) { + return /^(task|document|contract|booking|item|monitor|proposal|approval|page|notification|topic|membership|organization|space|status|project)_?id$/.test(name) || name === 'issue_number' || name === 'number' ? 'number' : 'text'; + } + + function targetList(action) { + if (Array.isArray(action.targets)) return action.targets; + return action.target ? [action.target] : []; + } + + function collectArguments(action) { + var args = Object.assign({}, action.fixed || {}); + (action.context || []).forEach(function (name) { + var value = firstValue([name]); + if (value !== undefined && value !== null && value !== '') args[name] = value; + }); + targetList(action).forEach(function (targetConfig) { + var value = firstValue(targetConfig.source || targetConfig.name); + if (value === undefined || value === null || value === '') throw new Error(t('selectRecord')); + args[targetConfig.name] = coerce(value, targetConfig.type || targetType(targetConfig.name)); + }); + (action.fields || []).forEach(function (fieldConfig) { + if (!fieldConfig || !fieldConfig.name || fieldConfig.generated || fieldConfig.type === 'hidden') return; + var input = Array.prototype.find.call(elements.form.querySelectorAll('[name]'), function (node) { + return node.getAttribute('name') === fieldConfig.name; + }); + if (!input) return; + var raw = fieldConfig.type === 'checkbox' ? input.checked : input.value.trim(); + if (raw === '' && !input.checked && !fieldConfig.required) return; + if (raw === '' && fieldConfig.required) throw new Error(t('required')); + args[fieldConfig.name] = coerce(raw, fieldConfig.type || 'text'); + }); + if (action.idempotencyKey) args.idempotency_key = 'shd-widget-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); + if (action.confirmArg) args.confirm = true; + return args; + } + + function setStatus(message, error) { + elements.status.textContent = message || ''; + elements.status.className = 'shd-widget-actions-status' + (error ? ' shd-widget-actions-status-error' : ''); + } + + function submitAction() { + if (state.busy || !state.action) return; + var action = state.action; + var args; + try { args = collectArguments(action); } catch (error) { setStatus(error.message || t('failure'), true); return; } + state.busy = true; + elements.submit.disabled = true; + elements.cancel.disabled = true; + setStatus(t('loading'), false); + request('tools/call', { name: action.tool, arguments: args }).then(function (result) { + unwrap(result); + setStatus(t('success'), false); + var refresh = document.getElementById('refresh'); + if (refresh && typeof refresh.click === 'function') window.setTimeout(function () { refresh.click(); }, 250); + }).catch(function (error) { + setStatus(error && error.message ? error.message : t('failure'), true); + }).finally(function () { + state.busy = false; + elements.submit.disabled = false; + elements.cancel.disabled = false; + }); + } + + function recordFromNode(node) { + var content = String(node.textContent || '').toLowerCase(); + var exact = state.records.find(function (item) { + var id = text(item.id || item.code || item.key || item.number).toLowerCase(); + return id && content.indexOf(id) !== -1; + }); + if (exact) return exact; + return state.records.find(function (item) { + var title = text(item.title || item.name || item.display_name || item.number).toLowerCase(); + return title && content.indexOf(title) !== -1; + }) || null; + } + + function update(value) { + var payload = normalize(value) || {}; + var widget = widgetFrom(payload); + if (Object.keys(widget).length) state.widget = widget; + var records = recordsFrom(payload); + if (records.length || Array.isArray(payload.data) || Array.isArray(payload.items) || Array.isArray(payload.projects)) state.records = records; + ensurePanel(); + var actions = actionList(); + elements.panel.hidden = actions.length === 0; + if (actions.length && !state.action) renderForm(); + else if (actions.length) renderForm(); + } + + document.addEventListener('click', function (event) { + var node = event.target && event.target.closest ? event.target.closest('.record,.project-row,.visual-card,.progress-card,.preview-card,.calendar-card,.timeline-card,.kanban-card') : null; + if (!node || (elements.panel && elements.panel.contains(node))) return; + var item = recordFromNode(node); + if (!item) return; + state.selected = item; + ensurePanel(); + renderForm(); + }, true); + + window.addEventListener('message', function (event) { + if (event.source !== window.parent) return; + var message = event.data; + if (!message || message.jsonrpc !== '2.0') return; + if (message.id !== undefined && pending.has(message.id)) { + var requestState = pending.get(message.id); + pending.delete(message.id); + window.clearTimeout(requestState.timeout); + if (message.error) requestState.reject(new Error(message.error.message || t('failure'))); + else requestState.resolve(message.result); + return; + } + if (message.method === 'ui/notifications/tool-input' || message.method === 'ui/notifications/tool-result') update(message.params || {}); + }, { passive: true }); + + ensurePanel(); + var initial = typeof window !== 'undefined' && window.openai ? (window.openai.toolOutput || window.openai.toolInput) : null; + if (initial) update(initial); +}()); diff --git a/plugins/shd-mcp-plugin/widgets/acl-matrix/v1/SHA256SUMS b/plugins/shd-mcp-plugin/widgets/acl-matrix/v1/SHA256SUMS index 6044833..56c1d65 100644 --- a/plugins/shd-mcp-plugin/widgets/acl-matrix/v1/SHA256SUMS +++ b/plugins/shd-mcp-plugin/widgets/acl-matrix/v1/SHA256SUMS @@ -1 +1 @@ -806f28ac654a7504ae5a258add4c03114dfc9fc80328d479e23b09be92c7a74d index.html +818956bcece3e1a64a8f41f51a8bcc19ebcd0c95d1eb9bfa923166dca9660711 index.html diff --git a/plugins/shd-mcp-plugin/widgets/acl-matrix/v1/index.html b/plugins/shd-mcp-plugin/widgets/acl-matrix/v1/index.html index 3e1f4ba..747cec0 100644 --- a/plugins/shd-mcp-plugin/widgets/acl-matrix/v1/index.html +++ b/plugins/shd-mcp-plugin/widgets/acl-matrix/v1/index.html @@ -14,6 +14,28 @@ .details{margin-top:14px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--bg)}.details[hidden]{display:none}.detail-head{display:flex;justify-content:space-between;gap:12px;align-items:start}.details h2{margin:0;font-size:18px}.close{border:0;background:transparent;color:var(--muted);font-size:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-top:14px}.detail{padding:9px;border-radius:8px;background:var(--surface)}.label{display:block;color:var(--muted);font-size:11px;margin-bottom:3px}.value{overflow-wrap:anywhere;white-space:pre-wrap}@media(max-width:640px){.shell{padding:12px}.card{padding:15px}.toolbar{display:grid;grid-template-columns:minmax(0,1fr) auto}} @media(prefers-color-scheme:dark){:root{--bg:#202020;--surface:#292929;--hover:#303336;--line:#43474c;--ink:#eaecf0;--muted:#96999d;--accent:#c7b07a;--soft:rgba(199,176,122,.18);--bad:#ff9a93;--good:#83c995;--button:#241f14;--shadow:0 10px 26px rgba(0,0,0,.34)}} +