feat(ui): steps 1-3 (trigger / action / params) with live YAML preview
This commit is contained in:
parent
232490d9fa
commit
e4d4bf7a08
3 changed files with 1320 additions and 0 deletions
822
tools/command_builder/web/app.js
Normal file
822
tools/command_builder/web/app.js
Normal file
|
|
@ -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 = `<p class="hint">Сначала выберите тип на шаге 2.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (t === "exe") {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.innerHTML = `
|
||||
<div class="field">
|
||||
<label>Файл из custom-commands/</label>
|
||||
<select id="exe-file"><option value="">— выбрать —</option></select>
|
||||
</div>
|
||||
<div class="params-row">
|
||||
<div class="field">
|
||||
<label>Задержка после запуска (мс)</label>
|
||||
<input type="number" id="exe-delay" min="0" step="50" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
<label class="checkbox"><input type="checkbox" id="exe-wait"> ждать завершения</label>
|
||||
<div class="row-hint" id="exe-hint"></div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="field">
|
||||
<label>URL</label>
|
||||
<input type="text" id="url-href" placeholder="https://example.com">
|
||||
</div>
|
||||
`;
|
||||
const inp = f.querySelector("#url-href");
|
||||
if (state.action) inp.value = state.action.href || "";
|
||||
inp.addEventListener("input", () => updateAction());
|
||||
return;
|
||||
}
|
||||
|
||||
if (t === "keys") {
|
||||
f.innerHTML = `
|
||||
<div class="field">
|
||||
<label>Режим</label>
|
||||
<select id="keys-mode">
|
||||
<option value="sequence">Сочетание клавиш</option>
|
||||
<option value="text">Печатать текст</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="keys-seq-field">
|
||||
<label>Сочетание</label>
|
||||
<input type="text" id="keys-sequence" placeholder="ctrl+shift+m">
|
||||
<div class="row-hint">Разделяйте плюсом. Пример: ctrl+alt+delete</div>
|
||||
</div>
|
||||
<div class="field" id="keys-text-field" hidden>
|
||||
<label>Текст</label>
|
||||
<input type="text" id="keys-text" placeholder="Привет, мир">
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="params-row">
|
||||
<div class="field">
|
||||
<label>Имя звука</label>
|
||||
<select id="snd-name"></select>
|
||||
</div>
|
||||
<button type="button" class="ghost" id="snd-preview">прослушать</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="field">
|
||||
<label>Операция</label>
|
||||
<select id="sys-op">
|
||||
<option value="volume_mute">Выключить звук</option>
|
||||
<option value="volume_unmute">Включить звук</option>
|
||||
<option value="exit">Выгрузить Jarvis</option>
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="field">
|
||||
<label>Команда (PowerShell)</label>
|
||||
<textarea id="shell-cmd" rows="3" placeholder="Get-Process | Select-Object -First 5"></textarea>
|
||||
</div>
|
||||
<label class="checkbox"><input type="checkbox" id="shell-wait"> ждать завершения</label>
|
||||
`;
|
||||
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 = `
|
||||
<div class="field">
|
||||
<label>Длительность (мс)</label>
|
||||
<input type="number" id="sleep-ms" min="0" step="50" value="500">
|
||||
</div>
|
||||
<div class="row-hint">Обычно используется внутри последовательности.</div>
|
||||
`;
|
||||
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 = `
|
||||
<p class="hint">Шаги выполняются по порядку.</p>
|
||||
<div class="steps-list" id="steps-list"></div>
|
||||
<button type="button" class="ghost" id="add-step">+ шаг</button>
|
||||
`;
|
||||
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 = `
|
||||
<div class="step-head">
|
||||
<span class="step-label">Шаг ${idx + 1}</span>
|
||||
<div>
|
||||
<button type="button" class="icon-btn" data-up>↑</button>
|
||||
<button type="button" class="icon-btn" data-down>↓</button>
|
||||
<button type="button" class="icon-btn" data-rm>удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
})();
|
||||
106
tools/command_builder/web/index.html
Normal file
106
tools/command_builder/web/index.html
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>J.A.R.V.I.S — конструктор команд</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<h1>Конструктор команд</h1>
|
||||
<div class="steps">
|
||||
<button class="step" data-step="1" type="button"><span>1</span> Фразы</button>
|
||||
<button class="step" data-step="2" type="button"><span>2</span> Действие</button>
|
||||
<button class="step" data-step="3" type="button"><span>3</span> Параметры</button>
|
||||
<button class="step" data-step="4" type="button"><span>4</span> Сохранить</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<section class="panel" data-panel="1">
|
||||
<h2>Голосовые фразы</h2>
|
||||
<p class="hint">Минимум одна фраза. Идентификатор подставится автоматически из первой фразы.</p>
|
||||
<div class="field">
|
||||
<label for="phrase-0">Основная фраза</label>
|
||||
<input type="text" id="phrase-0" data-phrase-input placeholder="например, открой документацию">
|
||||
</div>
|
||||
<div id="extra-phrases"></div>
|
||||
<button type="button" class="ghost" id="add-phrase">+ синоним</button>
|
||||
<div class="field">
|
||||
<label for="cmd-id">Идентификатор команды</label>
|
||||
<input type="text" id="cmd-id" placeholder="auto">
|
||||
<div class="row-hint" id="cmd-id-hint"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" data-go="2">Далее</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="2" hidden>
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab active" data-tab="manual">Вручную</button>
|
||||
<button type="button" class="tab" data-tab="llm">Подсказать (LLM)</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" data-tab-content="manual">
|
||||
<h2>Тип действия</h2>
|
||||
<div class="grid" id="action-grid">
|
||||
<button type="button" class="card" data-action="exe"><span class="card-title">Выполнить .exe</span><span class="card-sub">из custom-commands/</span></button>
|
||||
<button type="button" class="card" data-action="url"><span class="card-title">Открыть сайт</span><span class="card-sub">через системный браузер</span></button>
|
||||
<button type="button" class="card" data-action="keys"><span class="card-title">Нажать клавиши</span><span class="card-sub">или напечатать текст</span></button>
|
||||
<button type="button" class="card" data-action="play_sound"><span class="card-title">Воспроизвести звук</span><span class="card-sub">из набора Jarvis</span></button>
|
||||
<button type="button" class="card" data-action="system"><span class="card-title">Системное действие</span><span class="card-sub">звук, выход</span></button>
|
||||
<button type="button" class="card" data-action="shell"><span class="card-title">PowerShell</span><span class="card-sub">любая shell-команда</span></button>
|
||||
<button type="button" class="card" data-action="sleep"><span class="card-title">Пауза</span><span class="card-sub">только в составе</span></button>
|
||||
<button type="button" class="card" data-action="multi"><span class="card-title">Последовательность</span><span class="card-sub">несколько шагов</span></button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" data-go="1">Назад</button>
|
||||
<button type="button" class="primary" data-go="3" id="to-step-3">Далее</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" data-tab-content="llm" hidden>
|
||||
<h2>Опишите команду на русском</h2>
|
||||
<p class="hint">Модель предложит набор шагов. Их можно отредактировать перед сохранением.</p>
|
||||
<textarea id="llm-prompt" rows="4" placeholder="например, открой ютуб и приглуши звук"></textarea>
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" id="llm-generate">Сгенерировать</button>
|
||||
</div>
|
||||
<div id="llm-status" class="row-hint"></div>
|
||||
<div id="llm-suggestions"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="3" hidden>
|
||||
<h2>Параметры действия</h2>
|
||||
<div id="params-form"></div>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" data-go="2">Назад</button>
|
||||
<button type="button" class="primary" data-go="4">Далее</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" data-panel="4" hidden>
|
||||
<h2>Предпросмотр и сохранение</h2>
|
||||
<div class="field">
|
||||
<label>YAML</label>
|
||||
<pre id="yaml-preview"></pre>
|
||||
</div>
|
||||
<div id="save-status" class="row-hint"></div>
|
||||
<div class="actions">
|
||||
<button type="button" class="ghost" data-go="3">Назад</button>
|
||||
<button type="button" class="primary" id="save-btn">Сохранить</button>
|
||||
<button type="button" class="ghost" id="cancel-btn">Сброс</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/js-yaml.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
392
tools/command_builder/web/styles.css
Normal file
392
tools/command_builder/web/styles.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue