From e4d4bf7a089b460f99f9ded1d5e303de1b5b5c6a Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:11:01 +0300 Subject: [PATCH] feat(ui): steps 1-3 (trigger / action / params) with live YAML preview --- tools/command_builder/web/app.js | 822 +++++++++++++++++++++++++++ tools/command_builder/web/index.html | 106 ++++ tools/command_builder/web/styles.css | 392 +++++++++++++ 3 files changed, 1320 insertions(+) create mode 100644 tools/command_builder/web/app.js create mode 100644 tools/command_builder/web/index.html create mode 100644 tools/command_builder/web/styles.css 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; +}