feat: add SHD widget suite
Validate SHD MCP plugin / validate (push) Has been cancelled

This commit is contained in:
2026-08-24 21:46:52 +03:00
parent 2f82ab5f6b
commit cc63037e1f
150 changed files with 7225 additions and 61 deletions
@@ -0,0 +1 @@
64f552a54e178e55d0102a050d001751051ae4d2c50370dbdd24201263499603 index.html
@@ -0,0 +1,5 @@
# CRM widget
This artifact is served by the SHD MCP backend as `ui://shd/crm/v1.html`.
The HTML is standalone, uses the MCP Apps bridge first, and keeps `window.openai` as a compatibility extension. It has no external script or network dependency.
@@ -0,0 +1,282 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CRM — SHD MCP</title>
<style>
:root{color-scheme:light dark;--bg:#f7f8fa;--panel:#fff;--line:#d9dee7;--ink:#172033;--muted:#687386;--accent:#2563eb;--accent-soft:#e8efff;--danger:#b42318}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.shell{max-width:980px;margin:0 auto;padding:24px}.eyebrow{color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}
h1{margin:4px 0 6px;font-size:25px;line-height:1.15}.lede{margin:0;color:var(--muted)}.summary{display:flex;gap:10px;flex-wrap:wrap;margin:18px 0 14px}
.summary span{padding:5px 9px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:var(--muted)}.summary strong{color:var(--ink)}
.toolbar{display:flex;gap:8px;margin-bottom:12px}.search{min-width:0;flex:1;padding:9px 11px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:inherit}
button{cursor:pointer}.refresh{padding:9px 14px;border:1px solid var(--accent);border-radius:8px;background:var(--accent);color:#fff;font-weight:600}.refresh:disabled{opacity:.6;cursor:wait}
.status-message{min-height:22px;color:var(--muted)}.error{color:var(--danger)}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--panel)}
table{width:100%;border-collapse:collapse;min-width:560px}th,td{padding:11px 13px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--muted);font-size:12px;font-weight:600}
tbody tr:last-child td{border-bottom:0}.record{width:100%;border:0;background:transparent;color:inherit;text-align:left}.record:hover{background:var(--accent-soft)}
.title{font-weight:650}.subtitle{display:block;color:var(--muted);font-size:12px;margin-top:2px}.pill{display:inline-block;padding:3px 8px;border-radius:999px;background:var(--accent-soft);color:var(--accent);font-size:12px;white-space:nowrap}
.empty{padding:24px;text-align:center;color:var(--muted)}.details{margin-top:14px;padding:16px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}.details[hidden]{display:none}
.details-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}.detail-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(--bg)}.detail-label{display:block;color:var(--muted);font-size:11px;margin-bottom:3px}.detail-value{overflow-wrap:anywhere}
@media(prefers-color-scheme:dark){:root{--bg:#11151d;--panel:#1b2230;--line:#303a4b;--ink:#edf2fb;--muted:#aab4c5;--accent:#82aaff;--accent-soft:#263858;--danger:#ff9a93}}
</style>
</head>
<body>
<main class="shell" data-widget-module="crm">
<header>
<div class="eyebrow">SHD MCP</div>
<h1>CRM</h1>
<p class="lede">Реестр CRM-сущностей с поиском и просмотром деталей.</p>
<div class="summary" id="summary"></div>
</header>
<div class="toolbar">
<input class="search" id="search" type="search" placeholder="Поиск по названию, статусу или ID" autocomplete="off">
<button class="refresh" id="refresh" type="button">Обновить</button>
</div>
<div class="status-message" id="status" role="status" aria-live="polite"></div>
<div class="table-wrap">
<table>
<thead><tr><th>Сущность</th><th>Статус</th><th>Обновлено</th><th>Тип</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" hidden>Нет данных для отображения.</div>
</div>
<section class="details" id="details" hidden aria-live="polite">
<div class="details-head"><div><h2 id="details-title"></h2><div class="subtitle" id="details-subtitle"></div></div><button class="close" id="close" type="button" aria-label="Закрыть">×</button></div>
<div class="detail-grid" id="detail-grid"></div>
</section>
</main>
<script>
(function () {
var CONFIG = {"module":"crm","title":"CRM","description":"Реестр CRM-сущностей с поиском и просмотром деталей.","nameLabel":"Сущность","statusLabel":"Статус","dateLabel":"Обновлено","valueLabel":"Тип","listTool":"shd_list_crm","detailTool":"shd_get_crm_entity","detailKey":"entity_id","detailSection":"deals","listArgs":{"section":"deals","archived":false,"limit":100}};
var pending = new Map();
var nextRequestId = 1;
var records = [];
var busy = false;
var elements = {
search: document.getElementById('search'),
refresh: document.getElementById('refresh'),
status: document.getElementById('status'),
summary: document.getElementById('summary'),
rows: document.getElementById('rows'),
empty: document.getElementById('empty'),
details: document.getElementById('details'),
detailsTitle: document.getElementById('details-title'),
detailsSubtitle: document.getElementById('details-subtitle'),
detailGrid: document.getElementById('detail-grid'),
close: document.getElementById('close')
};
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('Host request timed out'));
}, 10000);
pending.set(id, { resolve: resolve, reject: reject, timeout: timeout });
});
}
function notify(method, params) {
window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
}
function normalizePayload(value) {
if (!value) return null;
if (value.structuredContent) return value.structuredContent;
if (value.toolOutput) return value.toolOutput;
return value;
}
function unwrap(value) {
var payload = normalizePayload(value);
if (value && value.isError) throw new Error('MCP tool returned an error');
if (payload && payload.error) throw new Error(String(payload.error));
return payload || value;
}
function toArray(value) {
var payload = normalizePayload(value);
if (!payload) return [];
var data = payload.data;
if (data && !Array.isArray(data) && Array.isArray(data.data)) data = data.data;
if (Array.isArray(payload.items)) data = payload.items;
return Array.isArray(data) ? data.filter(function (item) { return item && typeof item === 'object'; }) : [];
}
function idOf(item) { return String(item.id || item.code || item.key || item.number || ''); }
function titleOf(item) { return String(item.title || item.name || item.display_name || item.number || idOf(item) || '—'); }
function subtitleOf(item) { return String(item.subtitle || item.project_code || item.code || item.key || ''); }
function statusOf(item) {
var value = item.status;
if (value && typeof value === 'object') value = value.name || value.id;
return String(value || item.phase || '—');
}
function dateOf(item) { return String(item.date || item.due_date || item.updated_at || item.issued_at || item.created_at || ''); }
function valueOf(item) { return item.value === null || item.value === undefined ? '' : String(item.value); }
function dateText(value) {
if (!value) return '—';
var timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return value;
try { return new Intl.DateTimeFormat(undefined, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(timestamp)); }
catch (error) { return 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 renderSummary() {
elements.summary.replaceChildren();
var item = make('span');
item.append(make('strong', String(records.length)), ' записей');
elements.summary.append(item);
}
function renderRows() {
var query = elements.search.value.trim().toLowerCase();
var filtered = records.filter(function (item) {
if (!query) return true;
return (titleOf(item) + ' ' + subtitleOf(item) + ' ' + statusOf(item) + ' ' + idOf(item)).toLowerCase().indexOf(query) !== -1;
});
elements.rows.replaceChildren();
elements.empty.hidden = filtered.length !== 0;
filtered.forEach(function (item) {
var row = document.createElement('tr');
var name = make('td');
name.append(make('span', titleOf(item), 'title'));
if (subtitleOf(item)) name.append(make('span', subtitleOf(item), 'subtitle'));
var status = make('td');
status.append(make('span', statusOf(item), 'pill'));
row.append(name, status, make('td', dateText(dateOf(item))), make('td', valueOf(item) || '—'));
row.className = 'record';
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.setAttribute('aria-label', titleOf(item));
row.addEventListener('click', function () { showDetails(item); });
row.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') showDetails(item); });
elements.rows.append(row);
});
renderSummary();
}
function render(value) {
records = toArray(value).slice().sort(function (left, right) {
var a = Date.parse(dateOf(left)); var b = Date.parse(dateOf(right));
if (Number.isNaN(a)) a = Number.POSITIVE_INFINITY;
if (Number.isNaN(b)) b = Number.POSITIVE_INFINITY;
return a - b || titleOf(left).localeCompare(titleOf(right));
});
renderRows();
}
function setStatus(value, isError) {
elements.status.textContent = value || '';
elements.status.className = isError ? 'status-message error' : 'status-message';
}
function detailValue(value) {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'object') {
try { return JSON.stringify(value); } catch (error) { return '—'; }
}
return String(value);
}
function addDetail(label, value) {
var item = make('div', undefined, 'detail');
item.append(make('span', label, 'detail-label'), make('span', detailValue(value), 'detail-value'));
elements.detailGrid.append(item);
}
function showDetails(item) {
elements.detailsTitle.textContent = titleOf(item);
elements.detailsSubtitle.textContent = subtitleOf(item) || idOf(item);
elements.detailGrid.replaceChildren();
addDetail('ID', idOf(item));
addDetail(CONFIG.statusLabel, statusOf(item));
addDetail(CONFIG.dateLabel, dateText(dateOf(item)));
if (valueOf(item)) addDetail(CONFIG.valueLabel, valueOf(item));
if (item.meta) addDetail('Meta', item.meta);
elements.details.hidden = false;
var id = idOf(item);
if (!id || !CONFIG.detailTool) return;
var args = {};
args[CONFIG.detailKey] = Number(id) || id;
if (CONFIG.detailSection) args.section = CONFIG.detailSection;
request('tools/call', { name: CONFIG.detailTool, arguments: args }).then(function (result) {
var payload = unwrap(result);
var data = payload && payload.data;
if (Array.isArray(data)) data = data[0];
if (!data || typeof data !== 'object') return;
elements.detailGrid.replaceChildren();
Object.keys(data).slice(0, 12).forEach(function (key) { addDetail(key, data[key]); });
}).catch(function (error) { setStatus(error && error.message ? error.message : 'Не удалось загрузить детали.', true); });
}
function refresh() {
if (busy) return;
busy = true;
elements.refresh.disabled = true;
setStatus('Загрузка…', false);
request('tools/call', { name: CONFIG.listTool, arguments: CONFIG.listArgs }).then(function (result) {
render(unwrap(result));
setStatus('', false);
}).catch(function (error) {
setStatus(error && error.message ? error.message : 'Не удалось загрузить данные.', true);
}).finally(function () {
busy = false;
elements.refresh.disabled = false;
});
}
function initializeHost() {
return request('ui/initialize', {
protocolVersion: '2025-11-25',
appInfo: { name: 'SHD ' + CONFIG.title, title: 'SHD ' + CONFIG.title, version: '1.0.0', websiteUrl: 'https://shd.xyz.su' },
appCapabilities: {}
}).then(function () { notify('ui/notifications/initialized', {}); }).catch(function () {});
}
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 entry = pending.get(message.id);
pending.delete(message.id);
window.clearTimeout(entry.timeout);
if (message.error) entry.reject(new Error(message.error.message || 'Host request failed'));
else entry.resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-input') {
if (!records.length) render(message.params || {});
}
if (message.method === 'ui/notifications/tool-result') render(message.params && message.params.structuredContent);
}, { passive: true });
elements.search.addEventListener('input', renderRows);
elements.refresh.addEventListener('click', refresh);
elements.close.addEventListener('click', function () { elements.details.hidden = true; });
void initializeHost();
var initialOutput = typeof window !== 'undefined' && window.openai ? window.openai.toolOutput : null;
if (initialOutput) render(initialOutput);
else {
var initialInput = typeof window !== 'undefined' && window.openai ? window.openai.toolInput : null;
if (initialInput) render(initialInput);
else refresh();
}
}());
</script>
</body>
</html>
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"name": "shd-crm",
"version": "1.0.0",
"resources": [
{
"file": "index.html",
"uri": "ui://shd/crm/v1.html",
"mediaType": "text/html;profile=mcp-app",
"bytes": 15485,
"sha256": "64f552a54e178e55d0102a050d001751051ae4d2c50370dbdd24201263499603"
}
],
"license": "../../../../../LICENSE",
"source": {
"repository": "https://github.com/bulava92/shd-mcp-plugin",
"path": "plugins/shd-mcp-plugin/widgets/crm/v1/index.html"
}
}