diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef925d..a48e4bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 0.26.0 — 2026-10-21 +### Ajouté — panneau Spark intégré + +Le bouton Spark de la barre commune ouvre désormais le panneau de +l'agent commun (conversation, Aujourd'hui, approbations, missions), +comme dans TikTak et Flowdeck : assets `draco-panneau.js/css` +vendangés à l'identique, action `spark` branchée sur +`DRACO_PANNEAU.toggle()`. Redéploiement Docker requis (avec le reste +de la 0.26.0). + ### Ajouté — écritures MCP minimales et sûres (service d'agents) Le serveur MCP n'était qu'en lecture depuis la 0.5.0. Le service diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html index aced36c..54a211b 100644 --- a/app/templates/_draco_bar.html +++ b/app/templates/_draco_bar.html @@ -6,6 +6,7 @@ Inclus par _header.html (toutes les pages applicatives ; ni le wallboard ni l'écran de connexion, volontaires). #} +
@@ -86,7 +87,11 @@ if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; } if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; } if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; } - if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; } + if (act === "spark") { + if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); } + else { dire("Spark : le panneau n'a pas pu se charger."); } + return; + } if (act === "rappel-entete") { var bar = document.querySelector(".draco-bar"); if (bar) bar.style.display = ""; @@ -96,3 +101,4 @@ }; + diff --git a/static/css/draco-panneau.css b/static/css/draco-panneau.css new file mode 100644 index 0000000..743bc07 --- /dev/null +++ b/static/css/draco-panneau.css @@ -0,0 +1,83 @@ +/* Panneau Spark — branchement au service d'agents (Fusion, jalon J1). + Composant d'intégration TikTak : le panneau définitif sera vendangé + depuis dracodev-commons ; celui-ci prouve le chemin panneau → service. */ +.draco-panneau { + position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 94vw; + background: #fbfbfa; color: #242624; z-index: 400; + box-shadow: -12px 0 36px rgb(0 0 0 / .22); + display: flex; flex-direction: column; + transform: translateX(105%); transition: transform .28s ease; + font: 14px/1.5 system-ui, "Segoe UI", sans-serif; +} +.draco-panneau.open { transform: translateX(0); } +.draco-panneau-hd { + display: flex; align-items: center; gap: 9px; + padding: 13px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; +} +.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; } +.draco-panneau-hd .soustitre { font-size: 12px; color: #6c7268; } +.draco-panneau-hd .fermer { + margin-left: auto; border: 0; background: #eef0ea; border-radius: 8px; + width: 30px; height: 30px; cursor: pointer; font-size: 14px; +} +.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; } +.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; } +.draco-panneau-etat.ok .point { background: #2ea36b; } +.draco-panneau-etat.err .point { background: #cf4b32; } +.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; } +.draco-panneau-outils button { + border: 1px solid #d6d8d1; background: #f4f5f1; border-radius: 999px; + padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer; +} +.draco-panneau-outils button:hover { background: #e9ebe4; } +.draco-appro { + align-self: stretch; background: #fdf6ec; border: 1.6px solid #e8a13c; + border-radius: 12px; padding: 10px 12px; +} +.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; } +.draco-appro .resume { margin: 5px 0 8px; } +.draco-appro .btns { display: flex; gap: 7px; } +.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; } +.draco-appro .btns button.go { background: #2ea36b; color: #fff; } +.draco-auj { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; } +.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 6px 0 4px; } +.draco-auj h5:first-child { margin-top: 0; } +.draco-auj ul { margin: 0; padding-left: 17px; } +.draco-auj .etat-source { color: #6c7268; font-size: 12.5px; } +.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; } +.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; } +.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; } +.draco-msg.agent { align-self: flex-start; background: #eef0ea; border-bottom-left-radius: 4px; } +.draco-msg.sys { align-self: center; background: none; color: #6c7268; font-size: 12.5px; text-align: center; max-width: 96%; } +.draco-panneau-ft { border-top: 1px solid #e4e5e0; padding: 11px 12px; background: #fff; } +.draco-panneau-form { display: flex; gap: 8px; } +.draco-panneau-form input { + flex: 1; border: 1px solid #d6d8d1; border-radius: 10px; padding: 9px 11px; font: inherit; +} +.draco-panneau-form button { + border: 0; border-radius: 10px; background: #7c5cd6; color: #fff; + padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer; +} +.draco-panneau-form button:disabled { opacity: .5; cursor: default; } +.draco-panneau-note { font-size: 11.5px; color: #8a8f84; margin-top: 8px; text-align: center; } +.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; } +.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 0 0 6px; } +.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: #6c7268; margin: 8px 0 3px; } +.draco-missions select, .draco-missions input, .draco-missions textarea { + width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px; + padding: 7px 9px; font: inherit; font-size: 13px; background: #fff; +} +.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; } +.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; } +.draco-missions .btns button.go { background: #7c5cd6; color: #fff; } +.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; } +.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; } +.draco-missions .m-item { border: 1px solid #e4e5e0; border-radius: 10px; padding: 8px 10px; } +.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; } +.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: #6c7268; white-space: nowrap; } +.draco-missions .m-statut.st-succeeded { color: #2ea36b; } +.draco-missions .m-statut.st-failed { color: #cf4b32; } +.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; } +.draco-missions .m-meta { font-size: 12px; color: #6c7268; } +.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; } +.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: #f6e3de; color: #a03a28; } diff --git a/static/js/draco-panneau.js b/static/js/draco-panneau.js new file mode 100644 index 0000000..1eeeb55 --- /dev/null +++ b/static/js/draco-panneau.js @@ -0,0 +1,380 @@ +/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5). + * + * Parle à l'API du service d'agents (contrat de dracodev-commons) : + * GET /api/v1/me — identité (cookie draco_session) + * POST /api/v1/chat — conversation réelle du noyau (J2) : + * LLM DeepSeek + outils MCP des apps + * GET /api/v1/conversations/… — historique de la conversation + * GET /api/v1/executors — registre (noyau, sous-agents J3, + * externes J4 : état de configuration) + * POST /api/v1/missions — missions longues (J5) : lancement, + * suivi, rapport, annulation + * États honnêtes : service injoignable / session non reconnue / LLM non + * configuré / exécuteur non configuré sont affichés tels quels. */ +(function () { + "use strict"; + var API = "https://agent.dracodev.net"; + var convId = null; + try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ } + var etatConnu = false; + var historiqueCharge = false; + var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null; + + function domaine() { + try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } + } + + function construire() { + root = document.createElement("aside"); + root.className = "draco-panneau"; + root.setAttribute("aria-label", "Spark, l'agent commun"); + root.innerHTML = + '
' + + ' ✦' + + '
Spark — agent commun
' + + '
Service d\'agents dracodev
' + + ' ' + + '
' + + '
' + + ' Connexion au service…' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + ' ' + + '
' + + '
Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.
' + + '
'; + document.body.appendChild(root); + msgsEl = root.querySelector(".draco-panneau-msgs"); + etatEl = root.querySelector(".draco-panneau-etat"); + inputEl = root.querySelector("input"); + sendBtn = root.querySelector("button[type=submit]"); + root.querySelector(".fermer").addEventListener("click", fermer); + root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui); + root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations); + root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions); + root.querySelector("form").addEventListener("submit", envoyer); + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape" && root.classList.contains("open")) fermer(); + }); + ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + "."); + } + + function ajouterMsg(kind, texte) { + var d = document.createElement("div"); + d.className = "draco-msg " + kind; + d.textContent = texte; + msgsEl.appendChild(d); + msgsEl.scrollTop = msgsEl.scrollHeight; + return d; + } + + function majEtat(cls, texte) { + etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls; + etatEl.querySelector(".txt").textContent = texte; + } + + function verifierIdentite() { + if (etatConnu) return; + fetch(API + "/api/v1/me", { credentials: "include" }) + .then(function (r) { + if (r.status === 401) { + majEtat("err", "Session partagée non reconnue"); + ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme."); + etatConnu = true; + return null; + } + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (me) { + if (!me) return; + etatConnu = true; + majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub))); + }) + .catch(function () { + majEtat("err", "Service d'agents injoignable"); + ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré."); + }); + } + + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; + }); + } + + function chargerAujourdhui() { + var bloc = document.createElement("div"); + bloc.className = "draco-auj"; + bloc.innerHTML = "
Aujourd'hui — vu du service d'agents
Chargement…
"; + msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight; + fetch(API + "/api/v1/aujourdhui", { credentials: "include" }) + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (d) { + var h = "
Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "
"; + var t = d.sources.tiktak; + h += "
Tâches du jour — TikTak
"; + if (t.status === "ok") { + if (!t.tasks.length) h += "
Aucune tâche échue aujourd'hui. 🎉
"; + else h += ""; + } else if (t.status === "non_configure") { + h += "
Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).
"; + } else if (t.status === "non_autorise") { + h += "
Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).
"; + } else { + h += "
Source TikTak " + esc(t.status) + ".
"; + } + var k = d.sources.kanbante; + h += "
Cartes en Révision — Kanbante
"; + if (k.status === "ok") { + if (!k.review.length) h += "
Rien à valider. 🎉
"; + else h += ""; + } else if (k.status === "non_autorise") { + h += "
Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.
"; + } else { + h += "
Source Kanbante " + esc(k.status) + ".
"; + } + bloc.innerHTML = h; + msgsEl.scrollTop = msgsEl.scrollHeight; + }) + .catch(function () { + bloc.innerHTML = "
Aujourd'hui
Service injoignable.
"; + }); + } + + function chargerApprobations() { + fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" }) + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (d) { + if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; } + d.approvals.forEach(function (a) { + var bloc = document.createElement("div"); + bloc.className = "draco-appro"; + bloc.innerHTML = "
Approbation demandée — " + esc(a.action) + "
" + + "
" + esc(a.summary) + "
" + + "
" + + "
"; + bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); }); + bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); }); + msgsEl.appendChild(bloc); + }); + msgsEl.scrollTop = msgsEl.scrollHeight; + }) + .catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); }); + } + + function decider(id, decision, bloc) { + fetch(API + "/api/v1/approvals/" + id + "/decide", { + method: "POST", credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ decision: decision }) + }) + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (d) { + bloc.style.opacity = ".55"; + bloc.querySelector(".btns").innerHTML = "Décision enregistrée : " + esc(d.status) + ""; + }) + .catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); }); + } + + /* ── Missions longues (J5) ─────────────────────────────────── + * Sélecteur d'exécuteur alimenté par le registre du service : + * les externes non configurés sont montrés mais désactivés — + * jamais un lancement qui simule. La liste se rafraîchit à + * l'ouverture et après chaque lancement/annulation. */ + var STATUTS_MISSION = { + queued: "En file", running: "En cours…", paused_question: "Question en attente", + succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée" + }; + + function ouvrirMissions() { + var bloc = document.createElement("div"); + bloc.className = "draco-missions"; + bloc.innerHTML = + "
Missions longues
" + + "" + + "" + + "" + + "" + + "
" + + "
" + + "
Chargement des missions…
"; + msgsEl.appendChild(bloc); + msgsEl.scrollTop = msgsEl.scrollHeight; + fetch(API + "/api/v1/executors", { credentials: "include" }) + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (d) { + var sel = bloc.querySelector(".m-exec"); + sel.innerHTML = ""; + d.executors.forEach(function (e) { + if (e.family !== "sous-agent" && e.family !== "externe") return; + var opt = document.createElement("option"); + opt.value = e.id; + opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") + + (e.status === "non_configure" ? " — non configuré" : ""); + if (e.status === "non_configure") opt.disabled = true; + sel.appendChild(opt); + }); + if (!sel.options.length) sel.innerHTML = ""; + }) + .catch(function () { bloc.querySelector(".m-exec").innerHTML = ""; }); + bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); }); + bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); }); + chargerMissions(bloc); + } + + function chargerMissions(bloc) { + var zone = bloc.querySelector(".m-liste"); + fetch(API + "/api/v1/missions", { credentials: "include" }) + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (d) { + if (!d.missions.length) { zone.innerHTML = "
Aucune mission pour le moment.
"; return; } + zone.innerHTML = ""; + d.missions.slice(0, 10).forEach(function (m) { + var ligne = document.createElement("div"); + ligne.className = "m-item"; + var rapport = m.result ? "
" + esc(String(m.result).slice(0, 700)) + "
" : ""; + ligne.innerHTML = "
" + esc(m.title) + "" + + "" + esc(STATUTS_MISSION[m.status] || m.status) + "
" + + "
" + esc(m.executor) + + (m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") + + (m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "
" + rapport; + if (m.status === "queued" || m.status === "running" || m.status === "paused_question") { + var btn = document.createElement("button"); + btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler"; + btn.addEventListener("click", function () { + fetch(API + "/api/v1/missions/" + m.id + "/cancel", { + method: "POST", credentials: "include", + headers: { "Content-Type": "application/json" }, body: "{}" + }).then(function () { chargerMissions(bloc); }) + .catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); }); + }); + ligne.appendChild(btn); + } + zone.appendChild(ligne); + }); + }) + .catch(function () { zone.innerHTML = "
Missions : service injoignable.
"; }); + } + + function lancerMission(bloc) { + var executor = bloc.querySelector(".m-exec").value; + var titre = bloc.querySelector(".m-titre").value.trim(); + var prompt = bloc.querySelector(".m-prompt").value.trim(); + var carte = bloc.querySelector(".m-carte").value; + if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; } + if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; } + var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() }; + if (carte) corps.kanbante_number = parseInt(carte, 10); + fetch(API + "/api/v1/missions", { + method: "POST", credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(corps) + }) + .then(function (r) { + if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) { + throw new Error(e && e.detail ? e.detail : "HTTP " + r.status); + }); + return r.json(); + }) + .then(function () { + ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions."); + chargerMissions(bloc); + }) + .catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); }); + } + + function chargerHistorique() { + if (historiqueCharge || !convId) return; + historiqueCharge = true; + fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" }) + .then(function (r) { + if (r.status === 404) { + convId = null; + try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ } + return null; + } + if (!r.ok) return null; + return r.json(); + }) + .then(function (d) { + if (!d || !d.messages || !d.messages.length) return; + var sys = msgsEl.querySelector(".draco-msg.sys"); + d.messages.forEach(function (m) { + var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content); + if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el; + }); + msgsEl.scrollTop = msgsEl.scrollHeight; + }) + .catch(function () { /* historique indisponible : la conversation repart à neuf */ }); + } + + function envoyer(ev) { + ev.preventDefault(); + var texte = inputEl.value.trim(); + if (!texte) return; + inputEl.value = ""; + ajouterMsg("user", texte); + sendBtn.disabled = true; + var attente = ajouterMsg("agent", "Spark réfléchit…"); + fetch(API + "/api/v1/chat", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId }) + }) + .then(function (r) { + if (r.status === 401) { + attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie."; + majEtat("err", "Session partagée non reconnue"); + return null; + } + if (!r.ok) { + return r.json().catch(function () { return null; }).then(function (e) { + attente.textContent = (e && e.detail) ? e.detail : + "Le service a répondu HTTP " + r.status + " — réessaie dans un instant."; + return null; + }); + } + return r.json(); + }) + .then(function (d) { + if (!d) return; + convId = d.conversation_id; + try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ } + attente.textContent = d.reply; + if (d.tools_used && d.tools_used.length) { + ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?")); + } + majEtat("ok", "Connecté au service — domaine " + d.domain); + }) + .catch(function () { + attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie."; + majEtat("err", "Service d'agents injoignable"); + }) + .finally(function () { sendBtn.disabled = false; inputEl.focus(); }); + } + + function ouvrir() { + if (!root) construire(); + root.classList.add("open"); + verifierIdentite(); + chargerHistorique(); + setTimeout(function () { inputEl.focus(); }, 300); + } + function fermer() { if (root) root.classList.remove("open"); } + + window.DRACO_PANNEAU = { + open: ouvrir, + close: fermer, + toggle: function () { + if (root && root.classList.contains("open")) fermer(); else ouvrir(); + } + }; +})();