Major refactoring cleanup: - Add new controller architecture (class-controller-*.php) - Add new settings-v2 UI (views/settings-v2/) - Add new CSS architecture (agentic-sidebar.css, tokens) - Add esbuild build pipeline (scripts/build.js, package.json) - Add composer dependencies (vendor/) - Add frontend src directory (assets/js/src/index.jsx) - Add documentation files - Remove old/obsolete files (class-settings.php, old CSS) This commits all pending changes from previous refactoring efforts.
600 lines
19 KiB
JavaScript
600 lines
19 KiB
JavaScript
(function ($) {
|
|
"use strict";
|
|
|
|
const qs = (selector, root = document) => root.querySelector(selector);
|
|
const qsa = (selector, root = document) =>
|
|
Array.from(root.querySelectorAll(selector));
|
|
const config = window.wpawSettingsV2 || {};
|
|
|
|
function showToast(message, type = "success") {
|
|
const toast = qs("#wpaw2-toast");
|
|
const body = qs("#wpaw2-toast-message");
|
|
if (!toast || !body) return;
|
|
body.textContent = message;
|
|
toast.className = "toast show toast-" + type;
|
|
clearTimeout(toast._timer);
|
|
toast._timer = setTimeout(() => {
|
|
toast.classList.remove("show");
|
|
}, 2600);
|
|
}
|
|
|
|
function bindToastHover() {
|
|
const toast = qs("#wpaw2-toast");
|
|
if (!toast) return;
|
|
toast.addEventListener("mouseenter", () => {
|
|
clearTimeout(toast._timer);
|
|
});
|
|
toast.addEventListener("mouseleave", () => {
|
|
toast._timer = setTimeout(() => {
|
|
toast.classList.remove("show");
|
|
}, 1200);
|
|
});
|
|
}
|
|
|
|
function activateTab(target) {
|
|
qsa("[data-aw2-tab]").forEach((button) => {
|
|
const active = button.dataset.aw2Tab === target;
|
|
button.setAttribute("aria-selected", active ? "true" : "false");
|
|
});
|
|
qsa(".tab-panel").forEach((panel) => {
|
|
panel.classList.toggle("active", panel.id === target);
|
|
});
|
|
const crumb = qs("#wpaw2-crumb-tab");
|
|
const activeButton = qs(`[data-aw2-tab="${target}"]`);
|
|
if (crumb && activeButton)
|
|
crumb.textContent =
|
|
activeButton.dataset.label || activeButton.textContent.trim();
|
|
}
|
|
|
|
function bindTabs() {
|
|
qsa("[data-aw2-tab]").forEach((button) => {
|
|
button.addEventListener("click", () =>
|
|
activateTab(button.dataset.aw2Tab),
|
|
);
|
|
});
|
|
|
|
qsa("[data-aw2-subtab-target]").forEach((button) => {
|
|
button.addEventListener("click", (e) => {
|
|
const target = e.currentTarget.dataset.aw2SubtabTarget;
|
|
const nav = e.currentTarget.closest(".subtab-nav");
|
|
const panelContainer = nav.parentElement;
|
|
|
|
nav.querySelectorAll("[data-aw2-subtab-target]").forEach((btn) => {
|
|
btn.setAttribute(
|
|
"aria-selected",
|
|
btn === e.currentTarget ? "true" : "false",
|
|
);
|
|
});
|
|
|
|
panelContainer.querySelectorAll(".subtab-panel").forEach((panel) => {
|
|
if (panel.id === target) {
|
|
panel.classList.add("active");
|
|
} else {
|
|
panel.classList.remove("active");
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function bindPasswordToggles() {
|
|
qsa("[data-aw2-toggle-password]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const input = qs(button.dataset.aw2TogglePassword);
|
|
if (!input) return;
|
|
const isPassword = input.type === "password";
|
|
input.type = isPassword ? "text" : "password";
|
|
button.textContent = isPassword ? "Hide" : "Show";
|
|
});
|
|
});
|
|
}
|
|
|
|
function bindCopyButtons() {
|
|
qsa("[data-aw2-copy]").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
const source = qs(button.dataset.aw2Copy);
|
|
if (!source) return;
|
|
try {
|
|
await navigator.clipboard.writeText(source.textContent.trim());
|
|
showToast("Copied command");
|
|
} catch (error) {
|
|
showToast("Copy failed", "error");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function getModelsForType(type) {
|
|
const bucket =
|
|
config.models?.[type] ||
|
|
config.models?.execution ||
|
|
config.models?.planning ||
|
|
{};
|
|
const all = Array.isArray(bucket.all) ? bucket.all : [];
|
|
const recommended = Array.isArray(bucket.recommended)
|
|
? bucket.recommended
|
|
: [];
|
|
const merged = [...all];
|
|
recommended.forEach((model) => {
|
|
if (!merged.find((item) => item.id === model.id)) merged.push(model);
|
|
});
|
|
return merged;
|
|
}
|
|
|
|
function modelTypeFromSelect(select) {
|
|
const match = select.name.match(/\[(.+?)_model\]/);
|
|
return match ? match[1] : "writing";
|
|
}
|
|
|
|
function formatModelData(models) {
|
|
return models.map((model) => ({
|
|
id: model.id,
|
|
text: model.name || model.id,
|
|
pricing: model.pricing || {},
|
|
is_free: Boolean(model.is_free),
|
|
is_custom: Boolean(model.is_custom),
|
|
}));
|
|
}
|
|
|
|
function formatModelOption(model) {
|
|
if (!model.id) return model.text;
|
|
const $row = $('<div class="wpaw2-model-option"></div>');
|
|
const $name = $('<span class="wpaw2-model-name"></span>').text(model.text);
|
|
$row.append($name);
|
|
|
|
if (model.is_custom) {
|
|
$row.append('<span class="wpaw2-model-badge">Custom</span>');
|
|
} else if (model.is_free) {
|
|
$row.append('<span class="wpaw2-model-badge free">Free</span>');
|
|
} else {
|
|
const prompt = parseFloat(model.pricing?.prompt) || 0;
|
|
const image = parseFloat(model.pricing?.image) || 0;
|
|
const price = image > 0 ? image : prompt;
|
|
if (price > 0)
|
|
$row.append(
|
|
$('<span class="wpaw2-model-price"></span>').text(
|
|
`$${(price * 1000000).toFixed(2)}/1M`,
|
|
),
|
|
);
|
|
}
|
|
|
|
return $row;
|
|
}
|
|
|
|
function initSelect2() {
|
|
if (!$.fn.select2) return;
|
|
|
|
const $languageSelect = $("#preferred_languages");
|
|
if ($languageSelect.length) {
|
|
if ($languageSelect.data("select2")) {
|
|
$languageSelect.select2("destroy");
|
|
}
|
|
$languageSelect.select2({
|
|
width: "100%",
|
|
placeholder: "Select preferred languages...",
|
|
dropdownCssClass: "wpaw2-select2-dropdown",
|
|
});
|
|
}
|
|
|
|
qsa(".wpaw2-model-select").forEach((select) => {
|
|
const type = modelTypeFromSelect(select);
|
|
const models = getModelsForType(type);
|
|
const currentValue = select.value || config.currentModels?.[type] || "";
|
|
const $select = $(select);
|
|
|
|
if ($select.data("select2")) {
|
|
$select.select2("destroy");
|
|
}
|
|
|
|
$select.empty().select2({
|
|
width: "100%",
|
|
data: formatModelData(models),
|
|
placeholder: config.i18n?.searchPlaceholder || "Search models...",
|
|
allowClear: true,
|
|
dropdownCssClass: "wpaw2-select2-dropdown",
|
|
templateResult: formatModelOption,
|
|
templateSelection: (model) => model.text || model.id,
|
|
language: {
|
|
noResults: () => config.i18n?.noResults || "No models found",
|
|
},
|
|
});
|
|
|
|
if (currentValue) {
|
|
const model = models.find((item) => item.id === currentValue);
|
|
const option = new Option(
|
|
model?.name || currentValue,
|
|
currentValue,
|
|
true,
|
|
true,
|
|
);
|
|
$select.append(option).trigger("change");
|
|
}
|
|
});
|
|
}
|
|
|
|
function setSelectValue(select, value) {
|
|
if (!select || !value) return;
|
|
let option = Array.from(select.options).find(
|
|
(item) => item.value === value,
|
|
);
|
|
if (!option) {
|
|
option = new Option(value, value, true, true);
|
|
select.add(option);
|
|
}
|
|
select.value = value;
|
|
if ($.fn.select2 && $(select).data("select2")) $(select).trigger("change");
|
|
else select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
|
|
function bindPresetCards() {
|
|
const presets = config.presets || {};
|
|
qsa("[data-aw2-preset]").forEach((card) => {
|
|
card.addEventListener("click", () => {
|
|
const preset = presets[card.dataset.aw2Preset];
|
|
if (!preset) return;
|
|
Object.entries(preset).forEach(([key, value]) => {
|
|
setSelectValue(
|
|
qs(`[name="wp_agentic_writer_settings[${key}_model]"]`),
|
|
value,
|
|
);
|
|
});
|
|
qsa("[data-aw2-preset]").forEach((item) =>
|
|
item.classList.remove("active"),
|
|
);
|
|
card.classList.add("active");
|
|
updateEstimate();
|
|
showToast("Preset applied");
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateEstimate() {
|
|
const writing =
|
|
qs('[name="wp_agentic_writer_settings[writing_model]"]')?.value || "";
|
|
const image =
|
|
qs('[name="wp_agentic_writer_settings[image_model]"]')?.value || "";
|
|
let estimate = 0.14;
|
|
const text = `${writing} ${image}`.toLowerCase();
|
|
if (text.includes("mistral") || text.includes("flash")) estimate = 0.06;
|
|
if (
|
|
text.includes("gpt-4.1") ||
|
|
text.includes("opus") ||
|
|
text.includes("premium")
|
|
)
|
|
estimate = 0.31;
|
|
const output = qs("#wpaw2-cost-estimate");
|
|
if (output) output.textContent = `~$${estimate.toFixed(2)}`;
|
|
}
|
|
|
|
function bindEstimateInputs() {
|
|
qsa(".wpaw2-model-select").forEach((select) =>
|
|
select.addEventListener("change", updateEstimate),
|
|
);
|
|
updateEstimate();
|
|
}
|
|
|
|
function bindTogglePanels() {
|
|
qsa("[data-aw2-toggle-panel]").forEach((input) => {
|
|
const panel = qs(input.dataset.aw2TogglePanel);
|
|
if (!panel) return;
|
|
const sync = () => {
|
|
panel.hidden = !input.checked;
|
|
};
|
|
input.addEventListener("change", sync);
|
|
sync();
|
|
});
|
|
}
|
|
|
|
function bindCustomModels() {
|
|
const list = qs("#wpaw2-custom-models");
|
|
const add = qs("#wpaw2-add-custom-model");
|
|
if (!list || !add) return;
|
|
add.addEventListener("click", () => {
|
|
const row = document.createElement("div");
|
|
row.className = "custom-row";
|
|
row.innerHTML =
|
|
'<input class="field-control" type="text" data-field="id" placeholder="provider/model-id"><input class="field-control" type="text" data-field="name" placeholder="Display name"><select class="field-control" data-field="type"><option value="text">Text</option><option value="image">Image</option></select><button type="button" class="btn btn-danger btn-small" data-remove>Remove</button>';
|
|
list.appendChild(row);
|
|
});
|
|
list.addEventListener("click", (event) => {
|
|
if (event.target.matches("[data-remove]"))
|
|
event.target.closest(".custom-row")?.remove();
|
|
});
|
|
}
|
|
|
|
function ajaxPost(action, data) {
|
|
return $.ajax({
|
|
url: config.ajaxUrl,
|
|
type: "POST",
|
|
data: {
|
|
action,
|
|
nonce: config.nonce,
|
|
...data,
|
|
},
|
|
});
|
|
}
|
|
|
|
function bindAjaxButton(selector, action, getData, loadingText, onSuccess) {
|
|
const button = qs(selector);
|
|
if (!button) return;
|
|
const original = button.textContent;
|
|
button.addEventListener("click", () => {
|
|
button.disabled = true;
|
|
button.textContent = loadingText || "Testing...";
|
|
ajaxPost(action, getData ? getData() : {})
|
|
.done((response) => {
|
|
if (response?.success && typeof onSuccess === "function") {
|
|
onSuccess(response);
|
|
}
|
|
let message =
|
|
response?.data?.message ||
|
|
(response?.success ? "Connection successful" : "Connection failed");
|
|
if (response?.data?.models_count) {
|
|
message += ` (${response.data.models_count} models)`;
|
|
}
|
|
showToast(message, response?.success ? "success" : "error");
|
|
})
|
|
.fail((xhr) => {
|
|
showToast(
|
|
xhr.responseJSON?.data?.message || "Request failed",
|
|
"error",
|
|
);
|
|
})
|
|
.always(() => {
|
|
button.disabled = false;
|
|
button.textContent = original;
|
|
});
|
|
});
|
|
}
|
|
|
|
function loadCostLog() {
|
|
const tbody = qs("#wpaw-cost-log-tbody");
|
|
if (!tbody) return;
|
|
|
|
tbody.innerHTML = '<tr><td colspan="3">Loading cost data...</td></tr>';
|
|
ajaxPost("wpaw_get_cost_log_data", { page: 1, per_page: 25 })
|
|
.done((response) => {
|
|
if (!response?.success) {
|
|
tbody.innerHTML =
|
|
'<tr><td colspan="3">Unable to load cost data.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const records = response.data?.records || [];
|
|
if (!records.length) {
|
|
tbody.innerHTML =
|
|
'<tr><td colspan="3">Cost data will appear after OpenRouter generations.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = records.map(renderCostLogRecord).join("");
|
|
bindCostLogToggles(tbody);
|
|
})
|
|
.fail(() => {
|
|
tbody.innerHTML =
|
|
'<tr><td colspan="3">Unable to load cost data.</td></tr>';
|
|
});
|
|
}
|
|
|
|
function renderCostLogRecord(record) {
|
|
const postId = String(record.post_id ?? "0");
|
|
const rowId = `wpaw-cost-child-${postId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
const title = escapeHtml(record.post_title || "System/Other");
|
|
const titleHtml = record.post_link
|
|
? `<a href="${escapeAttr(record.post_link)}">${title}</a>`
|
|
: title;
|
|
const totalCost = escapeHtml(record.total_cost || "0.0000");
|
|
const callCount = Number(record.call_count || 0);
|
|
const actionRows = buildActionSummaries(record.details || []);
|
|
const hasActions = actionRows.length > 0;
|
|
const actionSummary = hasActions
|
|
? `${actionRows.length} actions / ${callCount} calls`
|
|
: `${callCount} calls`;
|
|
const toggle = hasActions
|
|
? `<button type="button" class="cost-row-toggle" aria-expanded="false" aria-controls="${escapeAttr(rowId)}" data-cost-child="#${escapeAttr(rowId)}">▸</button>`
|
|
: '<span class="cost-row-toggle-placeholder"></span>';
|
|
|
|
return `
|
|
<tr class="cost-parent-row">
|
|
<td>${toggle}<span class="cost-post-title">${titleHtml}</span></td>
|
|
<td>${escapeHtml(actionSummary)}</td>
|
|
<td>$${totalCost}</td>
|
|
</tr>
|
|
<tr class="cost-child-row" id="${escapeAttr(rowId)}" hidden>
|
|
<td colspan="3">
|
|
${renderActionSummaryTable(actionRows)}
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function buildActionSummaries(details) {
|
|
const grouped = new Map();
|
|
details.forEach((detail) => {
|
|
const action = detail.action || "unknown";
|
|
const current = grouped.get(action) || {
|
|
action,
|
|
calls: 0,
|
|
cost: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
models: new Set(),
|
|
};
|
|
current.calls += 1;
|
|
current.cost += Number.parseFloat(detail.cost || 0) || 0;
|
|
current.inputTokens += Number(detail.input_tokens || 0);
|
|
current.outputTokens += Number(detail.output_tokens || 0);
|
|
if (detail.model) current.models.add(detail.model);
|
|
grouped.set(action, current);
|
|
});
|
|
|
|
return Array.from(grouped.values()).sort((a, b) => b.cost - a.cost);
|
|
}
|
|
|
|
function renderActionSummaryTable(actionRows) {
|
|
if (!actionRows.length) {
|
|
return '<div class="cost-child-empty">No action details available for this post.</div>';
|
|
}
|
|
|
|
const rows = actionRows
|
|
.map((item) => {
|
|
const modelList = Array.from(item.models);
|
|
const models = modelList.length
|
|
? modelList
|
|
.map((m) => `<span class="model-pill">${escapeHtml(m)}</span>`)
|
|
.join("")
|
|
: "—";
|
|
const hasTokens = item.inputTokens > 0 || item.outputTokens > 0;
|
|
const inLabel = hasTokens ? escapeHtml(String(item.inputTokens)) : "—";
|
|
const outLabel = hasTokens
|
|
? escapeHtml(String(item.outputTokens))
|
|
: "—";
|
|
return `<tr>
|
|
<td>${escapeHtml(formatActionLabel(item.action))}</td>
|
|
<td>${escapeHtml(String(item.calls))}</td>
|
|
<td>${models}</td>
|
|
<td>${inLabel}</td>
|
|
<td>${outLabel}</td>
|
|
<td>$${escapeHtml(item.cost.toFixed(4))}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
|
|
return `<table class="cost-action-table">
|
|
<thead><tr><th>Action</th><th>Calls</th><th>Models</th><th>Input</th><th>Output</th><th>Cost</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
function bindCostLogToggles(root) {
|
|
// Accordion mode: expanding one row collapses all others.
|
|
qsa("[data-cost-child]", root).forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const child = qs(button.dataset.costChild);
|
|
if (!child) return;
|
|
const wasExpanded = button.getAttribute("aria-expanded") === "true";
|
|
|
|
// Collapse every open row first.
|
|
qsa("[data-cost-child][aria-expanded='true']", root).forEach((btn) => {
|
|
btn.setAttribute("aria-expanded", "false");
|
|
btn.textContent = "▸";
|
|
const sibling = qs(btn.dataset.costChild);
|
|
if (sibling) sibling.hidden = true;
|
|
});
|
|
|
|
// If the clicked row was not already open, expand it.
|
|
if (!wasExpanded) {
|
|
button.setAttribute("aria-expanded", "true");
|
|
button.textContent = "▾";
|
|
child.hidden = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function formatActionLabel(action) {
|
|
const normalized = String(action || "unknown").toLowerCase();
|
|
const labels = {
|
|
image_generation: "Generate Image",
|
|
generate_image: "Generate Image",
|
|
block_refinement: "Block Refinement",
|
|
chat: "Chat",
|
|
};
|
|
if (labels[normalized]) return labels[normalized];
|
|
|
|
return normalized
|
|
.replace(/[_-]+/g, " ")
|
|
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
const div = document.createElement("div");
|
|
div.textContent = String(value ?? "");
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function escapeAttr(value) {
|
|
return escapeHtml(value).replace(/"/g, """);
|
|
}
|
|
|
|
function bindAjaxCheckers() {
|
|
bindAjaxButton("#wpaw-test-api-key", "wpaw_test_api_connection", () => ({
|
|
api_key: qs("#openrouter_api_key")?.value || "",
|
|
}));
|
|
bindAjaxButton(
|
|
"#wpaw-test-local-backend",
|
|
"wpaw_test_local_backend",
|
|
() => {
|
|
// Find the first non-empty per-task model code for the test
|
|
const modelInputs = qsa(".wpaw-ce-model");
|
|
let model = "";
|
|
for (const input of modelInputs) {
|
|
if (input.value.trim()) {
|
|
model = input.value.trim();
|
|
break;
|
|
}
|
|
}
|
|
// Also sync the hidden legacy field
|
|
const legacy = qs("#local_backend_model");
|
|
if (legacy) legacy.value = model;
|
|
return {
|
|
url: qs("#local_backend_url")?.value || "",
|
|
key: qs("#local_backend_key")?.value || "",
|
|
model: model,
|
|
};
|
|
},
|
|
);
|
|
bindAjaxButton("#wpaw-test-memanto", "wpaw_test_memanto", () => ({
|
|
url: qs("#memanto_url")?.value || "",
|
|
key: qs("#memanto_moorcheh_key")?.value || "",
|
|
}));
|
|
bindAjaxButton(
|
|
"#wpaw-refresh-models",
|
|
"wpaw_refresh_models",
|
|
() => ({
|
|
api_key: qs("#openrouter_api_key")?.value || "",
|
|
}),
|
|
config.i18n?.refreshing || "Refreshing...",
|
|
(response) => {
|
|
if (response?.data?.models) {
|
|
config.models = response.data.models;
|
|
initSelect2();
|
|
updateEstimate();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
function bindSaveShortcut() {
|
|
document.addEventListener("keydown", (event) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
|
event.preventDefault();
|
|
qs("#wpaw2-settings-form")?.requestSubmit();
|
|
}
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
bindTabs();
|
|
bindPasswordToggles();
|
|
bindCopyButtons();
|
|
initSelect2();
|
|
bindPresetCards();
|
|
bindEstimateInputs();
|
|
bindTogglePanels();
|
|
bindCustomModels();
|
|
bindAjaxCheckers();
|
|
loadCostLog();
|
|
bindSaveShortcut();
|
|
bindToastHover();
|
|
qs("#wpaw2-toast-close")?.addEventListener("click", () =>
|
|
qs("#wpaw2-toast")?.classList.remove("show"),
|
|
);
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})(jQuery);
|