项目文件夹

文件
wehub-resource-sync 9f2e1cd2af
Ruby / Ruby 4.0.5 (push) Failing after 5m13s
Ruby / Ruby 3.3.4 (push) Failing after 5m43s
Ruby / Ruby 2.6.8 (push) Failing after 16m2s
chore: import upstream snapshot with attribution
2026-07-13 12:33:07 +08:00

2441 行
99 KiB
JavaScript

// settings.js — Settings panel logic
// Handles reading, editing, saving, testing AI model configurations.
const Settings = (() => {
// Local copy of models loaded from server
let _models = [];
// Provider presets loaded from server
let _providers = [];
// Provider id selected in the model edit modal (null when "Custom" or unset).
// Used to opt the request into anthropic_format=true when the user picks
// the Anthropic provider; other providers leave the flag unset and let the
// backend's runtime inference decide.
let _modalSelectedProviderId = null;
// ── Public API ──────────────────────────────────────────────────────────────
function open() {
_load();
_loadMedia();
_loadBrand();
_loadBrowserStatus();
_initNetworkSettings();
if (Clacky.Backup) Clacky.Backup.load();
_applyAboutTabVisibility();
}
// ── Data Loading ────────────────────────────────────────────────────────────
async function _load() {
const container = document.getElementById("model-cards");
container.innerHTML = `<div class="settings-loading">${I18n.t("settings.models.loading")}</div>`;
try {
// Load config and providers in parallel
const [configRes, providerRes] = await Promise.all([
fetch("/api/config"),
fetch("/api/providers")
]);
const configData = await configRes.json();
const providerData = await providerRes.json();
_models = configData.models || [];
_providers = providerData.providers || [];
_renderCards();
} catch (e) {
container.innerHTML = `<div class="settings-error">${I18n.t("settings.models.error", { msg: e.message })}</div>`;
}
}
// ── Rendering ───────────────────────────────────────────────────────────────
function _renderCards() {
const container = document.getElementById("model-cards");
container.innerHTML = "";
if (_models.length === 0) {
container.innerHTML = `<div class="settings-empty">${I18n.t("settings.models.empty")}</div>`;
return;
}
_models.forEach((m, i) => _renderCard(container, m, i));
}
function _getProviderName(model) {
const p = _findProviderByBaseUrl(model.base_url);
return p ? p.name : I18n.t("settings.models.provider.custom");
}
function _findProviderByBaseUrl(baseUrl) {
if (!baseUrl) return null;
const url = String(baseUrl).trim().replace(/\/+$/, "");
return _providers.find(p => {
const candidates = [p.base_url].concat(
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
).filter(Boolean);
return candidates.some(c => {
const norm = String(c).replace(/\/+$/, "");
return url === norm || url.startsWith(norm + "/");
});
}) || null;
}
function _renderCard(container, model, index) {
const isDefault = model.type === "default";
const isLite = model.type === "lite";
const provider = _findProviderByBaseUrl(model.base_url);
const providerName = provider ? provider.name : I18n.t("settings.models.provider.custom");
const websiteUrl = provider && provider.website_url;
const displayName = model.model || I18n.t("settings.models.unnamed");
const card = document.createElement("div");
card.className = "model-card-grid" + (isDefault ? " model-card-grid-default" : "");
card.dataset.index = index;
card.innerHTML = `
<div class="model-card-grid-info">
<div class="model-card-grid-name-row">
<span class="model-card-grid-name">${_esc(displayName)}</span>
${isDefault ? `<span class="badge badge-default">${I18n.t("settings.models.badge.default")}</span>` : ""}
${isLite ? `<span class="badge badge-lite">${I18n.t("settings.models.badge.lite")}</span>` : ""}
${!isDefault ? `<button class="btn-card-grid-action btn-card-grid-action-primary" data-index="${index}" data-action="default" style="margin-left:auto">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
<span>${I18n.t("settings.models.btn.setDefault")}</span>
</button>` : ""}
</div>
<div class="model-card-grid-provider">${_esc(providerName)}</div>
${model.api_key_masked ? `<div class="model-card-grid-model">${_esc(model.api_key_masked)}</div>` : ""}
<div class="model-card-grid-status">
<span class="model-test-result" data-index="${index}"></span>
</div>
</div>
<div class="model-card-grid-actions">
<div class="model-card-grid-toolbar">
<button class="btn-card-grid-action" data-index="${index}" data-action="test">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span>${I18n.t("settings.models.btn.test")}</span>
</button>
<button class="btn-card-grid-action" data-index="${index}" data-action="edit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<span>${I18n.t("settings.models.btn.edit")}</span>
</button>
<button class="btn-card-grid-action" data-index="${index}" data-action="duplicate">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>${I18n.t("settings.models.btn.duplicate")}</span>
</button>
${_models.length > 1 ? `<button class="btn-card-grid-action btn-card-grid-action-danger" data-index="${index}" data-action="delete">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<span>${I18n.t("settings.models.btn.delete")}</span>
</button>` : ""}
</div>
</div>
<div class="model-card-grid-footer">
${websiteUrl ? `<a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer" style="margin-left:auto">
${I18n.t("settings.models.link.topUp")}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M8 7h9v9"/></svg>
</a>` : ""}
</div>
`;
container.appendChild(card);
_bindCompactCardEvents(card, index);
}
function _bindCompactCardEvents(card, index) {
card.querySelectorAll("[data-action]").forEach(btn => {
btn.addEventListener("click", () => {
const action = btn.dataset.action;
switch (action) {
case "edit": _openModal(index); break;
case "test": _testModel(index); break;
case "delete": _removeModel(index); break;
case "default": _setAsDefault(index); break;
case "duplicate": _openModalDuplicate(index); break;
}
});
});
}
// ── Modal Functions ────────────────────────────────────────────────────────
function _openModal(index = -1) {
const modal = document.getElementById("model-edit-modal");
const titleEl = document.getElementById("model-modal-title");
const indexInput = document.getElementById("model-modal-index");
indexInput.value = index;
document.getElementById("model-modal-source-id").value = "";
// Populate provider dropdown
_populateModalProviderDropdown();
if (index >= 0 && _models[index]) {
// Edit mode
const model = _models[index];
titleEl.textContent = I18n.t("settings.models.modal.edit");
document.getElementById("model-modal-model").value = model.model || "";
document.getElementById("model-modal-baseurl").value = model.base_url || "";
document.getElementById("model-modal-apikey").value = model.api_key_masked || "";
document.getElementById("model-modal-default-field").style.display = "";
// Lock the checkbox when this is the only configured model: the system
// must always have one default (backend re-promotes on save), so
// unchecking would be a silent no-op. Force-checked + disabled makes
// the constraint visible without any extra copy.
const setDefaultCb = document.getElementById("model-modal-set-default");
const isOnlyModel = _models.length === 1;
setDefaultCb.checked = isOnlyModel ? true : (model.type === "default");
setDefaultCb.disabled = isOnlyModel;
// Set provider dropdown value
const matched = _findProviderByBaseUrl(model.base_url);
// Preserve an explicit anthropic_format=true even if base_url is custom:
// the user may have configured a self-hosted Anthropic-compatible proxy.
_modalSelectedProviderId = matched ? matched.id : (model.anthropic_format ? "anthropic" : null);
const providerName = matched ? matched.name : I18n.t("settings.models.provider.custom");
const providerValue = document.getElementById("model-modal-provider-value");
providerValue.textContent = providerName;
providerValue.classList.remove("placeholder");
} else {
// Add mode
titleEl.textContent = I18n.t("settings.models.modal.add");
document.getElementById("model-modal-model").value = "";
document.getElementById("model-modal-baseurl").value = "";
document.getElementById("model-modal-apikey").value = "";
document.getElementById("model-modal-default-field").style.display = "";
// Default to checked for new models — most users want their first/new
// model to take over as the default.
document.getElementById("model-modal-set-default").checked = true;
// Reset disabled flag in case the previous open was edit-mode on the
// sole-model lock path.
document.getElementById("model-modal-set-default").disabled = false;
// Reset provider dropdown
_modalSelectedProviderId = null;
const providerValue = document.getElementById("model-modal-provider-value");
providerValue.textContent = I18n.t("settings.models.placeholder.provider");
providerValue.classList.add("placeholder");
}
// Reset save button
const saveBtn = document.getElementById("model-modal-save");
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
// Clear test result
document.getElementById("model-modal-test-result").textContent = "";
document.getElementById("model-modal-test-result").className = "model-test-result";
// Show promo hint by default for new models
const promoHint = document.getElementById("model-modal-promo-hint");
_showPromoHint(promoHint);
modal.style.display = "";
document.body.style.overflow = "hidden";
document.getElementById("model-modal-provider-trigger").focus();
}
function _closeModal() {
const modal = document.getElementById("model-edit-modal");
modal.style.display = "none";
document.body.style.overflow = "";
}
function _openModalDuplicate(index) {
const source = _models[index];
if (!source) return;
_openModal(-1);
document.getElementById("model-modal-source-id").value = source.id || "";
document.getElementById("model-modal-title").textContent = I18n.t("settings.models.modal.duplicate");
document.getElementById("model-modal-model").value = source.model || "";
document.getElementById("model-modal-baseurl").value = source.base_url || "";
document.getElementById("model-modal-apikey").value = source.api_key_masked || "";
const matched = _findProviderByBaseUrl(source.base_url);
_modalSelectedProviderId = matched ? matched.id : (source.anthropic_format ? "anthropic" : null);
const providerValue = document.getElementById("model-modal-provider-value");
if (matched) {
providerValue.textContent = matched.name;
providerValue.classList.remove("placeholder");
const promoHint = document.getElementById("model-modal-promo-hint");
if (matched.id !== "openclacky") promoHint.classList.remove("visible");
}
}
function _populateModalProviderDropdown() {
const dropdown = document.getElementById("model-modal-provider-dropdown");
dropdown.innerHTML = `
<div class="custom-select-option" data-value="">${I18n.t("settings.models.placeholder.provider")}</div>
${_providers.map(p => `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(p.name)}">${_esc(p.name)}${p.id === "openclacky" ? ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>` : ""}</div>`).join("")}
<div class="custom-select-option" data-value="custom">${I18n.t("settings.models.custom")}</div>
`;
// Bind click events for options
dropdown.querySelectorAll(".custom-select-option").forEach(option => {
option.addEventListener("click", (e) => {
e.stopPropagation();
const value = option.dataset.value;
const text = option.dataset.label || option.textContent.trim();
// Track the picked provider so test/save can flag anthropic_format=true
// when the user explicitly picks Anthropic. Empty / "custom" → null.
_modalSelectedProviderId = (value && value !== "custom") ? value : null;
const providerValue = document.getElementById("model-modal-provider-value");
providerValue.textContent = text;
providerValue.classList.toggle("placeholder", !value);
dropdown.classList.remove("open");
document.getElementById("model-modal-provider-trigger").classList.remove("open");
// Show/hide promo hint
const promoHint = document.getElementById("model-modal-promo-hint");
if (value === "openclacky" || !value) {
_showPromoHint(promoHint);
} else {
promoHint.classList.remove("visible");
}
// Auto-fill if provider selected
if (value && value !== "custom") {
const preset = _providers.find(p => p.id === value);
if (preset) {
document.getElementById("model-modal-model").value = preset.default_model || "";
document.getElementById("model-modal-baseurl").value = preset.base_url || "";
const apikeyLink = document.getElementById("model-modal-apikey-link");
if (preset.website_url) {
apikeyLink.href = preset.website_url;
apikeyLink.style.display = "";
} else {
apikeyLink.style.display = "none";
}
// Update model dropdown with provider's models
setTimeout(() => _updateModalModelDropdown(), 0);
}
}
});
});
}
async function _testModel(index) {
const model = _models[index];
if (!model) return;
const testBtn = document.querySelector(`.btn-test-model[data-index="${index}"]`);
if (testBtn) testBtn.disabled = true;
_showTestResult(index, null, "");
try {
const result = await ModelTester.testConnection({
model: model.model,
base_url: model.base_url,
api_key: model.api_key_masked,
id: model.id,
index: typeof model.index === "number" ? model.index : index,
anthropic_format: model.anthropic_format
});
_showTestResult(index, result.ok, result.message);
} finally {
if (testBtn) testBtn.disabled = false;
}
}
async function _saveModalModel() {
const saveBtn = document.getElementById("model-modal-save");
const index = parseInt(document.getElementById("model-modal-index").value, 10);
const model = document.getElementById("model-modal-model").value.trim();
let base_url = document.getElementById("model-modal-baseurl").value.trim();
const api_key = document.getElementById("model-modal-apikey").value.trim();
saveBtn.disabled = true;
// Anthropic protocol is opted in only when the user picks the Anthropic
// provider in the modal. Other providers leave the flag absent so the
// backend's runtime inference (provider preset + model api overrides)
// decides — preserving e.g. OpenRouter's per-model anthropic-messages
// routing for Claude sub-models.
const anthropic_format = _modalSelectedProviderId === "anthropic";
const isNew = index < 0;
const existing = isNew ? {} : (_models[index] || {});
const existingId = existing.id || null;
const sourceId = document.getElementById("model-modal-source-id").value || null;
// Step 1: Test first
saveBtn.textContent = I18n.t("settings.models.btn.testing");
_showModalTestResult(null, "");
const result = await ModelTester.testConnection({
model, base_url, api_key, index, id: existingId || sourceId, anthropic_format
});
if (result.rewrote) {
base_url = result.base_url;
const baseInput = document.getElementById("model-modal-baseurl");
if (baseInput) baseInput.value = base_url;
}
_showModalTestResult(result.ok, result.message);
if (!result.ok) {
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
return;
}
// Step 2: Save
saveBtn.textContent = I18n.t("settings.models.btn.saving");
const hasId = !!existingId;
const payload = { model, base_url, anthropic_format };
const setDefault = document.getElementById("model-modal-set-default").checked;
payload.type = setDefault ? "default" : null;
if (setDefault) {
_models.forEach((m, i) => {
if (i !== index && m.type === "default") m.type = null;
});
}
if (api_key && !api_key.includes("****")) {
payload.api_key = api_key;
}
if (!hasId && !payload.api_key) {
if (sourceId) {
payload.source_id = sourceId;
} else {
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
_showModalTestResult(false, I18n.t("settings.models.placeholder.apikey"));
return;
}
}
const saveResult = await ModelTester.saveModel(payload, { existingId: hasId ? existingId : null });
if (saveResult.ok) {
saveBtn.textContent = I18n.t("settings.models.btn.saved");
setTimeout(() => {
_closeModal();
_load();
_loadMedia();
}, 800);
} else {
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
_showModalTestResult(false, saveResult.error || I18n.t("settings.models.saveFailed"));
}
}
function _showModalTestResult(ok, message) {
const el = document.getElementById("model-modal-test-result");
if (!el) return;
if (ok === null) { el.textContent = I18n.t("settings.models.btn.testing"); el.className = "model-test-result result-testing"; return; }
el.textContent = ok ? `✓ ${message || I18n.t("settings.models.connected")}` : `✗ ${I18n.t("settings.models.testFail")}: ${message || I18n.t("settings.models.failed")}`;
el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
}
function _positionDropdownFixed(dropdown, anchor) {
const rect = anchor.getBoundingClientRect();
dropdown.style.position = "fixed";
dropdown.style.top = (rect.bottom + 4) + "px";
dropdown.style.left = rect.left + "px";
dropdown.style.width = rect.width + "px";
dropdown.style.right = "auto";
dropdown.style.zIndex = "9999";
}
function _resetDropdownPosition(dropdown) {
dropdown.style.position = "";
dropdown.style.top = "";
dropdown.style.left = "";
dropdown.style.width = "";
dropdown.style.right = "";
dropdown.style.zIndex = "";
}
function _initModal() {
// Close button
document.getElementById("model-modal-close").addEventListener("click", _closeModal);
document.getElementById("model-modal-cancel").addEventListener("click", _closeModal);
// Save button
document.getElementById("model-modal-save").addEventListener("click", _saveModalModel);
// Click overlay to close
document.getElementById("model-edit-modal").addEventListener("click", (e) => {
if (e.target.id === "model-edit-modal") _closeModal();
});
// ESC to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("model-edit-modal").style.display !== "none") {
_closeModal();
}
});
// Provider dropdown toggle
const providerTrigger = document.getElementById("model-modal-provider-trigger");
const providerDropdown = document.getElementById("model-modal-provider-dropdown");
providerTrigger.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = providerDropdown.classList.contains("open");
document.querySelectorAll(".custom-select-dropdown.open").forEach(d => {
d.classList.remove("open");
});
if (!isOpen) {
providerDropdown.classList.add("open");
providerTrigger.classList.add("open");
} else {
providerDropdown.classList.remove("open");
providerTrigger.classList.remove("open");
}
});
// Close dropdowns on outside click
document.addEventListener("click", () => {
providerDropdown.classList.remove("open");
providerTrigger.classList.remove("open");
});
// Toggle API key visibility
document.getElementById("model-modal-toggle-key").addEventListener("click", () => {
const input = document.getElementById("model-modal-apikey");
input.type = input.type === "password" ? "text" : "password";
});
// Model dropdown functionality
const modelDropdownBtn = document.getElementById("model-modal-model-dropdown-btn");
const modelDropdown = document.getElementById("model-modal-model-dropdown");
const modelCombobox = document.getElementById("model-modal-model-combobox");
const modelInput = document.getElementById("model-modal-model");
function _openModelDropdown() {
_closeBaseUrlDropdown();
_updateModalModelDropdown();
_positionDropdownFixed(modelDropdown, modelCombobox);
modelDropdown.style.display = "block";
document.body.appendChild(modelDropdown);
}
function _closeModelDropdown() {
modelDropdown.style.display = "none";
_resetDropdownPosition(modelDropdown);
modelCombobox.appendChild(modelDropdown);
}
modelDropdownBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (modelDropdown.style.display === "block") {
_closeModelDropdown();
} else {
_openModelDropdown();
}
});
// Base URL dropdown functionality
const baseUrlDropdownBtn = document.getElementById("model-modal-baseurl-dropdown-btn");
const baseUrlDropdown = document.getElementById("model-modal-baseurl-dropdown");
const baseUrlCombobox = document.getElementById("model-modal-baseurl-combobox");
const baseUrlInput = document.getElementById("model-modal-baseurl");
function _openBaseUrlDropdown() {
_closeModelDropdown();
_updateModalBaseUrlDropdown();
_positionDropdownFixed(baseUrlDropdown, baseUrlCombobox);
baseUrlDropdown.style.display = "block";
document.body.appendChild(baseUrlDropdown);
}
function _closeBaseUrlDropdown() {
baseUrlDropdown.style.display = "none";
_resetDropdownPosition(baseUrlDropdown);
baseUrlCombobox.appendChild(baseUrlDropdown);
}
baseUrlDropdownBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (baseUrlDropdown.style.display === "block") {
_closeBaseUrlDropdown();
} else {
_openBaseUrlDropdown();
}
});
// Update model dropdown when base_url changes
baseUrlInput.addEventListener("blur", () => {
_updateModalModelDropdown();
});
// Close dropdowns on outside click
document.addEventListener("mousedown", (e) => {
if (!modelCombobox.contains(e.target) && !modelDropdown.contains(e.target)) {
_closeModelDropdown();
}
if (!baseUrlCombobox.contains(e.target) && !baseUrlDropdown.contains(e.target)) {
_closeBaseUrlDropdown();
}
});
}
function _getModalCurrentProvider() {
const baseUrlInput = document.getElementById("model-modal-baseurl");
const url = (baseUrlInput?.value || "").trim().replace(/\/+$/, "");
if (!url) return null;
return _providers.find(p => {
const candidates = [p.base_url].concat(
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
).filter(Boolean);
return candidates.some(c => {
const norm = String(c).replace(/\/+$/, "");
return url === norm || url.startsWith(norm + "/");
});
}) || null;
}
function _updateModalModelDropdown() {
const modelDropdown = document.getElementById("model-modal-model-dropdown");
const modelInput = document.getElementById("model-modal-model");
const provider = _getModalCurrentProvider();
const models = provider?.models || [];
if (models.length === 0) {
modelDropdown.innerHTML = `<div class="model-dropdown-empty">${I18n.t("settings.models.noModels") || "No preset models available"}</div>`;
return;
}
modelDropdown.innerHTML = models.map(m =>
`<div class="model-dropdown-option" data-value="${_esc(m)}">${_esc(m)}</div>`
).join("");
modelDropdown.querySelectorAll(".model-dropdown-option").forEach(opt => {
opt.addEventListener("click", (e) => {
e.stopPropagation();
modelInput.value = opt.dataset.value;
modelDropdown.style.display = "none";
});
});
}
function _updateModalBaseUrlDropdown() {
const baseUrlDropdown = document.getElementById("model-modal-baseurl-dropdown");
const baseUrlInput = document.getElementById("model-modal-baseurl");
const provider = _getModalCurrentProvider();
const variants = provider && Array.isArray(provider.endpoint_variants) ? provider.endpoint_variants : [];
if (variants.length === 0) {
baseUrlDropdown.innerHTML = `<div class="model-dropdown-empty">${I18n.t("settings.models.baseurl.noVariants")}</div>`;
return;
}
baseUrlDropdown.innerHTML = variants.map(v => {
const translated = v.label_key ? I18n.t(v.label_key) : null;
const labelText = (translated && translated !== v.label_key) ? translated : (v.label || v.base_url);
const label = _esc(labelText);
const url = _esc(v.base_url);
return `
<div class="model-dropdown-option base-url-dropdown-option" data-value="${url}">
<div class="base-url-dropdown-label">${label}</div>
<div class="base-url-dropdown-url">${url}</div>
</div>`;
}).join("");
baseUrlDropdown.querySelectorAll(".base-url-dropdown-option").forEach(opt => {
opt.addEventListener("click", (e) => {
e.stopPropagation();
baseUrlInput.value = opt.dataset.value;
baseUrlDropdown.style.display = "none";
_updateModalModelDropdown();
});
});
}
function _showPromoHint(promoHint) {
const items = [
I18n.t("provider.promo.openclacky.1"),
I18n.t("provider.promo.openclacky.2"),
I18n.t("provider.promo.openclacky.3"),
];
const title = `<div class="promo-title">${I18n.t("provider.promo.openclacky.title")}</div>`;
const body = items.map(s => `<div class="promo-item"><span class="promo-icon">✦</span>${s}</div>`).join("");
promoHint.innerHTML = `<div class="promo-inner">${title}${body}</div>`;
promoHint.classList.add("visible");
}
function _bindCardEvents(card, index) {
// Custom dropdown interactions
const customSelectWrapper = card.querySelector(".custom-select-wrapper");
const trigger = customSelectWrapper.querySelector(".custom-select-trigger");
const dropdown = customSelectWrapper.querySelector(".custom-select-dropdown");
const valueSpan = trigger.querySelector(".custom-select-value");
const options = dropdown.querySelectorAll(".custom-select-option");
// Initialize promo hint: only show for new cards (no existing model config)
const quickSetupField = card.querySelector(".quick-setup-field");
const isNewCard = quickSetupField && quickSetupField.style.display !== "none";
const initialPromoHint = card.querySelector(`.provider-promo-hint[data-index="${index}"]`);
const initialSelected = dropdown.querySelector(".custom-select-option.selected");
const initialValue = initialSelected ? initialSelected.dataset.value : "";
if (isNewCard && initialPromoHint && (!initialValue || initialValue === "openclacky")) {
_showPromoHint(initialPromoHint);
}
// Toggle dropdown
trigger.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.contains("open");
// Close all other dropdowns
document.querySelectorAll(".custom-select-dropdown.open").forEach(d => {
d.classList.remove("open");
d.previousElementSibling.classList.remove("open");
});
if (!isOpen) {
dropdown.classList.add("open");
trigger.classList.add("open");
}
});
// Select option
options.forEach(option => {
option.addEventListener("click", (e) => {
e.stopPropagation();
const value = option.dataset.value;
const text = option.dataset.label || option.textContent;
// Update UI
valueSpan.textContent = text;
if (value) {
valueSpan.classList.remove("placeholder");
} else {
valueSpan.classList.add("placeholder");
}
// Update selected state
options.forEach(opt => opt.classList.remove("selected"));
option.classList.add("selected");
// Close dropdown
dropdown.classList.remove("open");
trigger.classList.remove("open");
// Auto-fill model & base_url if a provider preset was selected
const getApiKeyLink = card.querySelector(`.get-apikey-link[data-index="${index}"]`);
const promoHint = card.querySelector(`.provider-promo-hint[data-index="${index}"]`);
if (value && value !== "custom") {
const preset = _providers.find(p => p.id === value);
if (preset) {
const modelInput = card.querySelector(`[data-key="model"]`);
const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
if (modelInput) modelInput.value = preset.default_model || "";
if (baseUrlInput) baseUrlInput.value = preset.base_url || "";
// Show "how to get" link if provider has a website_url
if (getApiKeyLink && preset.website_url) {
getApiKeyLink.href = preset.website_url;
getApiKeyLink.style.display = "";
} else if (getApiKeyLink) {
getApiKeyLink.style.display = "none";
}
}
// Show promo hint for openclacky, hide for others
if (promoHint) {
if (value === "openclacky") {
_showPromoHint(promoHint);
} else {
promoHint.classList.remove("visible");
}
}
} else {
if (getApiKeyLink) getApiKeyLink.style.display = "none";
// Show promo hint when no provider selected (default state)
if (promoHint) _showPromoHint(promoHint);
}
});
});
// Close dropdown when clicking outside
document.addEventListener("click", () => {
dropdown.classList.remove("open");
trigger.classList.remove("open");
});
// Toggle API key visibility
const toggleKeyBtn = card.querySelector(".btn-toggle-key");
const apiKeyInput = card.querySelector(".api-key-input");
const eyeIcon = toggleKeyBtn.querySelector("svg");
toggleKeyBtn.addEventListener("click", () => {
const isPassword = apiKeyInput.type === "password";
apiKeyInput.type = isPassword ? "text" : "password";
// Update icon
if (isPassword) {
// Show eye-off icon
eyeIcon.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
`;
} else {
// Show eye icon
eyeIcon.innerHTML = `
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
`;
}
});
// Save: auto-test first, then save if passed
card.querySelector(".btn-save-model").addEventListener("click", () => _saveModel(index));
// Remove model
const removeBtn = card.querySelector(".btn-model-remove");
if (removeBtn) {
removeBtn.addEventListener("click", () => _removeModel(index));
}
// Set as default model
const setDefaultBtn = card.querySelector(".btn-set-default");
if (setDefaultBtn) {
setDefaultBtn.addEventListener("click", () => _setAsDefault(index));
}
// Model name combobox: dropdown button + model list
const modelCombobox = card.querySelector(".model-name-combobox");
const modelInput = modelCombobox.querySelector(".model-name-input");
const modelDropdownBtn = modelCombobox.querySelector(".model-name-dropdown-btn");
const modelDropdown = modelCombobox.querySelector(".model-name-dropdown");
// Build model list from current base_url's provider
const _updateModelDropdown = () => {
const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
const baseUrl = baseUrlInput ? baseUrlInput.value.trim().replace(/\/+$/, "") : "";
// Find provider by matching base_url against BOTH the canonical
// preset.base_url AND every endpoint_variants[].base_url — otherwise
// picking e.g. GLM's Coding-Plan variant would wipe the model list
// because only the canonical URL would match.
const provider = _providers.find(p => {
const candidates = [p.base_url].concat(
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
).filter(Boolean);
return candidates.some(c => {
const norm = String(c).replace(/\/+$/, "");
return baseUrl === norm || baseUrl.startsWith(norm + "/");
});
});
const models = provider?.models || [];
if (models.length === 0) {
modelDropdown.innerHTML = '<div class="model-dropdown-empty">No preset models available</div>';
return;
}
// Render model options
modelDropdown.innerHTML = models.map(m =>
`<div class="model-dropdown-option" data-value="${_esc(m)}">${_esc(m)}</div>`
).join("");
// Bind click events
modelDropdown.querySelectorAll(".model-dropdown-option").forEach(opt => {
opt.addEventListener("click", (e) => {
e.stopPropagation();
const value = opt.dataset.value;
if (modelInput) modelInput.value = value;
modelDropdown.style.display = "none";
});
});
};
// Toggle dropdown
modelDropdownBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = modelDropdown.style.display === "block";
// Close all other model dropdowns
document.querySelectorAll(".model-name-dropdown").forEach(d => {
d.style.display = "none";
});
if (!isOpen) {
_updateModelDropdown();
modelDropdown.style.display = "block";
}
});
// Close dropdown when clicking outside
document.addEventListener("click", () => {
modelDropdown.style.display = "none";
});
// Re-populate model list when base_url changes
const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
if (baseUrlInput) {
baseUrlInput.addEventListener("blur", () => {
_updateModelDropdown();
});
}
// Base URL combobox: dropdown button + endpoint_variants list.
//
// Rationale: some providers (GLM on Zhipu/Z.ai, MiniMax on .com/.io) run
// multiple regional / billing-plan endpoints under a single identity.
// Listing every variant lets the user pick the right one instead of
// hand-editing the URL, while still allowing free-form input for
// unknown / self-hosted proxies. Mirrors the model-name combobox.
//
// Data source: the endpoint_variants[] field on each provider preset,
// resolved by matching the currently-entered base_url against every
// preset's {base_url + endpoint_variants[].base_url}. When no variants
// are declared for the matched provider (single-endpoint providers like
// Anthropic, OpenClacky), the dropdown shows an "empty" hint.
const baseUrlCombobox = card.querySelector(".base-url-combobox");
const baseUrlDropdownBtn = baseUrlCombobox.querySelector(".base-url-dropdown-btn");
const baseUrlDropdown = baseUrlCombobox.querySelector(".base-url-dropdown");
// Resolve the "active" provider preset from the current form values:
// 1. If the Quick Setup select points at a known provider, use that
// (even before the base_url input is typed into).
// 2. Otherwise fall back to matching the current base_url against all
// preset base_url + endpoint_variants. Unknown URLs → null.
const _currentProvider = () => {
const selected = card.querySelector(".custom-select-option.selected");
const selectedId = selected?.dataset.value;
if (selectedId && selectedId !== "custom") {
const byId = _providers.find(p => p.id === selectedId);
if (byId) return byId;
}
const url = (baseUrlInput?.value || "").trim().replace(/\/+$/, "");
if (!url) return null;
return _providers.find(p => {
const candidates = [p.base_url].concat(
Array.isArray(p.endpoint_variants) ? p.endpoint_variants.map(v => v.base_url) : []
).filter(Boolean);
return candidates.some(c => {
const norm = String(c).replace(/\/+$/, "");
return url === norm || url.startsWith(norm + "/");
});
}) || null;
};
const _renderBaseUrlDropdown = () => {
const provider = _currentProvider();
const variants = provider && Array.isArray(provider.endpoint_variants)
? provider.endpoint_variants
: [];
if (variants.length === 0) {
baseUrlDropdown.innerHTML =
`<div class="model-dropdown-empty">${I18n.t("settings.models.baseurl.noVariants")}</div>`;
return;
}
baseUrlDropdown.innerHTML = variants.map(v => {
// Prefer i18n key (localised per UI language); fall back to literal
// `label` (shipped English copy) and finally to base_url for safety.
// Pattern: _translateVariant(v) -> "大陆 · 按量付费" in zh, "Mainland · Pay-as-you-go" in en.
const translated = v.label_key ? I18n.t(v.label_key) : null;
// I18n.t typically returns the key itself when missing — treat that as a miss.
const labelText = (translated && translated !== v.label_key) ? translated : (v.label || v.base_url);
const label = _esc(labelText);
const url = _esc(v.base_url);
return `
<div class="model-dropdown-option base-url-dropdown-option" data-value="${url}">
<div class="base-url-dropdown-label">${label}</div>
<div class="base-url-dropdown-url">${url}</div>
</div>`;
}).join("");
baseUrlDropdown.querySelectorAll(".base-url-dropdown-option").forEach(opt => {
opt.addEventListener("click", (e) => {
e.stopPropagation();
if (baseUrlInput) {
baseUrlInput.value = opt.dataset.value;
// Trigger model-list refresh since base_url just changed.
_updateModelDropdown();
}
baseUrlDropdown.style.display = "none";
});
});
};
baseUrlDropdownBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = baseUrlDropdown.style.display === "block";
// Close sibling dropdowns (model-name + other base-url) to avoid overlap.
document.querySelectorAll(".model-name-dropdown, .base-url-dropdown").forEach(d => {
d.style.display = "none";
});
if (!isOpen) {
_renderBaseUrlDropdown();
baseUrlDropdown.style.display = "block";
}
});
// Close dropdown when clicking outside
document.addEventListener("click", () => {
baseUrlDropdown.style.display = "none";
});
}
// ── Read form values from a card ────────────────────────────────────────────
function _readCard(index) {
const card = document.querySelector(`.model-card[data-index="${index}"]`);
if (!card) return null;
return {
index,
model: card.querySelector(`[data-key="model"]`).value.trim(),
base_url: card.querySelector(`[data-key="base_url"]`).value.trim(),
api_key: card.querySelector(`[data-key="api_key"]`).value.trim(),
// The inline card form has no provider picker — preserve whatever the
// model was saved with. The modal flow is the only place where the
// user can flip this flag.
anthropic_format: !!_models[index]?.anthropic_format,
type: _models[index]?.type ?? null
};
}
// ── Save ─────────────────────────────────────────────────────────────────────
async function _saveModel(index) {
const saveBtn = document.querySelector(`.btn-save-model[data-index="${index}"]`);
const updated = _readCard(index);
if (!updated) return;
saveBtn.disabled = true;
// Step 1: auto-test first
saveBtn.textContent = I18n.t("settings.models.btn.testing");
_showTestResult(index, null, "");
try {
const testRes = await fetch("/api/config/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...updated, index })
});
const testData = await testRes.json();
_showTestResult(index, testData.ok, testData.message);
if (!testData.ok) {
// Test failed — stop, let user fix
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
return;
}
} catch (e) {
_showTestResult(index, false, e.message);
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
return;
}
// Step 2: test passed — now save via single-item endpoint.
//
// Contract (see http_server.rb):
// - Row has an id already → PATCH /api/config/models/:id
// - No id yet (locally-added row) → POST /api/config/models to
// create, then capture the server-assigned id.
// We NEVER send "the whole list" — each save touches exactly one row,
// so no bug in this function can ever affect another model's api_key.
saveBtn.textContent = I18n.t("settings.models.btn.saving");
const existing = _models[index] || {};
const hasId = !!existing.id;
// For PATCH: only send api_key if the user actually typed something
// non-masked. The masked display value ("sk-ab12****...5678") must
// never be sent as api_key — the server treats it as "no change"
// defensively, but the cleanest path is simply to omit it.
const payload = {
model: updated.model,
base_url: updated.base_url,
anthropic_format: updated.anthropic_format,
type: updated.type
};
if (updated.api_key && !updated.api_key.includes("****")) {
payload.api_key = updated.api_key;
}
try {
let res, data;
if (hasId) {
res = await fetch(`/api/config/models/${encodeURIComponent(existing.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
data = await res.json();
} else {
// Creation requires a non-empty api_key — surface a friendly
// error rather than a server 422.
if (!payload.api_key) {
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
_showTestResult(index, false, I18n.t("settings.models.placeholder.apikey"));
return;
}
res = await fetch(`/api/config/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
data = await res.json();
if (data.ok && data.id) {
// Record the assigned id so subsequent saves become PATCH.
_models[index].id = data.id;
}
}
if (data.ok) {
saveBtn.textContent = I18n.t("settings.models.btn.saved");
setTimeout(() => { saveBtn.textContent = I18n.t("settings.models.btn.save"); saveBtn.disabled = false; }, 1500);
// Reload to get fresh masked keys
setTimeout(_load, 1600);
} else {
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
_showTestResult(index, false, data.error || I18n.t("settings.models.saveFailed"));
}
} catch (e) {
saveBtn.textContent = I18n.t("settings.models.btn.save");
saveBtn.disabled = false;
_showTestResult(index, false, e.message);
}
}
function _showTestResult(index, ok, message) {
const el = document.querySelector(`.model-test-result[data-index="${index}"]`);
if (!el) return;
if (ok === null) { el.textContent = I18n.t("settings.models.btn.testing"); el.className = "model-test-result result-testing"; return; }
el.textContent = ok ? `✓ ${message || I18n.t("settings.models.connected")}` : `✗ ${I18n.t("settings.models.testFail")}: ${message || I18n.t("settings.models.failed")}`;
el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
}
// ── Set as Default Model ───────────────────────────────────────────────────
async function _setAsDefault(index) {
const btn = document.querySelector(`.btn-card-grid-action[data-index="${index}"][data-action="default"]`);
const target = _models[index];
if (!target || !target.id) {
alert(I18n.t("settings.models.setDefaultFailed"));
return;
}
if (btn) {
btn.disabled = true;
const span = btn.querySelector("span");
if (span) span.textContent = I18n.t("settings.models.btn.setting");
}
try {
const res = await fetch(`/api/config/models/${encodeURIComponent(target.id)}/default`, {
method: "POST"
});
const data = await res.json();
if (data.ok) {
if (btn) {
const span = btn.querySelector("span");
if (span) span.textContent = I18n.t("settings.models.btn.done");
}
setTimeout(() => { _load(); _loadMedia(); }, 800);
} else {
if (btn) {
btn.disabled = false;
const span = btn.querySelector("span");
if (span) span.textContent = I18n.t("settings.models.btn.setDefault");
}
alert(data.error || I18n.t("settings.models.setDefaultFailed"));
}
} catch (e) {
if (btn) {
btn.disabled = false;
const span = btn.querySelector("span");
if (span) span.textContent = I18n.t("settings.models.btn.setDefault");
}
alert(I18n.t("settings.models.errorPrefix") + e.message);
}
}
// ── Add / Remove model ───────────────────────────────────────────────────────
function _addModel() {
// Open modal in add mode (index = -1)
_openModal(-1);
}
async function _removeModel(index) {
if (_models.length <= 1) return;
const modelName = _models[index]?.model || String(index + 1);
const confirmed = await Modal.confirm(I18n.t("settings.models.confirmRemove", { model: modelName }));
if (!confirmed) return;
const target = _models[index];
// Unsaved local card → just drop it from the local list, no server call.
if (!target || !target.id) {
_models.splice(index, 1);
_renderCards();
return;
}
try {
const res = await fetch(`/api/config/models/${encodeURIComponent(target.id)}`, {
method: "DELETE"
});
// Whatever the server says, reload to reflect the true state.
// (On error, _load will re-show the model.)
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error || I18n.t("settings.models.setDefaultFailed"));
}
} catch (_) { /* ignore */ }
// Reload fresh state
_load();
_loadMedia();
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function _esc(str) {
return (str || "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
// ── Rerun onboard ────────────────────────────────────────────────────────────
async function _rerunOnboard() {
const btn = document.getElementById("btn-rerun-onboard");
btn.disabled = true;
btn.textContent = I18n.t("settings.personalize.btn.starting");
try {
// Close settings panel and navigate to chat, then start the onboard session.
// Onboard.startSoulSession() creates a new session, selects it, and sends /onboard.
Router.navigate("chat");
await Onboard.startSoulSession();
} catch (e) {
btn.disabled = false;
btn.textContent = I18n.t("settings.personalize.btn.rerun");
}
}
// ── Browser Setup ─────────────────────────────────────────────────────────────
async function _loadBrowserStatus() {
try {
const res = await fetch("/api/browser/status");
const data = await res.json();
const desc = document.getElementById("browser-status-desc");
const btn = document.getElementById("btn-browser-setup");
const toggleWrap = document.getElementById("browser-toggle-wrap");
const toggleInput = document.getElementById("browser-toggle-input");
// File doesn't exist → not set up yet
if (data.enabled === undefined || data.enabled === null) {
desc.textContent = I18n.t("settings.browser.desc");
btn.textContent = I18n.t("settings.browser.btn");
toggleWrap.style.display = "none";
return;
}
// Configured — show toggle + reconfigure button
const version = data.chrome_version ? ` (Chrome v${data.chrome_version})` : "";
desc.textContent = I18n.t(data.enabled ? "settings.browser.configured" : "settings.browser.disabled") + version;
btn.textContent = I18n.t("settings.browser.btn.reconfigure");
toggleWrap.style.display = "inline-block";
toggleInput.checked = data.enabled;
// Only bind once
if (!toggleInput.dataset.bound) {
toggleInput.dataset.bound = "1";
toggleInput.addEventListener("change", _toggleBrowser);
}
} catch (_) { /* non-critical */ }
}
async function _toggleBrowser() {
const toggleInput = document.getElementById("browser-toggle-input");
toggleInput.disabled = true;
try {
const res = await fetch("/api/browser/toggle", { method: "POST" });
const data = await res.json();
if (!data.ok) throw new Error(data.error || "toggle failed");
await _loadBrowserStatus();
} catch (_) {
// Revert on failure
toggleInput.checked = !toggleInput.checked;
} finally {
toggleInput.disabled = false;
}
}
async function _setupBrowser() {
const btn = document.getElementById("btn-browser-setup");
btn.disabled = true;
btn.textContent = I18n.t("settings.browser.btn.starting");
try {
const res = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "🌐 Browser Setup", source: "setup" })
});
const data = await res.json();
const session = data.session;
if (!session) throw new Error("No session returned");
Sessions.add(session);
Sessions.renderList();
Sessions.setPendingMessage(session.id, "/browser-setup");
Sessions.select(session.id);
} catch (e) {
btn.disabled = false;
btn.textContent = I18n.t("settings.browser.btn");
}
}
// ── Network / Proxy ───────────────────────────────────────────────────────────
async function _initNetworkSettings() {
const urlInput = document.getElementById("settings-proxy-url");
const saveBtn = document.getElementById("btn-save-proxy-url");
const clearBtn = document.getElementById("btn-clear-proxy-url");
const status = document.getElementById("settings-proxy-url-status");
if (!urlInput || !saveBtn) return;
try {
const res = await fetch("/api/config/settings");
const data = await res.json();
if (data.ok) {
urlInput.value = data.proxy_url || "";
}
} catch (_) { /* non-critical */ }
async function _patchProxyUrl(value, successKey) {
status.textContent = "";
status.className = "model-test-result";
try {
const res = await fetch("/api/config/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxy_url: value })
});
const data = await res.json();
if (data.ok) {
status.textContent = I18n.t(successKey);
status.className = "model-test-result success";
} else {
status.textContent = data.error || I18n.t("settings.network.invalidUrl");
status.className = "model-test-result error";
}
} catch (e) {
status.textContent = e.message || I18n.t("settings.network.invalidUrl");
status.className = "model-test-result error";
}
}
if (!saveBtn.dataset.bound) {
saveBtn.dataset.bound = "1";
saveBtn.addEventListener("click", () => {
_patchProxyUrl(urlInput.value.trim(), "settings.network.saved");
});
}
if (clearBtn && !clearBtn.dataset.bound) {
clearBtn.dataset.bound = "1";
clearBtn.addEventListener("click", () => {
urlInput.value = "";
_patchProxyUrl("", "settings.network.cleared");
});
}
}
// ── Brand & License ───────────────────────────────────────────────────────────
// Whether the server was started with --brand-test (relaxed key validation).
let _brandTestMode = false;
// Load and render the current brand/license status in Settings.
async function _loadBrand() {
try {
const res = await fetch("/api/brand/status");
const data = await res.json();
_brandTestMode = !!data.test_mode;
_renderBrandStatus(data);
} catch (_) {
// If the API is unreachable just leave both areas hidden — non-critical.
}
}
function _renderBrandStatus(data) {
const statusCard = document.getElementById("brand-status-card");
const activateForm = document.getElementById("brand-activate-form");
if (data.branded && !data.needs_activation) {
// Already activated — show status card, hide form
statusCard.style.display = "";
activateForm.style.display = "none";
document.getElementById("brand-status-name").textContent = data.product_name || "—";
const badge = document.getElementById("brand-status-badge");
if (data.warning) {
// Distinguish between expired (red) and expiring-soon (yellow)
const isExpired = data.warning && data.warning.toLowerCase().includes("expired");
badge.textContent = isExpired ? I18n.t("settings.brand.badge.expired") : I18n.t("settings.brand.badge.warning");
badge.className = "brand-status-value " + (isExpired ? "badge-expired" : "badge-expiring");
} else {
badge.textContent = I18n.t("settings.brand.badge.active");
badge.className = "brand-status-value badge-active";
}
// Fetch full brand info for expiry date and support QR code
fetch("/api/brand").then(r => r.json()).then(info => {
const expiresEl = document.getElementById("brand-status-expires");
if (info.license_expires_at) {
expiresEl.textContent = new Date(info.license_expires_at).toLocaleDateString();
} else {
expiresEl.textContent = "—";
}
// Show homepage link if available
const homepageRow = document.getElementById("brand-status-homepage-row");
const homepageLink = document.getElementById("brand-status-homepage");
if (info.homepage_url && homepageRow && homepageLink) {
homepageLink.href = info.homepage_url;
homepageLink.textContent = info.homepage_url;
homepageRow.style.display = "";
} else if (homepageRow) {
homepageRow.style.display = "none";
}
// Show support contact if available
const contactWrap = document.getElementById("brand-support-contact");
const contactLink = document.getElementById("brand-support-contact-link");
if (info.support_contact && contactWrap && contactLink) {
const contact = info.support_contact;
contactLink.textContent = contact;
// Auto-detect mailto / http link
if (contact.startsWith("http://") || contact.startsWith("https://")) {
contactLink.href = contact;
} else if (contact.includes("@")) {
contactLink.href = "mailto:" + contact;
} else {
contactLink.href = "#";
contactLink.style.cursor = "default";
}
contactWrap.style.display = "";
} else if (contactWrap) {
contactWrap.style.display = "none";
}
// Show support QR code if available
const qrContainer = document.getElementById("brand-support-qr");
const qrImg = document.getElementById("brand-support-qr-img");
if (info.support_qr_url && qrContainer && qrImg) {
qrImg.src = info.support_qr_url;
qrContainer.style.display = "";
_initQrLightbox(info.support_qr_url, info.support_qr_label || null);
} else if (qrContainer) {
qrContainer.style.display = "none";
}
}).catch(() => {
document.getElementById("brand-status-expires").textContent = "—";
});
} else {
// Not activated (or needs activation) — show form, hide status card
statusCard.style.display = "none";
activateForm.style.display = "";
// Pre-fill brand name in input placeholder if we know it
if (data.product_name) {
const desc = activateForm.querySelector(".brand-activate-desc");
if (desc) desc.textContent =
I18n.t("settings.brand.descNamed", { name: data.product_name });
}
// Show "Get a serial number" link only when the brand vendor has
// published a homepage_url (read from /api/brand). No homepage → no link.
if (typeof Brand.applyGetSerialLink === "function") Brand.applyGetSerialLink();
}
}
/** Return a user-friendly error message for license activation failures. */
function _friendlyActivateError(rawError) {
if (!rawError) return I18n.t("settings.brand.activationFailed");
const lower = rawError.toLowerCase();
if (lower.includes("timeout") || lower.includes("network error") ||
lower.includes("execution expired") || lower.includes("failed to open")) {
return I18n.t("settings.brand.networkRetry");
}
return rawError;
}
async function _activateLicense() {
const input = document.getElementById("settings-license-key");
const btn = document.getElementById("btn-settings-activate");
const result = document.getElementById("settings-activate-result");
const key = input.value.trim();
if (!key) {
_showBrandResult(false, I18n.t("settings.brand.enterKey"));
return;
}
// In brand-test mode skip strict key format validation so developers can use any test key.
if (!_brandTestMode && !/^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{8}){4}$/.test(key)) {
_showBrandResult(false, I18n.t("settings.brand.invalidFormat"));
return;
}
btn.disabled = true;
btn.textContent = I18n.t("settings.brand.btn.activating");
_showBrandResult(null, "");
try {
const res = await fetch("/api/brand/activate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ license_key: key })
});
const data = await res.json();
if (data.ok) {
_showBrandResult(true, I18n.t("settings.brand.activated", { name: data.product_name || "configured" }));
// Apply brand name and logo across the entire UI immediately
if (data.product_name) Brand.applyBrandName(data.product_name);
if (data.theme_color) _applyAccentColor(data.theme_color, { persist: true });
Brand.clearBrandCache();
Brand.applyHeaderLogo();
// Refresh brand status flags (user_licensed may have flipped from false
// to true if this is a creator-tier license) and repaint dependent UI:
// - Creator sidebar entry (hidden for brand consumers, shown otherwise)
// - Header owner badge (shown only for creator licenses)
// Without this refresh the user would need to reload the page to see
// the Creator Hub appear in the sidebar after activation.
Brand.refresh().then(() => {
if (typeof Creator !== "undefined" && Creator.updateSidebarVisibility) {
Creator.updateSidebarVisibility();
}
if (typeof Brand.applyOwnerBadge === "function") Brand.applyOwnerBadge();
});
// Remove the activation banner immediately after successful activation
const banner = document.getElementById("brand-activation-banner");
if (banner) banner.remove();
// Reload brand status card after short delay
setTimeout(_loadBrand, 800);
// Auto-navigate to brand skills tab after a brief moment so user sees the success message first
setTimeout(() => {
Router.navigate("skills");
if (typeof Skills !== "undefined") Skills.openBrandSkillsTab();
}, 1500);
} else {
_showBrandResult(false, _friendlyActivateError(data.error));
}
} catch (e) {
// Fetch itself threw (network down, timeout, etc.) — always show retry message
_showBrandResult(false, I18n.t("settings.brand.networkRetry"));
} finally {
btn.disabled = false;
btn.textContent = I18n.t("settings.brand.btn.activate");
}
}
function _showBrandResult(ok, message) {
const el = document.getElementById("settings-activate-result");
if (!el) return;
if (ok === null) { el.textContent = ""; el.className = "model-test-result"; return; }
el.textContent = message;
el.className = "model-test-result " + (ok ? "result-ok" : "result-fail");
}
// ── Init ──────────────────────────────────────────────────────────────────────
function _initTabs() {
const bar = document.getElementById("settings-tabs");
if (!bar) return;
// Delegated so extension tabs (mounted into the settings.tabs slot after
// this runs) switch correctly without re-binding.
bar.addEventListener("click", (e) => {
const tab = e.target.closest(".settings-tab");
if (!tab || !bar.contains(tab)) return;
const targetTab = tab.dataset.tab;
if (!targetTab) return;
document.querySelectorAll("#settings-tabs .settings-tab").forEach(t =>
t.classList.toggle("active", t.dataset.tab === targetTab));
document.querySelectorAll("#settings-body .settings-tab-content").forEach(c => {
const isActive = c.dataset.tabContent === targetTab;
c.classList.toggle("active", isActive);
c.style.display = isActive ? "" : "none";
});
});
}
function _applyAboutTabVisibility() {
const branded = typeof Brand !== "undefined" && Brand.branded;
const tabBtn = document.querySelector('#settings-tabs .settings-tab[data-tab="about"]');
const tabPanel = document.querySelector('#settings-body .settings-tab-content[data-tab-content="about"]');
if (!tabBtn || !tabPanel) return;
if (branded) {
tabBtn.style.display = "none";
if (tabBtn.classList.contains("active")) {
tabBtn.classList.remove("active");
tabPanel.classList.remove("active");
tabPanel.style.display = "none";
const fallback = document.querySelector('#settings-tabs .settings-tab[data-tab="models"]');
const fallbackPanel = document.querySelector('#settings-body .settings-tab-content[data-tab-content="models"]');
if (fallback) fallback.classList.add("active");
if (fallbackPanel) {
fallbackPanel.classList.add("active");
fallbackPanel.style.display = "";
}
}
} else {
tabBtn.style.display = "";
}
}
function _initLangBtns() {
// Highlight the active language button on open
document.querySelectorAll("#language-section .settings-lang-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.lang === I18n.lang());
btn.addEventListener("click", () => {
I18n.setLang(btn.dataset.lang);
document.querySelectorAll("#language-section .settings-lang-btn").forEach(b =>
b.classList.toggle("active", b.dataset.lang === I18n.lang())
);
});
});
}
// ── Advanced Settings ────────────────────────────────────────────────────────
async function _loadAdvancedSettings() {
try {
const res = await fetch("/api/config/settings");
const data = await res.json();
if (data.ok) {
const comp = document.getElementById("settings-compression-toggle");
const cache = document.getElementById("settings-prompt-caching-toggle");
const mem = document.getElementById("settings-memory-update-toggle");
if (comp) comp.checked = data.enable_compression !== false;
if (cache) cache.checked = data.enable_prompt_caching !== false;
if (mem) mem.checked = data.memory_update_enabled !== false;
}
} catch (e) {
console.error("Failed to load advanced settings:", e);
}
}
async function _saveAdvancedSetting(key, value) {
try {
await fetch("/api/config/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value })
});
} catch (e) {
console.error("Failed to save setting:", e);
}
}
function _initAdvancedSettings() {
_loadAdvancedSettings();
document.getElementById("settings-compression-toggle")?.addEventListener("change", (e) => {
_saveAdvancedSetting("enable_compression", e.target.checked);
});
document.getElementById("settings-prompt-caching-toggle")?.addEventListener("change", (e) => {
_saveAdvancedSetting("enable_prompt_caching", e.target.checked);
});
document.getElementById("settings-memory-update-toggle")?.addEventListener("change", (e) => {
_saveAdvancedSetting("memory_update_enabled", e.target.checked);
});
}
// ── About Tab ───────────────────────────────────────────────────────────────
async function _loadAboutInfo() {
try {
const res = await fetch("/api/version");
const data = await res.json();
if (data.current) {
const el = document.getElementById("about-version");
if (el) el.textContent = `v${data.current}`;
}
} catch (e) {
console.error("Failed to load version info:", e);
}
}
// ── Media generation (Settings → Models tab, below the model cards) ───
// Per-kind tri-state: off / auto (derive from default) / custom (user-set).
// Backend API:
// GET /api/config/media → { media: { image: {...}, ... }, default_provider: {...} }
// PATCH /api/config/media/:kind → body { source: "off"|"auto"|"custom", model?, base_url?, api_key?, anthropic_format? }
// The state object per kind:
// { source, configured, model, base_url, api_key_masked, provider, available }
const MEDIA_KINDS = ["image", "video", "audio", "stt", "video_understanding", "ocr"];
let _mediaState = null;
let _mediaDefaults = null;
const _mediaCustomDraft = {};
async function _loadMedia() {
const container = document.getElementById("media-rows");
if (!container) return;
container.innerHTML = `<div class="settings-loading">${I18n.t("settings.media.loading")}</div>`;
try {
const [mediaRes, ocrRes] = await Promise.all([
fetch("/api/config/media"),
fetch("/api/config/ocr")
]);
const mediaData = await mediaRes.json();
const ocrData = await ocrRes.json();
_mediaState = mediaData.media || {};
_mediaDefaults = mediaData.default_provider || {};
_mediaState["ocr"] = ocrData.ocr || { source: "off", available: [] };
_mediaDefaults["ocr"] = ocrData.default_provider || { available: [] };
_renderMediaRows();
} catch (e) {
container.innerHTML = `<div class="settings-error">${I18n.t("settings.media.error", { msg: e.message })}</div>`;
}
}
function _renderMediaRows() {
const container = document.getElementById("media-rows");
if (!container) return;
container.innerHTML = "";
MEDIA_KINDS.forEach(kind => {
container.appendChild(_renderMediaRow(kind));
});
}
function _refreshKindRows(_kind) {
_renderMediaRows();
}
async function _reloadKind(_kind) {
await _loadMedia();
}
function _renderMediaRow(kind) {
const state = (_mediaState && _mediaState[kind]) || { source: "off", available: [] };
const def = (_mediaDefaults && _mediaDefaults[kind]) || { available: [] };
const autoAvailable = !!(def && def.model);
const isCustomEditing = state.source === "custom" && (!state.configured || _mediaCustomDraft[kind]);
const isVisionPrimary = kind === "ocr" && state.source === "auto" && state.primary;
const row = document.createElement("div");
row.className = "media-row";
if (!isVisionPrimary && (isCustomEditing || (state.source === "auto" && state.configured) || (state.source === "custom" && state.configured))) {
row.classList.add("is-expanded");
}
row.dataset.kind = kind;
// Compact head: title · segmented · status
const head = document.createElement("div");
head.className = "media-row-head";
const title = document.createElement("span");
title.className = "media-row-title";
title.textContent = I18n.t(`settings.media.kind.${kind}`);
head.appendChild(title);
// When the default chat model already supports vision, the OCR sidecar
// reuses it automatically — there's nothing to choose, so show a single
// read-only note instead of the off/auto/custom switcher.
if (isVisionPrimary) {
const note = document.createElement("span");
note.className = "media-row-status media-vision-primary-note";
note.textContent = I18n.t("settings.media.vision.primary");
head.appendChild(note);
const model = document.createElement("span");
model.className = "media-vision-primary-model";
model.textContent = state.model || "";
head.appendChild(model);
row.appendChild(head);
return row;
}
const seg = document.createElement("div");
seg.className = "media-row-segmented";
["off", "auto", "custom"].forEach(src => {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.source = src;
btn.textContent = I18n.t(`settings.media.source.${src}`);
if (state.source === src) btn.classList.add("is-active");
if (src === "auto" && !autoAvailable) {
btn.disabled = true;
btn.title = I18n.t("settings.media.auto.disabledTitle");
} else {
btn.addEventListener("click", () => _onMediaSourceClick(kind, src));
}
seg.appendChild(btn);
});
head.appendChild(seg);
const status = document.createElement("span");
status.className = "media-row-status";
status.textContent = _mediaStatusText(kind, state, def);
head.appendChild(status);
row.appendChild(head);
const detail = _renderMediaDetail(kind, state, def);
if (detail) row.appendChild(detail);
return row;
}
function _mediaStatusText(kind, state, def) {
if (state.source === "off") return "";
if (state.source === "auto") {
if (state.configured && state.model) return state.model;
return "";
}
return "";
}
function _renderMediaDetail(kind, state, def) {
if (state.source === "off") return null;
if (state.source === "auto") {
if (state.configured && state.model) {
const wrap = document.createElement("div");
wrap.className = "media-row-detail";
const providerLine = document.createElement("div");
providerLine.className = "media-provider-line";
const pLabel = document.createElement("span");
pLabel.className = "media-provider-label";
pLabel.textContent = I18n.t("settings.media.field.provider");
const pVal = document.createElement("span");
pVal.className = "media-provider-value";
pVal.textContent = state.provider || "—";
providerLine.appendChild(pLabel);
providerLine.appendChild(pVal);
wrap.appendChild(providerLine);
const modelField = _buildField(I18n.t("settings.media.field.model"));
const opts = Array.isArray(state.available) ? state.available : [];
const aliases = (state.aliases && typeof state.aliases === "object") ? state.aliases : {};
const labelOf = (m) => aliases[m] ? `${m} (${aliases[m]})` : m;
if (opts.length > 1) {
const sel = document.createElement("select");
sel.className = "field-select";
opts.forEach(m => {
const o = document.createElement("option");
o.value = m;
o.textContent = labelOf(m);
if (m === state.model) o.selected = true;
sel.appendChild(o);
});
sel.addEventListener("change", async () => {
const picked = sel.value;
const payload = picked === (def && def.model)
? { source: "auto" }
: { source: "auto", model: picked };
sel.disabled = true;
_setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
try {
await _saveMediaConfig(kind, payload);
await _reloadKind(kind);
} catch (e) {
sel.disabled = false;
_setMediaResult(kind, "fail", e.message);
}
});
modelField.appendChild(sel);
} else {
const ro = document.createElement("div");
ro.className = "media-auto-readonly";
ro.textContent = labelOf(state.model);
modelField.appendChild(ro);
}
wrap.appendChild(modelField);
if (state.stale && state.requested_model) {
const warn = document.createElement("div");
warn.className = "media-row-hint is-warning";
warn.textContent = I18n.t("settings.media.auto.stale", {
requested: labelOf(state.requested_model),
current: labelOf(state.model)
});
wrap.appendChild(warn);
} else {
const hint = document.createElement("div");
hint.className = "media-row-hint";
hint.textContent = I18n.t("settings.media.auto.followsDefault");
wrap.appendChild(hint);
}
wrap.appendChild(_buildMediaResult(kind));
return wrap;
}
const wrap = document.createElement("div");
wrap.className = "media-row-detail is-warning";
const hasDefault = def && def.provider;
wrap.innerHTML = `<div>${hasDefault ? I18n.t("settings.media.auto.unsupported") : I18n.t("settings.media.auto.noDefaultModel")}</div>`;
return wrap;
}
// custom
if (state.configured && !_mediaCustomDraft[kind]) {
const wrap = document.createElement("div");
wrap.className = "media-row-detail";
const list = document.createElement("div");
list.className = "media-custom-readonly-list";
[
["settings.media.field.model", state.model],
["settings.media.field.baseUrl", state.base_url],
["settings.media.field.apiKey", state.api_key_masked]
].forEach(([lk, v]) => {
const row = document.createElement("div");
row.className = "media-custom-readonly-row";
const lbl = document.createElement("span");
lbl.className = "media-custom-readonly-label";
lbl.textContent = I18n.t(lk);
const val = document.createElement("span");
val.className = "media-custom-readonly-value";
val.textContent = v || "—";
row.appendChild(lbl);
row.appendChild(val);
list.appendChild(row);
});
wrap.appendChild(list);
const actions = document.createElement("div");
actions.className = "media-row-actions";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "btn-secondary media-row-btn-sm";
editBtn.textContent = I18n.t("settings.media.action.edit");
editBtn.addEventListener("click", () => {
_mediaCustomDraft[kind] = {
model: state.model || "",
base_url: state.base_url || "",
api_key: ""
};
_refreshKindRows(kind);
});
const testBtn = document.createElement("button");
testBtn.type = "button";
testBtn.className = "btn-secondary media-row-btn-sm";
testBtn.textContent = I18n.t("settings.media.action.test");
testBtn.addEventListener("click", async () => {
testBtn.disabled = true;
editBtn.disabled = true;
_setMediaResult(kind, "testing", I18n.t("settings.media.testing"));
try {
const r = await _testMediaConfig(kind, {
model: state.model,
base_url: state.base_url,
api_key: state.api_key_masked
});
_setMediaResult(kind, r.ok ? "ok" : "fail", r.message || "");
} finally {
testBtn.disabled = false;
editBtn.disabled = false;
}
});
actions.appendChild(testBtn);
actions.appendChild(editBtn);
wrap.appendChild(actions);
wrap.appendChild(_buildMediaResult(kind));
return wrap;
}
// edit form
const draft = _mediaCustomDraft[kind] || { model: "", base_url: "", api_key: "" };
const wrap = document.createElement("div");
wrap.className = "media-row-detail";
const fields = document.createElement("div");
fields.className = "media-custom-fields";
const mkInput = (labelKey, fieldName, type, placeholder, initial) => {
const f = _buildField(I18n.t(labelKey));
const inp = document.createElement("input");
inp.type = type;
inp.className = "field-input";
inp.value = initial || "";
inp.placeholder = placeholder;
inp.dataset.field = fieldName;
inp.addEventListener("input", () => {
_mediaCustomDraft[kind] = _mediaCustomDraft[kind] || {};
_mediaCustomDraft[kind][fieldName] = inp.value;
});
f.appendChild(inp);
return f;
};
fields.appendChild(mkInput("settings.media.field.model", "model", "text", "gpt-image-1", draft.model));
fields.appendChild(mkInput("settings.media.field.baseUrl", "base_url", "text", "https://api.openai.com/v1", draft.base_url));
fields.appendChild(mkInput("settings.media.field.apiKey", "api_key", "password", I18n.t("settings.media.apiKey.placeholder"), draft.api_key));
wrap.appendChild(fields);
const result = _buildMediaResult(kind);
wrap.appendChild(result);
const actions = document.createElement("div");
actions.className = "media-row-actions";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn-secondary media-row-btn-sm";
cancelBtn.textContent = I18n.t("settings.media.action.cancel");
cancelBtn.addEventListener("click", () => {
delete _mediaCustomDraft[kind];
if (!state.configured) {
const fallback = (_mediaDefaults && _mediaDefaults[kind] && _mediaDefaults[kind].model) ? "auto" : "off";
_mediaState[kind] = { ..._mediaState[kind], source: fallback };
}
_refreshKindRows(kind);
});
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "btn-primary media-row-btn-sm";
saveBtn.textContent = I18n.t("settings.media.action.save");
saveBtn.addEventListener("click", async () => {
const d = _mediaCustomDraft[kind] || {};
saveBtn.disabled = true;
cancelBtn.disabled = true;
_setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
try {
await _saveMediaConfig(kind, {
source: "custom",
model: (d.model || "").trim(),
base_url: (d.base_url || "").trim(),
api_key: d.api_key || ""
});
delete _mediaCustomDraft[kind];
await _reloadKind(kind);
} catch (e) {
saveBtn.disabled = false;
cancelBtn.disabled = false;
_setMediaResult(kind, "fail", e.message);
}
});
actions.appendChild(cancelBtn);
actions.appendChild(saveBtn);
wrap.appendChild(actions);
return wrap;
}
function _buildField(labelText) {
const f = document.createElement("div");
f.className = "model-field";
const label = document.createElement("span");
label.className = "field-label";
label.textContent = labelText;
f.appendChild(label);
return f;
}
function _buildMediaResult(kind) {
const el = document.createElement("div");
el.className = "model-test-result";
el.dataset.mediaKind = kind;
return el;
}
function _setMediaResult(kind, status, message) {
const el = document.querySelector(`.model-test-result[data-media-kind="${kind}"]`);
if (!el) return;
el.className = `model-test-result result-${status}`;
if (!message) {
el.textContent = "";
return;
}
const prefix = status === "ok" ? "✓ " : status === "fail" ? "✗ " : "";
el.textContent = prefix + message;
}
async function _onMediaSourceClick(kind, source) {
const cur = (_mediaState && _mediaState[kind]) || {};
if (cur.source === source && source !== "custom") return;
if (source === "custom") {
if (cur.source !== "custom" && !_mediaCustomDraft[kind]) {
_mediaCustomDraft[kind] = { model: "", base_url: "", api_key: "" };
}
_mediaState[kind] = { ...cur, source: "custom" };
_renderMediaRows();
return;
}
try {
await _saveMediaConfig(kind, { source });
delete _mediaCustomDraft[kind];
await _loadMedia();
} catch (e) {
_renderMediaRows();
_setMediaResult(kind, "fail", e.message);
}
}
async function _saveMediaConfig(kind, body) {
const url = kind === "ocr" ? `/api/config/ocr` : `/api/config/media/${kind}`;
const res = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) {
throw new Error(data.error || `HTTP ${res.status}`);
}
return data;
}
async function _testMediaConfig(kind, { model, base_url, api_key }) {
try {
const url = kind === "ocr" ? `/api/config/ocr/test` : `/api/config/media/test`;
const payload = kind === "ocr"
? { model, base_url, api_key }
: { kind, model, base_url, api_key };
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, message: data.error || `HTTP ${res.status}` };
return data;
} catch (e) {
return { ok: false, message: e.message };
}
}
function init() {
_initTabs();
_initModal();
_initAdvancedSettings();
_loadAboutInfo();
document.getElementById("btn-add-model").addEventListener("click", _addModel);
document.getElementById("btn-rerun-onboard").addEventListener("click", _rerunOnboard);
document.getElementById("btn-browser-setup").addEventListener("click", _setupBrowser);
document.getElementById("btn-settings-activate").addEventListener("click", _activateLicense);
document.getElementById("settings-license-key").addEventListener("keydown", e => {
if (e.key === "Enter") _activateLicense();
});
// "Get a Serial Number" → opens the brand vendor's homepage in a new tab.
// URL comes from /api/brand `homepage_url`, stashed on the button's dataset
// by Brand.applyGetSerialLink(). If no homepage is configured the whole
// row stays hidden, so this listener is effectively unreachable in that
// case; the guard is purely defensive.
document.getElementById("btn-get-serial")?.addEventListener("click", (e) => {
const url = e.currentTarget.dataset.homepageUrl;
if (url) window.open(url, "_blank", "noopener,noreferrer");
});
document.getElementById("btn-rebind-license").addEventListener("click", async () => {
const confirmed = await Modal.confirm(I18n.t("settings.brand.confirmRebind"));
if (!confirmed) return;
// Show the form again so user can enter a new key
document.getElementById("brand-status-card").style.display = "none";
document.getElementById("brand-activate-form").style.display = "";
document.getElementById("settings-license-key").value = "";
document.getElementById("settings-license-key").focus();
});
document.getElementById("btn-unbind-license").addEventListener("click", async () => {
const confirmed = await Modal.confirm(I18n.t("settings.brand.confirmUnbind"));
if (!confirmed) return;
try {
const res = await fetch("/api/brand/license", { method: "DELETE" });
const data = await res.json();
if (data.ok) {
// Clear brand name and logo from header
Brand.applyBrandName("OpenClacky");
Brand.clearBrandCache();
Brand.applyHeaderLogo();
const userAccent = (() => { try { return localStorage.getItem("clacky-accent-color"); } catch (_) { return null; } })();
_applyAccentColor(userAccent || ACCENT_DEFAULT, { persist: false });
// Reset Skills panel state (hide Brand Skills tab, switch to My Skills)
if (typeof Skills !== "undefined" && Skills.resetAfterUnbind) {
Skills.resetAfterUnbind();
}
// Refresh brand flags so the sidebar creator entry and owner badge
// reflect the now-unbound state without a page reload.
Brand.refresh().then(() => {
if (typeof Creator !== "undefined" && Creator.updateSidebarVisibility) {
Creator.updateSidebarVisibility();
}
if (typeof Brand.applyOwnerBadge === "function") Brand.applyOwnerBadge();
});
// Hide status card, show activation form
document.getElementById("brand-status-card").style.display = "none";
document.getElementById("brand-activate-form").style.display = "";
document.getElementById("settings-license-key").value = "";
_showBrandResult(true, I18n.t("settings.brand.unbindSuccess"));
// Reload brand status after a brief delay
setTimeout(_loadBrand, 800);
} else {
_showBrandResult(false, data.error || I18n.t("settings.brand.unbindFailed"));
}
} catch (e) {
_showBrandResult(false, I18n.t("settings.brand.networkRetry"));
}
});
_initLangBtns();
_initFontBtns();
_initCurrencyBtns();
_initAccentColorBtns();
// Re-render model cards when language changes (dynamic HTML, not data-i18n)
document.addEventListener("langchange", () => {
_renderCards();
_renderMediaRows();
});
}
// ── Currency ──────────────────────────────────────────────────────────
const CURRENCY_STORAGE_KEY = "clacky-currency";
const EXCHANGE_RATE_STORAGE_KEY = "clacky-exchange-rate";
const CURRENCY_DEFAULT = "USD";
const DEFAULT_EXCHANGE_RATE = 6.7944;
function _applyCurrency(currency) {
try { localStorage.setItem(CURRENCY_STORAGE_KEY, currency); } catch (_) {}
// Update active state on all currency buttons
document.querySelectorAll("#currency-section .settings-lang-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.currency === currency);
});
// Show/hide exchange rate input based on currency
const exchangeRateSection = document.getElementById("exchange-rate-section");
if (exchangeRateSection) {
exchangeRateSection.style.display = currency === "CNY" ? "block" : "none";
}
// Dispatch event for billing panel to update
document.dispatchEvent(new CustomEvent("currencychange", { detail: { currency } }));
}
function _getExchangeRate() {
try {
const rate = localStorage.getItem(EXCHANGE_RATE_STORAGE_KEY);
if (rate) {
const parsed = parseFloat(rate);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
} catch (_) {}
return DEFAULT_EXCHANGE_RATE;
}
function _setExchangeRate(rate) {
try {
if (rate && !isNaN(rate) && rate > 0) {
localStorage.setItem(EXCHANGE_RATE_STORAGE_KEY, rate.toString());
document.dispatchEvent(new CustomEvent("currencychange"));
}
} catch (_) {}
}
function _initCurrencyBtns() {
// Apply saved preference (or default) on page load
let saved = null;
try { saved = localStorage.getItem(CURRENCY_STORAGE_KEY); } catch (_) {}
const current = saved || CURRENCY_DEFAULT;
// Wire up button clicks
document.querySelectorAll("#currency-section .settings-lang-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.currency === current);
btn.addEventListener("click", () => {
_applyCurrency(btn.dataset.currency);
});
});
// Initialize exchange rate input
const exchangeRateInput = document.getElementById("settings-exchange-rate");
const exchangeRateSection = document.getElementById("exchange-rate-section");
const updateRateBtn = document.getElementById("btn-update-exchange-rate");
if (exchangeRateInput && exchangeRateSection) {
// Set initial value
exchangeRateInput.value = _getExchangeRate();
// Show/hide based on current currency
exchangeRateSection.style.display = current === "CNY" ? "block" : "none";
// Handle input changes
exchangeRateInput.addEventListener("change", () => {
const rate = parseFloat(exchangeRateInput.value);
if (!isNaN(rate) && rate > 0) {
_setExchangeRate(rate);
} else {
exchangeRateInput.value = _getExchangeRate();
}
});
if (updateRateBtn && !updateRateBtn.dataset.bound) {
updateRateBtn.dataset.bound = "1";
updateRateBtn.addEventListener("click", () => _updateLatestExchangeRate());
}
}
}
async function _updateLatestExchangeRate() {
const input = document.getElementById("settings-exchange-rate");
const btn = document.getElementById("btn-update-exchange-rate");
if (!input || !btn) return;
const label = btn.querySelector("span");
const originalText = label ? label.textContent : btn.textContent;
btn.disabled = true;
if (label) label.textContent = I18n.t("settings.currency.updating");
else btn.textContent = I18n.t("settings.currency.updating");
_setExchangeRateStatus("", "");
try {
const res = await fetch("/api/exchange-rate?from=USD&to=CNY");
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(I18n.t("settings.currency.updateFailed"));
const rate = parseFloat(data.rate);
if (isNaN(rate) || rate <= 0) throw new Error(I18n.t("settings.currency.updateFailed"));
input.value = rate.toString();
_setExchangeRate(rate);
_setExchangeRateStatus(
I18n.t("settings.currency.updated", { source: data.source || "", date: data.date || "" }),
"success"
);
} catch (e) {
_setExchangeRateStatus(e.message || I18n.t("settings.currency.updateFailed"), "error");
} finally {
btn.disabled = false;
if (label) label.textContent = originalText || I18n.t("settings.currency.updateLatest");
else btn.textContent = originalText || I18n.t("settings.currency.updateLatest");
}
}
function _setExchangeRateStatus(message, type) {
const status = document.getElementById("settings-exchange-rate-status");
if (!status) return;
status.textContent = message || "";
status.classList.toggle("success", type === "success");
status.classList.toggle("error", type === "error");
}
// ── Font Size ──────────────────────────────────────────────────────────
const FONT_STORAGE_KEY = "clacky-font-size";
const FONT_DEFAULT = "medium";
function _applyFontSize(size) {
document.documentElement.setAttribute("data-font-size", size);
try { localStorage.setItem(FONT_STORAGE_KEY, size); } catch (_) {}
// Update active state on all font-size buttons (if settings panel is open)
document.querySelectorAll("#font-size-section .settings-lang-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.font === size);
});
}
function _initFontBtns() {
// Apply saved preference (or default) on page load
let saved = null;
try { saved = localStorage.getItem(FONT_STORAGE_KEY); } catch (_) {}
_applyFontSize(saved || FONT_DEFAULT);
// Wire up button clicks
document.querySelectorAll("#font-size-section .settings-lang-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.font === (saved || FONT_DEFAULT));
btn.addEventListener("click", () => {
_applyFontSize(btn.dataset.font);
});
});
}
// ── Accent Color ──────────────────────────────────────────────────────
const ACCENT_STORAGE_KEY = "clacky-accent-color";
const ACCENT_DEFAULT = "#4f46e5";
function _applyAccentColor(color, { persist = true } = {}) {
const root = document.documentElement;
root.style.setProperty("--color-accent-primary", color);
root.style.setProperty("--color-accent-hover", color);
root.style.setProperty("--color-button-primary", color);
root.style.setProperty("--color-button-primary-hover", `color-mix(in srgb, ${color} 82%, #000)`);
if (persist) {
try { localStorage.setItem(ACCENT_STORAGE_KEY, color); } catch (_) {}
}
document.querySelectorAll("#accent-color-section .settings-accent-swatch").forEach(btn => {
btn.classList.toggle("active", btn.dataset.color === color);
});
}
function _initAccentColorBtns() {
let saved = null;
try { saved = localStorage.getItem(ACCENT_STORAGE_KEY); } catch (_) {}
_applyAccentColor(saved || ACCENT_DEFAULT, { persist: !!saved });
document.querySelectorAll("#accent-color-section .settings-accent-swatch").forEach(btn => {
btn.addEventListener("click", () => _applyAccentColor(btn.dataset.color));
});
}
// ── QR Code Lightbox ───────────────────────────────────────────────────
// Sets up click-to-enlarge behaviour for the support QR code.
// Safe to call multiple times — idempotent via a data attribute guard.
function _initQrLightbox(qrUrl, label) {
const btn = document.getElementById("brand-support-qr-btn");
const lightbox = document.getElementById("qr-lightbox");
const backdrop = document.getElementById("qr-lightbox-backdrop");
const closeBtn = document.getElementById("qr-lightbox-close");
const lbImg = document.getElementById("qr-lightbox-img");
const lbLabel = document.getElementById("qr-lightbox-label");
if (!btn || !lightbox) return;
// Avoid double-binding
if (btn.dataset.lightboxBound) return;
btn.dataset.lightboxBound = "1";
function openLightbox() {
lbImg.src = qrUrl;
if (lbLabel && label) lbLabel.textContent = label;
lightbox.style.display = "";
document.body.style.overflow = "hidden";
closeBtn && closeBtn.focus();
}
function closeLightbox() {
lightbox.style.display = "none";
document.body.style.overflow = "";
btn.focus();
}
btn.addEventListener("click", openLightbox);
closeBtn && closeBtn.addEventListener("click", closeLightbox);
backdrop && backdrop.addEventListener("click", closeLightbox);
// Close on Escape key
document.addEventListener("keydown", e => {
if (e.key === "Escape" && lightbox.style.display !== "none") closeLightbox();
});
}
return { open, init, loadBrand: _loadBrand, applyAccentColor: _applyAccentColor };
})();