diff --git a/tools/command_builder/web/app.js b/tools/command_builder/web/app.js
new file mode 100644
index 0000000..b25bbd7
--- /dev/null
+++ b/tools/command_builder/web/app.js
@@ -0,0 +1,822 @@
+(function () {
+ const state = {
+ step: 1,
+ phrases: [""],
+ cmdId: "",
+ cmdIdAuto: true,
+ existingIds: [],
+ actionType: null,
+ action: null,
+ confirmSound: null,
+ };
+
+ const els = {
+ steps: document.querySelectorAll(".step"),
+ panels: document.querySelectorAll(".panel"),
+ phrase0: document.getElementById("phrase-0"),
+ extra: document.getElementById("extra-phrases"),
+ addPhrase: document.getElementById("add-phrase"),
+ cmdId: document.getElementById("cmd-id"),
+ cmdIdHint: document.getElementById("cmd-id-hint"),
+ actionGrid: document.getElementById("action-grid"),
+ paramsForm: document.getElementById("params-form"),
+ yamlPreview: document.getElementById("yaml-preview"),
+ saveBtn: document.getElementById("save-btn"),
+ cancelBtn: document.getElementById("cancel-btn"),
+ saveStatus: document.getElementById("save-status"),
+ toast: document.getElementById("toast"),
+ toStep3: document.getElementById("to-step-3"),
+ tabs: document.querySelectorAll(".tab"),
+ tabContents: document.querySelectorAll("[data-tab-content]"),
+ llmPrompt: document.getElementById("llm-prompt"),
+ llmGenerate: document.getElementById("llm-generate"),
+ llmStatus: document.getElementById("llm-status"),
+ llmSuggestions: document.getElementById("llm-suggestions"),
+ };
+
+ function api() {
+ return (window.pywebview && window.pywebview.api) || null;
+ }
+
+ async function callApi(method, ...args) {
+ const a = api();
+ if (!a || typeof a[method] !== "function") {
+ return { ok: false, error: "bridge not ready: " + method };
+ }
+ try {
+ return await a[method](...args);
+ } catch (ex) {
+ return { ok: false, error: String(ex) };
+ }
+ }
+
+ function setStep(n) {
+ state.step = n;
+ els.panels.forEach((p) => {
+ p.hidden = parseInt(p.dataset.panel, 10) !== n;
+ });
+ els.steps.forEach((s) => {
+ s.classList.toggle("active", parseInt(s.dataset.step, 10) === n);
+ });
+ if (n === 4) {
+ renderPreview();
+ }
+ }
+
+ function showToast(msg, isError) {
+ els.toast.textContent = msg;
+ els.toast.classList.toggle("error", !!isError);
+ els.toast.hidden = false;
+ clearTimeout(showToast._t);
+ showToast._t = setTimeout(() => { els.toast.hidden = true; }, 2800);
+ }
+
+ function refreshExtraPhrases() {
+ els.extra.innerHTML = "";
+ for (let i = 1; i < state.phrases.length; i++) {
+ const row = document.createElement("div");
+ row.className = "extra-row";
+ const inp = document.createElement("input");
+ inp.type = "text";
+ inp.value = state.phrases[i];
+ inp.placeholder = "синоним";
+ inp.addEventListener("input", () => {
+ state.phrases[i] = inp.value;
+ });
+ const rm = document.createElement("button");
+ rm.type = "button";
+ rm.textContent = "удалить";
+ rm.addEventListener("click", () => {
+ state.phrases.splice(i, 1);
+ refreshExtraPhrases();
+ });
+ row.appendChild(inp);
+ row.appendChild(rm);
+ els.extra.appendChild(row);
+ }
+ }
+
+ function transliterateSlug(s) {
+ const map = {
+ "а":"a","б":"b","в":"v","г":"g","д":"d","е":"e","ё":"e","ж":"zh",
+ "з":"z","и":"i","й":"y","к":"k","л":"l","м":"m","н":"n","о":"o",
+ "п":"p","р":"r","с":"s","т":"t","у":"u","ф":"f","х":"h","ц":"ts",
+ "ч":"ch","ш":"sh","щ":"sch","ъ":"","ы":"y","ь":"","э":"e","ю":"yu","я":"ya",
+ };
+ let out = "";
+ const lower = (s || "").toLowerCase().trim();
+ for (const ch of lower) {
+ if (map[ch] !== undefined) out += map[ch];
+ else if (/[a-z0-9]/i.test(ch)) out += ch;
+ else if (ch === " " || ch === "_" || ch === "-") out += "_";
+ }
+ out = out.replace(/_+/g, "_").replace(/^_|_$/g, "");
+ return out || "cmd";
+ }
+
+ function checkCmdIdCollision() {
+ if (!state.cmdId) {
+ els.cmdIdHint.className = "row-hint";
+ els.cmdIdHint.textContent = "";
+ return;
+ }
+ if (state.existingIds.includes(state.cmdId)) {
+ els.cmdIdHint.className = "row-hint error";
+ els.cmdIdHint.textContent = `Идентификатор «${state.cmdId}» уже используется`;
+ } else {
+ els.cmdIdHint.className = "row-hint ok";
+ els.cmdIdHint.textContent = "Свободен";
+ }
+ }
+
+ function syncCmdId() {
+ if (state.cmdIdAuto) {
+ state.cmdId = transliterateSlug(state.phrases[0] || "");
+ els.cmdId.value = state.cmdId;
+ }
+ checkCmdIdCollision();
+ }
+
+ function renderActionForm() {
+ const t = state.actionType;
+ const f = els.paramsForm;
+ f.innerHTML = "";
+ if (!t) {
+ f.innerHTML = `
Сначала выберите тип на шаге 2.
`;
+ return;
+ }
+
+ if (t === "exe") {
+ const wrap = document.createElement("div");
+ wrap.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ f.appendChild(wrap);
+ const sel = wrap.querySelector("#exe-file");
+ callApi("list_exes").then((list) => {
+ if (Array.isArray(list)) {
+ for (const name of list) {
+ const o = document.createElement("option");
+ o.value = `${name}.exe`;
+ o.textContent = name;
+ sel.appendChild(o);
+ }
+ if (state.action && state.action.file) sel.value = state.action.file;
+ }
+ });
+ sel.addEventListener("change", () => updateAction());
+ wrap.querySelector("#exe-delay").addEventListener("input", () => updateAction());
+ wrap.querySelector("#exe-wait").addEventListener("change", () => updateAction());
+ if (state.action) {
+ wrap.querySelector("#exe-delay").value = state.action.delay_ms ?? "";
+ wrap.querySelector("#exe-wait").checked = !!state.action.wait;
+ }
+ return;
+ }
+
+ if (t === "url") {
+ f.innerHTML = `
+
+
+
+
+ `;
+ const inp = f.querySelector("#url-href");
+ if (state.action) inp.value = state.action.href || "";
+ inp.addEventListener("input", () => updateAction());
+ return;
+ }
+
+ if (t === "keys") {
+ f.innerHTML = `
+
+
+
+
+
+
+
+
Разделяйте плюсом. Пример: ctrl+alt+delete
+
+
+
+
+
+ `;
+ const mode = f.querySelector("#keys-mode");
+ const seqField = f.querySelector("#keys-seq-field");
+ const textField = f.querySelector("#keys-text-field");
+ const seq = f.querySelector("#keys-sequence");
+ const text = f.querySelector("#keys-text");
+ if (state.action && state.action.text) {
+ mode.value = "text";
+ text.value = state.action.text;
+ seqField.hidden = true;
+ textField.hidden = false;
+ } else if (state.action && state.action.sequence) {
+ seq.value = state.action.sequence;
+ }
+ const sync = () => {
+ if (mode.value === "sequence") {
+ seqField.hidden = false;
+ textField.hidden = true;
+ } else {
+ seqField.hidden = true;
+ textField.hidden = false;
+ }
+ updateAction();
+ };
+ mode.addEventListener("change", sync);
+ seq.addEventListener("input", () => updateAction());
+ text.addEventListener("input", () => updateAction());
+ return;
+ }
+
+ if (t === "play_sound") {
+ f.innerHTML = `
+
+
+
+
+
+
+
+ `;
+ const sel = f.querySelector("#snd-name");
+ callApi("list_sound_names").then((list) => {
+ if (Array.isArray(list)) {
+ for (const name of list) {
+ const o = document.createElement("option");
+ o.value = name;
+ o.textContent = name;
+ sel.appendChild(o);
+ }
+ if (state.action && state.action.name) sel.value = state.action.name;
+ updateAction();
+ }
+ });
+ sel.addEventListener("change", () => updateAction());
+ f.querySelector("#snd-preview").addEventListener("click", () => {
+ callApi("preview_sound", sel.value);
+ });
+ return;
+ }
+
+ if (t === "system") {
+ f.innerHTML = `
+
+
+
+
+ `;
+ const sel = f.querySelector("#sys-op");
+ if (state.action && state.action.op) sel.value = state.action.op;
+ sel.addEventListener("change", () => updateAction());
+ return;
+ }
+
+ if (t === "shell") {
+ f.innerHTML = `
+
+
+
+
+
+ `;
+ const cmd = f.querySelector("#shell-cmd");
+ const wait = f.querySelector("#shell-wait");
+ if (state.action) {
+ cmd.value = state.action.cmd || "";
+ wait.checked = !!state.action.wait;
+ }
+ cmd.addEventListener("input", () => updateAction());
+ wait.addEventListener("change", () => updateAction());
+ return;
+ }
+
+ if (t === "sleep") {
+ f.innerHTML = `
+
+
+
+
+ Обычно используется внутри последовательности.
+ `;
+ const inp = f.querySelector("#sleep-ms");
+ if (state.action && state.action.ms != null) inp.value = state.action.ms;
+ inp.addEventListener("input", () => updateAction());
+ return;
+ }
+
+ if (t === "multi") {
+ renderMultiForm();
+ return;
+ }
+ }
+
+ function renderMultiForm() {
+ const f = els.paramsForm;
+ f.innerHTML = `
+ Шаги выполняются по порядку.
+
+
+ `;
+ const list = f.querySelector("#steps-list");
+ if (!state.action || state.action.type !== "multi") {
+ state.action = { type: "multi", steps: [] };
+ }
+ const renderList = () => {
+ list.innerHTML = "";
+ state.action.steps.forEach((step, idx) => {
+ const card = document.createElement("div");
+ card.className = "step-card";
+ card.innerHTML = `
+
+
Шаг ${idx + 1}
+
+
+
+
+
+
+ `;
+ const body = document.createElement("div");
+ renderInlineEditor(body, step, (next) => {
+ state.action.steps[idx] = next;
+ updateAction(true);
+ });
+ card.appendChild(body);
+ card.querySelector("[data-up]").addEventListener("click", () => {
+ if (idx > 0) {
+ const tmp = state.action.steps[idx - 1];
+ state.action.steps[idx - 1] = state.action.steps[idx];
+ state.action.steps[idx] = tmp;
+ renderList();
+ updateAction(true);
+ }
+ });
+ card.querySelector("[data-down]").addEventListener("click", () => {
+ if (idx < state.action.steps.length - 1) {
+ const tmp = state.action.steps[idx + 1];
+ state.action.steps[idx + 1] = state.action.steps[idx];
+ state.action.steps[idx] = tmp;
+ renderList();
+ updateAction(true);
+ }
+ });
+ card.querySelector("[data-rm]").addEventListener("click", () => {
+ state.action.steps.splice(idx, 1);
+ renderList();
+ updateAction(true);
+ });
+ list.appendChild(card);
+ });
+ };
+ renderList();
+ f.querySelector("#add-step").addEventListener("click", () => {
+ state.action.steps.push({ type: "play_sound", name: "ok" });
+ renderList();
+ updateAction(true);
+ });
+ updateAction(true);
+ }
+
+ function renderInlineEditor(host, step, onChange) {
+ const typeSel = document.createElement("select");
+ const types = [
+ ["play_sound", "Звук"],
+ ["exe", ".exe"],
+ ["url", "URL"],
+ ["keys", "Клавиши"],
+ ["shell", "Shell"],
+ ["system", "Система"],
+ ["sleep", "Пауза"],
+ ];
+ for (const [v, label] of types) {
+ const o = document.createElement("option");
+ o.value = v;
+ o.textContent = label;
+ if (v === step.type) o.selected = true;
+ typeSel.appendChild(o);
+ }
+ host.appendChild(wrapField("Тип", typeSel));
+
+ const body = document.createElement("div");
+ host.appendChild(body);
+
+ const renderBody = () => {
+ body.innerHTML = "";
+ const t = typeSel.value;
+ if (t === "play_sound") {
+ const sel = document.createElement("select");
+ callApi("list_sound_names").then((list) => {
+ if (Array.isArray(list)) {
+ for (const name of list) {
+ const o = document.createElement("option");
+ o.value = name;
+ o.textContent = name;
+ if (step.name === name) o.selected = true;
+ sel.appendChild(o);
+ }
+ }
+ });
+ sel.addEventListener("change", () => onChange({ type: "play_sound", name: sel.value }));
+ body.appendChild(wrapField("Звук", sel));
+ } else if (t === "exe") {
+ const sel = document.createElement("select");
+ callApi("list_exes").then((list) => {
+ if (Array.isArray(list)) {
+ for (const name of list) {
+ const o = document.createElement("option");
+ o.value = `${name}.exe`;
+ o.textContent = name;
+ if (step.file === `${name}.exe`) o.selected = true;
+ sel.appendChild(o);
+ }
+ }
+ });
+ const wait = document.createElement("input");
+ wait.type = "checkbox";
+ wait.checked = !!step.wait;
+ const waitLabel = document.createElement("label");
+ waitLabel.className = "checkbox";
+ waitLabel.appendChild(wait);
+ waitLabel.appendChild(document.createTextNode("ждать"));
+ const delay = document.createElement("input");
+ delay.type = "number";
+ delay.value = step.delay_ms ?? "";
+ delay.placeholder = "delay_ms";
+ const apply = () => {
+ const next = { type: "exe", file: sel.value };
+ if (wait.checked) next.wait = true;
+ if (delay.value) next.delay_ms = parseInt(delay.value, 10);
+ onChange(next);
+ };
+ sel.addEventListener("change", apply);
+ wait.addEventListener("change", apply);
+ delay.addEventListener("input", apply);
+ body.appendChild(wrapField("Файл", sel));
+ body.appendChild(wrapField("Задержка (мс)", delay));
+ body.appendChild(waitLabel);
+ } else if (t === "url") {
+ const inp = document.createElement("input");
+ inp.type = "text";
+ inp.value = step.href || "";
+ inp.placeholder = "https://...";
+ inp.addEventListener("input", () => onChange({ type: "url", href: inp.value }));
+ body.appendChild(wrapField("URL", inp));
+ } else if (t === "keys") {
+ const inp = document.createElement("input");
+ inp.type = "text";
+ inp.value = step.sequence || "";
+ inp.placeholder = "ctrl+shift+m";
+ inp.addEventListener("input", () => onChange({ type: "keys", sequence: inp.value }));
+ body.appendChild(wrapField("Сочетание", inp));
+ } else if (t === "shell") {
+ const inp = document.createElement("input");
+ inp.type = "text";
+ inp.value = step.cmd || "";
+ const wait = document.createElement("input");
+ wait.type = "checkbox";
+ wait.checked = !!step.wait;
+ const waitLabel = document.createElement("label");
+ waitLabel.className = "checkbox";
+ waitLabel.appendChild(wait);
+ waitLabel.appendChild(document.createTextNode("ждать"));
+ const apply = () => {
+ const next = { type: "shell", cmd: inp.value };
+ if (wait.checked) next.wait = true;
+ onChange(next);
+ };
+ inp.addEventListener("input", apply);
+ wait.addEventListener("change", apply);
+ body.appendChild(wrapField("Команда", inp));
+ body.appendChild(waitLabel);
+ } else if (t === "system") {
+ const sel = document.createElement("select");
+ for (const op of ["volume_mute", "volume_unmute", "exit"]) {
+ const o = document.createElement("option");
+ o.value = op;
+ o.textContent = op;
+ if (step.op === op) o.selected = true;
+ sel.appendChild(o);
+ }
+ sel.addEventListener("change", () => onChange({ type: "system", op: sel.value }));
+ body.appendChild(wrapField("Операция", sel));
+ } else if (t === "sleep") {
+ const inp = document.createElement("input");
+ inp.type = "number";
+ inp.value = step.ms ?? 500;
+ inp.addEventListener("input", () => onChange({ type: "sleep", ms: parseInt(inp.value, 10) || 0 }));
+ body.appendChild(wrapField("Миллисекунды", inp));
+ }
+ };
+ typeSel.addEventListener("change", () => {
+ const t = typeSel.value;
+ const fresh = ({
+ play_sound: { type: "play_sound", name: "ok" },
+ exe: { type: "exe", file: "" },
+ url: { type: "url", href: "" },
+ keys: { type: "keys", sequence: "" },
+ shell: { type: "shell", cmd: "" },
+ system: { type: "system", op: "volume_mute" },
+ sleep: { type: "sleep", ms: 500 },
+ })[t];
+ onChange(fresh);
+ step = fresh;
+ renderBody();
+ });
+ renderBody();
+ }
+
+ function wrapField(label, control) {
+ const w = document.createElement("div");
+ w.className = "field";
+ const l = document.createElement("label");
+ l.textContent = label;
+ w.appendChild(l);
+ w.appendChild(control);
+ return w;
+ }
+
+ function updateAction(skipRender) {
+ const t = state.actionType;
+ if (!t) return;
+ if (t === "exe") {
+ const file = document.getElementById("exe-file");
+ const delay = document.getElementById("exe-delay");
+ const wait = document.getElementById("exe-wait");
+ const a = { type: "exe", file: file ? file.value : "" };
+ if (wait && wait.checked) a.wait = true;
+ if (delay && delay.value) a.delay_ms = parseInt(delay.value, 10);
+ state.action = a;
+ } else if (t === "url") {
+ state.action = { type: "url", href: (document.getElementById("url-href")||{}).value || "" };
+ } else if (t === "keys") {
+ const mode = (document.getElementById("keys-mode")||{}).value;
+ if (mode === "text") {
+ state.action = { type: "keys", text: (document.getElementById("keys-text")||{}).value || "" };
+ } else {
+ state.action = { type: "keys", sequence: (document.getElementById("keys-sequence")||{}).value || "" };
+ }
+ } else if (t === "play_sound") {
+ state.action = { type: "play_sound", name: (document.getElementById("snd-name")||{}).value || "ok" };
+ } else if (t === "system") {
+ state.action = { type: "system", op: (document.getElementById("sys-op")||{}).value || "volume_mute" };
+ } else if (t === "shell") {
+ const a = { type: "shell", cmd: (document.getElementById("shell-cmd")||{}).value || "" };
+ if ((document.getElementById("shell-wait")||{}).checked) a.wait = true;
+ state.action = a;
+ } else if (t === "sleep") {
+ state.action = { type: "sleep", ms: parseInt((document.getElementById("sleep-ms")||{}).value || "0", 10) };
+ }
+ }
+
+ function buildDraft() {
+ return {
+ cmd_id: state.cmdId,
+ phrases: state.phrases.map((p) => p.trim()).filter(Boolean),
+ action: state.action,
+ confirm_sound: state.confirmSound || undefined,
+ };
+ }
+
+ function renderPreview() {
+ const draft = buildDraft();
+ if (!draft.cmd_id || !draft.phrases.length || !draft.action) {
+ els.yamlPreview.textContent = "(черновик неполный)";
+ return;
+ }
+ const body = { phrases: draft.phrases, action: draft.action };
+ if (draft.confirm_sound) body.confirm_sound = draft.confirm_sound;
+ const obj = {};
+ obj[draft.cmd_id] = body;
+ try {
+ els.yamlPreview.textContent = jsyaml.dump(obj, { lineWidth: 4096, noRefs: true });
+ } catch (ex) {
+ els.yamlPreview.textContent = "Ошибка YAML: " + ex.message;
+ }
+ }
+
+ async function refreshExistingIds() {
+ const r = await callApi("list_existing_commands");
+ if (Array.isArray(r)) {
+ state.existingIds = r;
+ checkCmdIdCollision();
+ }
+ }
+
+ function bind() {
+ els.phrase0.addEventListener("input", () => {
+ state.phrases[0] = els.phrase0.value;
+ syncCmdId();
+ });
+ els.addPhrase.addEventListener("click", () => {
+ state.phrases.push("");
+ refreshExtraPhrases();
+ });
+ els.cmdId.addEventListener("input", () => {
+ state.cmdIdAuto = false;
+ state.cmdId = els.cmdId.value.trim();
+ checkCmdIdCollision();
+ });
+
+ els.actionGrid.addEventListener("click", (ev) => {
+ const card = ev.target.closest(".card");
+ if (!card) return;
+ const t = card.dataset.action;
+ els.actionGrid.querySelectorAll(".card").forEach((c) => c.classList.toggle("selected", c === card));
+ if (state.actionType !== t) {
+ state.actionType = t;
+ state.action = null;
+ }
+ });
+
+ els.toStep3.addEventListener("click", () => {
+ if (!state.actionType) {
+ showToast("Выберите тип действия", true);
+ return;
+ }
+ renderActionForm();
+ setStep(3);
+ });
+
+ document.querySelectorAll("[data-go]").forEach((b) => {
+ if (b.id === "to-step-3") return;
+ b.addEventListener("click", () => setStep(parseInt(b.dataset.go, 10)));
+ });
+
+ els.steps.forEach((s) => {
+ s.addEventListener("click", () => {
+ const target = parseInt(s.dataset.step, 10);
+ if (target === 3) renderActionForm();
+ setStep(target);
+ });
+ });
+
+ els.tabs.forEach((t) => {
+ t.addEventListener("click", () => {
+ els.tabs.forEach((x) => x.classList.toggle("active", x === t));
+ els.tabContents.forEach((c) => {
+ c.hidden = c.dataset.tabContent !== t.dataset.tab;
+ });
+ });
+ });
+
+ els.llmGenerate.addEventListener("click", async () => {
+ els.llmStatus.className = "row-hint";
+ els.llmStatus.textContent = "Запрос к модели...";
+ els.llmSuggestions.innerHTML = "";
+ const r = await callApi("llm_suggest", els.llmPrompt.value || "");
+ if (!r || !r.ok) {
+ els.llmStatus.className = "row-hint error";
+ els.llmStatus.textContent = (r && r.error) || "Не удалось получить ответ";
+ return;
+ }
+ els.llmStatus.className = "row-hint ok";
+ els.llmStatus.textContent = "Готово. Примите блок или отредактируйте вручную.";
+ renderLlmSuggestion(r.suggestion);
+ });
+
+ els.saveBtn.addEventListener("click", async () => {
+ const draft = buildDraft();
+ const v = await callApi("validate_draft", JSON.stringify(draft));
+ if (!v.ok) {
+ els.saveStatus.className = "row-hint error";
+ els.saveStatus.textContent = v.error;
+ return;
+ }
+ const overwrite = state.existingIds.includes(draft.cmd_id);
+ if (overwrite) {
+ if (!confirm(`Команда «${draft.cmd_id}» уже существует. Перезаписать?`)) return;
+ draft.overwrite = true;
+ }
+ const r = await callApi("save_command", JSON.stringify(draft));
+ if (!r.ok) {
+ els.saveStatus.className = "row-hint error";
+ els.saveStatus.textContent = r.error;
+ return;
+ }
+ els.saveStatus.className = "row-hint ok";
+ els.saveStatus.textContent = `Сохранено: ${r.id}`;
+ showToast(`Команда «${r.id}» сохранена`);
+ refreshExistingIds();
+ });
+
+ els.cancelBtn.addEventListener("click", () => {
+ if (!confirm("Сбросить черновик?")) return;
+ resetDraft();
+ });
+
+ document.addEventListener("keydown", (ev) => {
+ if (ev.key === "Escape") {
+ if (state.step > 1) setStep(state.step - 1);
+ }
+ });
+ }
+
+ function renderLlmSuggestion(suggestion) {
+ const host = els.llmSuggestions;
+ host.innerHTML = "";
+ if (!suggestion) return;
+ const block = document.createElement("div");
+ block.className = "suggested-block";
+ const pre = document.createElement("pre");
+ try {
+ pre.textContent = jsyaml.dump(suggestion, { lineWidth: 4096, noRefs: true });
+ } catch (ex) {
+ pre.textContent = JSON.stringify(suggestion, null, 2);
+ }
+ block.appendChild(pre);
+ const accept = document.createElement("button");
+ accept.type = "button";
+ accept.className = "primary";
+ accept.textContent = "Принять";
+ accept.addEventListener("click", () => {
+ if (suggestion.phrases) state.phrases = suggestion.phrases.slice();
+ if (suggestion.cmd_id) {
+ state.cmdId = suggestion.cmd_id;
+ state.cmdIdAuto = false;
+ els.cmdId.value = state.cmdId;
+ } else {
+ syncCmdId();
+ }
+ els.phrase0.value = state.phrases[0] || "";
+ refreshExtraPhrases();
+ if (suggestion.action) {
+ state.actionType = suggestion.action.type;
+ state.action = suggestion.action;
+ els.actionGrid.querySelectorAll(".card").forEach((c) => {
+ c.classList.toggle("selected", c.dataset.action === state.actionType);
+ });
+ els.tabs.forEach((x) => x.classList.toggle("active", x.dataset.tab === "manual"));
+ els.tabContents.forEach((c) => { c.hidden = c.dataset.tabContent !== "manual"; });
+ renderActionForm();
+ }
+ setStep(3);
+ });
+ const discard = document.createElement("button");
+ discard.type = "button";
+ discard.className = "ghost";
+ discard.textContent = "Отклонить";
+ discard.addEventListener("click", () => { host.innerHTML = ""; });
+ const actions = document.createElement("div");
+ actions.className = "actions";
+ actions.appendChild(discard);
+ actions.appendChild(accept);
+ block.appendChild(actions);
+ host.appendChild(block);
+ }
+
+ function resetDraft() {
+ state.phrases = [""];
+ state.cmdId = "";
+ state.cmdIdAuto = true;
+ state.actionType = null;
+ state.action = null;
+ state.confirmSound = null;
+ els.phrase0.value = "";
+ els.cmdId.value = "";
+ els.cmdIdHint.textContent = "";
+ refreshExtraPhrases();
+ els.actionGrid.querySelectorAll(".card").forEach((c) => c.classList.remove("selected"));
+ els.paramsForm.innerHTML = "";
+ els.yamlPreview.textContent = "";
+ els.saveStatus.textContent = "";
+ setStep(1);
+ }
+
+ function init() {
+ bind();
+ refreshExtraPhrases();
+ setStep(1);
+ const tryReady = () => {
+ if (api()) {
+ refreshExistingIds();
+ } else {
+ setTimeout(tryReady, 80);
+ }
+ };
+ tryReady();
+ }
+
+ document.addEventListener("DOMContentLoaded", init);
+})();
diff --git a/tools/command_builder/web/index.html b/tools/command_builder/web/index.html
new file mode 100644
index 0000000..b1da05b
--- /dev/null
+++ b/tools/command_builder/web/index.html
@@ -0,0 +1,106 @@
+
+
+
+
+ J.A.R.V.I.S — конструктор команд
+
+
+
+
+
+
+
+
+ Голосовые фразы
+ Минимум одна фраза. Идентификатор подставится автоматически из первой фразы.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Тип действия
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Опишите команду на русском
+
Модель предложит набор шагов. Их можно отредактировать перед сохранением.
+
+
+
+
+
+
+
+
+
+
+ Параметры действия
+
+
+
+
+
+
+
+
+ Предпросмотр и сохранение
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/command_builder/web/styles.css b/tools/command_builder/web/styles.css
new file mode 100644
index 0000000..b1f6b57
--- /dev/null
+++ b/tools/command_builder/web/styles.css
@@ -0,0 +1,392 @@
+:root {
+ --bg: #0a1214;
+ --bg-elev: #0f1a1d;
+ --bg-elev-2: #142327;
+ --border: #1d3036;
+ --border-strong: #294349;
+ --text: #d6e4e6;
+ --text-dim: #8aa1a5;
+ --accent: #52fefe;
+ --accent-dim: #2cb8b8;
+ --danger: #ff6b6b;
+ --ok: #6bf2a4;
+ --radius: 6px;
+ --radius-lg: 8px;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: "Inter", "Segoe UI", system-ui, sans-serif;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+.topbar {
+ padding: 18px 24px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-elev);
+}
+
+.topbar h1 {
+ margin: 0 0 14px 0;
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+ color: var(--accent);
+}
+
+.steps {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.step {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ border-radius: var(--radius);
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border);
+ color: var(--text-dim);
+ font-size: 12px;
+ cursor: pointer;
+ font-family: inherit;
+}
+
+.step span {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--border);
+ color: var(--text);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.step.active {
+ border-color: var(--accent-dim);
+ color: var(--text);
+}
+
+.step.active span {
+ background: var(--accent);
+ color: #062023;
+}
+
+.main {
+ flex: 1;
+ padding: 22px 24px 32px;
+ max-width: 880px;
+ width: 100%;
+ margin: 0 auto;
+}
+
+.panel h2 {
+ margin: 0 0 6px 0;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.hint {
+ margin: 0 0 18px 0;
+ color: var(--text-dim);
+ font-size: 12.5px;
+}
+
+.field {
+ margin-bottom: 14px;
+}
+
+.field label {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--text-dim);
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+input[type="text"],
+input[type="number"],
+textarea,
+select {
+ width: 100%;
+ padding: 9px 11px;
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-family: inherit;
+ font-size: 13.5px;
+ outline: none;
+}
+
+input[type="text"]:focus,
+input[type="number"]:focus,
+textarea:focus,
+select:focus {
+ border-color: var(--accent-dim);
+}
+
+textarea {
+ resize: vertical;
+}
+
+.row-hint {
+ margin-top: 6px;
+ font-size: 11.5px;
+ color: var(--text-dim);
+ min-height: 14px;
+}
+
+.row-hint.error {
+ color: var(--danger);
+}
+
+.row-hint.ok {
+ color: var(--ok);
+}
+
+.actions {
+ margin-top: 20px;
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+}
+
+button {
+ cursor: pointer;
+ border: none;
+ border-radius: var(--radius);
+ padding: 9px 16px;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ transition: background 80ms ease, border-color 80ms ease;
+}
+
+.primary {
+ background: var(--accent);
+ color: #062023;
+}
+
+.primary:hover {
+ background: #7affff;
+}
+
+.primary:disabled {
+ background: var(--border);
+ color: var(--text-dim);
+ cursor: not-allowed;
+}
+
+.ghost {
+ background: transparent;
+ border: 1px solid var(--border-strong);
+ color: var(--text);
+}
+
+.ghost:hover {
+ border-color: var(--accent-dim);
+}
+
+#extra-phrases .extra-row {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+
+#extra-phrases .extra-row input {
+ flex: 1;
+}
+
+#extra-phrases .extra-row button {
+ background: transparent;
+ border: 1px solid var(--border-strong);
+ color: var(--text-dim);
+ padding: 6px 10px;
+ font-size: 12px;
+}
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 18px;
+}
+
+.tab {
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+ padding: 8px 14px;
+ color: var(--text-dim);
+ font-size: 13px;
+}
+
+.tab.active {
+ color: var(--text);
+ border-bottom-color: var(--accent);
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 10px;
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 14px;
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ color: var(--text);
+ text-align: left;
+ min-height: 70px;
+}
+
+.card:hover {
+ border-color: var(--accent-dim);
+}
+
+.card.selected {
+ border-color: var(--accent);
+ background: var(--bg-elev-2);
+}
+
+.card-title {
+ font-weight: 600;
+ font-size: 13.5px;
+}
+
+.card-sub {
+ font-size: 11.5px;
+ color: var(--text-dim);
+}
+
+pre {
+ background: #06101294;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 12px 14px;
+ overflow-x: auto;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 12.5px;
+ color: #c4ecec;
+ white-space: pre-wrap;
+}
+
+.toast {
+ position: fixed;
+ right: 22px;
+ bottom: 22px;
+ padding: 10px 16px;
+ background: var(--bg-elev-2);
+ border: 1px solid var(--accent-dim);
+ border-radius: var(--radius);
+ color: var(--accent);
+ font-size: 13px;
+ box-shadow: 0 4px 14px rgba(0,0,0,0.45);
+}
+
+.toast.error {
+ border-color: var(--danger);
+ color: var(--danger);
+}
+
+.params-row {
+ display: flex;
+ gap: 10px;
+ align-items: flex-end;
+ flex-wrap: wrap;
+}
+
+.params-row .field {
+ flex: 1;
+ min-width: 200px;
+ margin-bottom: 0;
+}
+
+.checkbox {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--text-dim);
+ font-size: 12.5px;
+ margin-top: 6px;
+ cursor: pointer;
+}
+
+.checkbox input {
+ accent-color: var(--accent);
+}
+
+.steps-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.step-card {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 12px;
+}
+
+.step-card .step-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.step-card .step-head .step-label {
+ font-size: 12px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+.step-card .step-head .icon-btn {
+ background: transparent;
+ border: 1px solid var(--border-strong);
+ color: var(--text-dim);
+ padding: 3px 8px;
+ font-size: 11px;
+}
+
+.suggested-block {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-bottom: 10px;
+}
+
+.suggested-block pre {
+ margin: 6px 0;
+ font-size: 12px;
+}