oclaw/interfaces/admin/static/js/pages/models.js
oliver 5c6833612f Add WhatsApp session bind/unbind to Admin Runtime, and fix related Admin JS errors.
Expose sidecar status, start/stop, QR bind, and unbind so 405 session failures are visible instead of a stuck connecting state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:23:35 +08:00

833 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { state, t, el, tdCell, apiGet, apiPost, apiRequest, resolveAdminApiUrl, getStoredAuthToken, renderPageShell, renderSectionCard, markPrewarmReminder } from "../core.js";
import { hasPermission } from "./authz.js";
async function renderModels() {
const canPickActive = hasPermission("admin:read");
const canConfigureBindings = canPickActive;
const canConfigureChatUi = hasPermission("admin:tenant:write");
const status = el("div", { class: "muted", text: "" });
const isAdminPool = String((state.authSession && state.authSession.username) || "").trim().toLowerCase() === "administrator";
// 与后端 _require_models_mutate 对齐:administrator 写全局池需 tenant:write;普通用户写自己的配置只需 admin:read
const canMutateProfiles = isAdminPool ? hasPermission("admin:tenant:write") : canPickActive;
const readonlyHint = el("div", {
class: "muted",
text: isAdminPool && !canMutateProfiles ? t("models.readonly") : "",
});
const activeSelect = el("select", { class: "input", disabled: !canPickActive });
const chatModelSelectorVisibleCb = el("input", { type: "checkbox", disabled: !canConfigureChatUi });
const bindingWrap = el("div", {});
const opsAiSpecialistSelect = el("select", { class: "input", disabled: !canConfigureBindings });
const opsAiProfileSelect = el("select", { class: "input", disabled: !canConfigureBindings });
const modelsGrantsLinkRow = el("div", { class: "muted u-hidden" });
const newName = el("input", { class: "input", placeholder: t("models.createNamePlaceholder") });
const newMode = el("select", { class: "input", disabled: !canMutateProfiles }, [
el("option", { value: "openai", text: t("models.mode.openai") }),
el("option", { value: "anthropic", text: t("models.mode.anthropic") }),
el("option", { value: "google", text: t("models.mode.google") }),
el("option", { value: "ollama", text: t("models.mode.ollama") }),
el("option", { value: "rule", text: t("models.mode.rule") }),
]);
const profName = el("input", { class: "input" });
const modeSel = el("select", { class: "input" });
const modelInp = el("input", { class: "input" });
const baseInp = el("input", { class: "input", placeholder: t("models.baseUrlPlaceholder") });
const thinkingModeCb = el("input", { type: "checkbox" });
const reasoningEffortSel = el("select", { class: "input", style: "max-width:180px" }, [
el("option", { value: "", text: t("models.reasoningEffortDefault") }),
el("option", { value: "low", text: t("models.reasoningEffortLow") }),
el("option", { value: "medium", text: t("models.reasoningEffortMedium") }),
el("option", { value: "high", text: t("models.reasoningEffortHigh") }),
]);
const keyInp = el("input", { class: "input", type: "password", autocomplete: "off" });
const rememberCb = el("input", { type: "checkbox" });
const readonlyProfileHint = el("div", { class: "muted", text: "" });
const builtinCap = el("div", { class: "muted", text: "" });
const warnKey = el("div", { class: "muted", text: "" });
const openaiHint = el("div", { class: "muted", text: "" });
const ollamaHint = el("div", { class: "muted", text: "" });
const evalMetrics = el("div", { class: "row" });
const evalTableWrap = el("div");
const evalDetails = el("details", { class: "details" });
const evalSummary = el("summary", { text: t("models.evalToggle") });
const evalInner = el("div", {});
const expertsStatus = el("div", { class: "muted", text: "" });
const expertsSelect = el("select", { class: "input" });
const expertNewId = el("input", { class: "input", placeholder: "new expert id, e.g. qa" });
const expertNewNameEn = el("input", { class: "input", placeholder: "English name (required)" });
const expertNewNameZh = el("input", { class: "input", placeholder: t("models.phNameZh") });
const expertRoleSel = el("select", { class: "input" }, [
el("option", { value: "expert", text: "expert" }),
el("option", { value: "system", text: "system" }),
]);
const expertNameEn = el("input", { class: "input", placeholder: "English name (required)" });
const expertNameZh = el("input", { class: "input", placeholder: t("models.phNameZh") });
const expertSoul = el("textarea", { class: "input", rows: "5", placeholder: "SOUL.md (optional)" });
const expertRoleSystem = el("textarea", { class: "input", rows: "5", placeholder: "ROLE_SYSTEM.md (optional)" });
async function downloadEvalExport(format) {
const fmt = format === "json" ? "json" : "csv";
const url = resolveAdminApiUrl(`/admin/api/models/eval/export?format=${encodeURIComponent(fmt)}`);
const token = getStoredAuthToken();
const res = await fetch(url, {
headers: {
accept: fmt === "json" ? "application/json" : "text/csv",
...(token ? { authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) throw new Error(`export ${res.status}`);
const blob = await res.blob();
const a = document.createElement("a");
const name = fmt === "json" ? "agent_eval_logs.json" : "agent_eval_logs.csv";
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
const btnEvalCsv = el("button", {
class: "btn",
text: t("models.evalDownloadCsv"),
onclick: async () => {
try {
await downloadEvalExport("csv");
} catch (e) {
status.textContent = String(e.message || e);
}
},
});
const btnEvalJson = el("button", {
class: "btn",
text: t("models.evalDownloadJson"),
onclick: async () => {
try {
await downloadEvalExport("json");
} catch (e) {
status.textContent = String(e.message || e);
}
},
});
const evalActionRow = el("div", { class: "row" }, [
el("span", { class: "muted", text: t("models.evalLogsPreview") }),
btnEvalCsv,
btnEvalJson,
]);
evalInner.appendChild(evalMetrics);
evalInner.appendChild(evalActionRow);
evalInner.appendChild(evalTableWrap);
evalDetails.appendChild(evalSummary);
evalDetails.appendChild(evalInner);
let modelsState = null;
let evalPack = null;
let secretsStatus = null;
let expertsState = { items: [] };
const secretsStatusMsg = el("div", { class: "muted", text: "" });
const secretsMigrateBtn = el("button", { class: "btn", text: t("secrets.migrateBtn") });
const secretsCard = el("div", { class: "card u-hidden" }, [
el("div", { class: "card__title", text: t("secrets.migrateTitle") }),
el("div", { class: "muted", text: t("secrets.migrateHint") }),
el("div", { class: "row" }, [secretsMigrateBtn]),
secretsStatusMsg,
]);
function fmtSecretsDone(s, p) {
return t("secrets.migrateDone").replace("{s}", String(s)).replace("{p}", String(p));
}
function paintSecretsCard() {
secretsStatusMsg.textContent = "";
if (!secretsStatus || secretsStatus.ok !== true) {
secretsCard.style.display = "none";
return;
}
const n1 = intOr0(secretsStatus.legacy_b64_app_settings);
const n2 = intOr0(secretsStatus.legacy_b64_llm_profiles);
const total = n1 + n2;
const canMigrate = hasPermission("admin:tenant:write");
if (total <= 0) {
secretsCard.style.display = "none";
return;
}
secretsCard.style.display = "block";
if (!canMigrate) {
secretsMigrateBtn.disabled = true;
secretsStatusMsg.textContent = t("secrets.migrateForbidden");
return;
}
if (!secretsStatus.has_master_key) {
secretsMigrateBtn.disabled = true;
secretsStatusMsg.textContent = t("secrets.migrateNeedKey");
return;
}
secretsMigrateBtn.disabled = false;
secretsStatusMsg.textContent = `legacy: settings=${n1}, profiles=${n2}`;
}
const VIS_KEYS = {
builtin: "models.vis.builtin",
owned: "models.vis.owned",
grant_user: "models.vis.grantUser",
grant_tenant: "models.vis.grantTenant",
global: "models.vis.global",
other_user: "models.vis.otherUser",
};
function visibilityTag(vr) {
const k = VIS_KEYS[String(vr || "")];
return k ? t(k) : "";
}
function labelFor(pid) {
const profiles = (modelsState && modelsState.profiles) || [];
const p = profiles.find((x) => String(x.id) === String(pid));
const base = (p && String(p.name || "").trim()) || pid;
const vr = p && p.visibility_reason;
if (!vr) return base;
const tag = visibilityTag(vr);
return tag ? `${base} (${tag})` : base;
}
function fillModeSelect(sel, modes, current) {
sel.innerHTML = "";
modes.forEach((m) => {
const label =
m === "openai"
? t("models.mode.openai")
: m === "openai_responses"
? t("models.mode.openai_responses")
: m === "anthropic"
? t("models.mode.anthropic")
: m === "google"
? t("models.mode.google")
: m === "ollama"
? t("models.mode.ollama")
: t("models.mode.rule");
sel.appendChild(el("option", {
value: m,
text: label,
}));
});
if (modes.includes(current)) sel.value = current;
else sel.value = modes[0];
}
async function refresh() {
try {
const data = await apiGet("/admin/api/models");
modelsState = data;
status.textContent = "";
paint();
try {
secretsStatus = await apiGet("/admin/api/secrets/status");
} catch (_) {
secretsStatus = null;
}
paintSecretsCard();
try {
evalPack = await apiGet("/admin/api/models/eval?limit_logs=100");
paintEval();
} catch (ee) {
evalPack = { ok: false, summary: {}, logs: [] };
paintEval();
status.textContent = [status.textContent, `${t("models.sectionEval")}: ${String(ee.message || ee)}`].filter(Boolean).join(" | ");
}
try {
expertsState = await apiGet("/admin/api/experts");
} catch (_) {
expertsState = { ok: false, items: [] };
}
paintExperts();
} catch (e) {
modelsState = null;
evalPack = null;
secretsStatus = null;
expertsState = { ok: false, items: [] };
status.textContent = String(e.message || e);
}
}
function currentExpertItem() {
const items = Array.isArray(expertsState && expertsState.items) ? expertsState.items : [];
const id = String(expertsSelect.value || "");
return items.find((x) => String(x.id || "") === id) || null;
}
function fillExpertFields(item) {
const files = (item && item.files && typeof item.files === "object") ? item.files : {};
expertNameEn.value = String((item && item.display_name_en) || "");
expertNameZh.value = String((item && item.display_name_zh) || "");
expertRoleSel.value = String((item && item.role) || "expert");
expertSoul.value = String(files["SOUL.md"] || "");
expertRoleSystem.value = String(files["ROLE_SYSTEM.md"] || "");
const isSystem = !!(item && (item.builtin || String(item.role || "") === "system"));
expertRoleSel.disabled = isSystem;
btnExpertDelete.disabled = isSystem;
}
function paintExperts() {
const items = Array.isArray(expertsState && expertsState.items) ? expertsState.items : [];
const prev = String(expertsSelect.value || "");
expertsSelect.innerHTML = "";
items.forEach((x) => {
const id = String(x.id || "");
if (!id) return;
const isSystem = !!(x && (x.builtin || String(x.role || "") === "system"));
const tail = isSystem ? " (system)" : "";
const name = String(x.display_name_en || "").trim() || id;
expertsSelect.appendChild(el("option", { value: id, text: `${name} [${id}]${tail}` }));
});
if (items.some((x) => String(x.id || "") === prev)) expertsSelect.value = prev;
else if (items.length) expertsSelect.value = String(items[0].id || "");
fillExpertFields(currentExpertItem());
}
secretsMigrateBtn.addEventListener("click", async () => {
if (!hasPermission("admin:tenant:write")) {
secretsStatusMsg.textContent = t("secrets.migrateForbidden");
return;
}
secretsMigrateBtn.disabled = true;
secretsStatusMsg.textContent = "";
try {
const res = await apiPost("/admin/api/secrets/migrate", {});
if (res && res.ok) {
const s = intOr0(res.migrated_app_settings);
const p = intOr0(res.migrated_llm_profiles);
secretsStatusMsg.textContent = (s + p) > 0 ? fmtSecretsDone(s, p) : t("secrets.migrateNoop");
} else {
secretsStatusMsg.textContent = String((res && (res.error || res.detail)) || "migrate_failed");
}
try {
secretsStatus = await apiGet("/admin/api/secrets/status");
} catch (_) {
secretsStatus = null;
}
paintSecretsCard();
} catch (e) {
secretsStatusMsg.textContent = String(e.message || e);
paintSecretsCard();
} finally {
secretsMigrateBtn.disabled = false;
}
});
function paintBindings() {
bindingWrap.innerHTML = "";
if (!modelsState || !modelsState.bindings) return;
const profiles = modelsState.profiles || [];
const profileIds = profiles.map((p) => String(p.id));
const roleIds = Array.isArray(modelsState.role_ids) ? modelsState.role_ids : [];
roleIds.forEach((rid) => {
const sel = el("select", { class: "input", disabled: !canConfigureBindings });
sel.appendChild(el("option", { value: "", text: t("models.useGlobal") }));
profiles.forEach((p) => {
sel.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
});
const v = String(modelsState.bindings[rid] || "").trim();
sel.value = profileIds.includes(v) ? v : "";
sel.addEventListener("change", async () => {
if (!canConfigureBindings) return;
const next = Object.assign({}, modelsState.bindings);
next[rid] = String(sel.value || "");
try {
await apiPost("/admin/api/models/bindings", { bindings: next });
await refresh();
} catch (e) {
status.textContent = String(e.message || e);
}
});
bindingWrap.appendChild(el("div", { class: "row" }, [
el("label", { text: t("models.role." + rid) + " " }),
sel,
]));
});
}
function paintOpsAiBindings() {
if (!modelsState || !modelsState.ops_ai_bindings) return;
const profiles = modelsState.profiles || [];
const profileIds = profiles.map((p) => String(p.id));
const roleIds = (Array.isArray(modelsState.role_ids) ? modelsState.role_ids : []).filter((rid) => String(rid || "") !== "manager");
const curSpecialist = String(opsAiSpecialistSelect.value || "").trim();
opsAiSpecialistSelect.innerHTML = "";
roleIds.forEach((rid) => {
opsAiSpecialistSelect.appendChild(el("option", { value: String(rid), text: t("models.role." + rid) }));
});
if (roleIds.includes(curSpecialist)) opsAiSpecialistSelect.value = curSpecialist;
else if (roleIds.length) opsAiSpecialistSelect.value = roleIds[0];
const rid = String(opsAiSpecialistSelect.value || "").trim();
opsAiProfileSelect.innerHTML = "";
profiles.forEach((p) => {
opsAiProfileSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
});
const v = String((modelsState.ops_ai_bindings && modelsState.ops_ai_bindings[rid]) || "").trim();
opsAiProfileSelect.value = profileIds.includes(v) ? v : "";
}
function paintEval() {
evalMetrics.innerHTML = "";
evalTableWrap.innerHTML = "";
const summary = (evalPack && evalPack.summary) || {};
const total = intOr0(summary.total);
const sr = Number(summary.success_rate || 0);
const p95 = intOr0(summary.p95_latency_ms);
evalMetrics.appendChild(el("span", { text: `${t("models.evalTotal")}: ${total} ` }));
evalMetrics.appendChild(el("span", { text: `${t("models.evalSuccess")}: ${(sr * 100).toFixed(1)}% ` }));
evalMetrics.appendChild(el("span", { text: `${t("models.evalP95")}: ${p95} ms` }));
const logs = Array.isArray(evalPack && evalPack.logs) ? evalPack.logs : [];
if (!logs.length) {
evalTableWrap.appendChild(el("div", { class: "muted", text: t("models.noEvalLogs") }));
return;
}
const tbody = el("tbody");
logs.forEach((r) => {
tbody.appendChild(el("tr", {}, [
tdCell(r.timestamp || "", 24),
tdCell(r.session_id || "", 20),
tdCell(r.specialist || "", 12),
tdCell(r.task_kind || "", 14),
tdCell(r.success ? "1" : "0", 4),
tdCell(String(r.latency_ms ?? ""), 8),
tdCell(String(r.notes || ""), 60),
]));
});
evalTableWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("table.timestamp") }),
el("th", { text: "session_id" }),
el("th", { text: t("table.specialist") }),
el("th", { text: "task_kind" }),
el("th", { text: t("table.status") }),
el("th", { text: t("table.durationMs") }),
el("th", { text: "notes" }),
])]),
tbody,
])]));
}
function intOr0(x) {
const n = parseInt(String(x), 10);
return Number.isFinite(n) ? n : 0;
}
function paint() {
if (!modelsState) return;
if (modelsState.ok !== true) {
status.textContent = status.textContent || t("models.loadFailed");
return;
}
const profiles = Array.isArray(modelsState.profiles) ? modelsState.profiles : [];
const builtin = String(modelsState.builtin_ollama_profile_id || "");
activeSelect.innerHTML = "";
profiles.forEach((p) => {
activeSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
});
const aid = String(modelsState.active_llm_profile_id || "");
if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid;
activeSelect.disabled = !canPickActive;
chatModelSelectorVisibleCb.disabled = !canConfigureChatUi;
chatModelSelectorVisibleCb.checked = modelsState.chat_model_selector_visible !== false;
opsAiSpecialistSelect.disabled = !canConfigureBindings;
opsAiProfileSelect.disabled = !canConfigureBindings;
paintBindings();
paintOpsAiBindings();
const selProf = profiles.find((p) => String(p.id) === aid) || profiles[0] || {};
const pid = String(selProf.id || "");
const rowMutable = selProf.mutable !== false;
const canEditFields = canMutateProfiles && rowMutable;
readonlyProfileHint.textContent = rowMutable ? "" : t("models.profileReadonlyHint");
const isBuiltin = pid === builtin;
profName.value = String(selProf.name || "");
const rawMode = String(selProf.mode || "openai").toLowerCase();
const baseAllowedModes = ["openai", "anthropic", "google", "ollama", "rule"];
const allowedModes = rawMode === "openai_responses" ? ["openai_responses", ...baseAllowedModes] : baseAllowedModes;
const modeVal = allowedModes.includes(rawMode) ? rawMode : "openai";
if (isBuiltin) {
fillModeSelect(modeSel, ["ollama"], "ollama");
modeSel.disabled = true;
builtinCap.textContent = t("models.builtinLocked");
} else {
modeSel.disabled = !canEditFields;
builtinCap.textContent = "";
fillModeSelect(modeSel, allowedModes, modeVal);
}
modelInp.value = String(selProf.model || "");
baseInp.value = String(selProf.base_url || "");
thinkingModeCb.checked = !!selProf.thinking_mode_enabled;
reasoningEffortSel.value = String(selProf.reasoning_effort || "");
keyInp.value = String(modelsState.profile_secret || "");
rememberCb.checked = !!selProf.has_key;
profName.disabled = !canEditFields;
modelInp.disabled = !canEditFields;
baseInp.disabled = !canEditFields;
thinkingModeCb.disabled = !canEditFields;
reasoningEffortSel.disabled = !canEditFields;
keyInp.disabled = !canEditFields;
rememberCb.disabled = !canEditFields;
const m = isBuiltin ? "ollama" : modeVal;
warnKey.textContent = "";
openaiHint.textContent = "";
ollamaHint.textContent = "";
if (m === "openai" || m === "openai_responses") {
ollamaHint.textContent = "";
if (modelsState.has_openai_api_key_env) openaiHint.textContent = t("models.openaiKeyHint");
if (selProf.has_key && !String(keyInp.value || "").trim()) {
warnKey.textContent = t("models.warnKeyInDb");
}
} else if (m === "google") {
openaiHint.textContent = "";
ollamaHint.textContent = "";
} else if (m === "ollama") {
ollamaHint.textContent = t("models.ollamaHint");
}
btnDelete.disabled = !canMutateProfiles || !rowMutable || pid === builtin;
modelsGrantsLinkRow.innerHTML = "";
if (modelsState.can_manage_llm_grants) {
modelsGrantsLinkRow.style.display = "block";
modelsGrantsLinkRow.appendChild(el("span", { text: `${t("models.grantsNavHint")} ` }));
modelsGrantsLinkRow.appendChild(el("a", { href: "#/api-grants", text: t("models.linkApiGrants") }));
} else {
modelsGrantsLinkRow.style.display = "none";
}
paintEval();
}
activeSelect.addEventListener("change", async () => {
if (!canPickActive) return;
try {
await apiPost("/admin/api/models/active", { profile_id: activeSelect.value });
await refresh();
} catch (e) {
status.textContent = String(e.message || e);
}
});
chatModelSelectorVisibleCb.addEventListener("change", async () => {
if (!canConfigureChatUi) return;
try {
await apiPost("/admin/api/models/chat-ui", {
chat_model_selector_visible: !!chatModelSelectorVisibleCb.checked,
});
await refresh();
} catch (e) {
status.textContent = String(e.message || e);
}
});
opsAiSpecialistSelect.addEventListener("change", async () => {
paintOpsAiBindings();
});
opsAiProfileSelect.addEventListener("change", async () => {
if (!canConfigureBindings) return;
try {
const rid = String(opsAiSpecialistSelect.value || "").trim();
const next = Object.assign({}, (modelsState && modelsState.ops_ai_bindings) || {});
next[rid] = String(opsAiProfileSelect.value || "");
await apiPost("/admin/api/models/ops-ai/bindings", { bindings: next });
await refresh();
} catch (e) {
status.textContent = String(e.message || e);
}
});
const btnCreate = el("button", { class: "btn btn--primary", text: t("models.createBtn"), onclick: async () => {
if (!canMutateProfiles) return;
try {
await apiPost("/admin/api/models/profiles", {
name: newName.value.trim() || t("models.newProfileDefault"),
mode: newMode.value,
});
newName.value = "";
await refresh();
} catch (e) {
status.textContent = String(e.message || e);
}
}});
const btnSave = el("button", { class: "btn btn--primary", text: t("models.save"), onclick: async () => {
if (!canMutateProfiles) return;
const sel = (modelsState.profiles || []).find((p) => String(p.id) === String(modelsState.active_llm_profile_id || ""));
if (sel && sel.mutable === false) return;
const pid = String(modelsState.active_llm_profile_id || "");
const builtin = String(modelsState.builtin_ollama_profile_id || "");
try {
let modeSave = modeSel.value;
if (pid === builtin) modeSave = "ollama";
await apiRequest("PATCH", "/admin/api/models/profiles/" + encodeURIComponent(pid), {
name: profName.value.trim() || t("models.newProfileDefault"),
mode: modeSave,
model: modelInp.value.trim(),
base_url: baseInp.value.trim(),
thinking_mode_enabled: !!thinkingModeCb.checked,
reasoning_effort: String(reasoningEffortSel.value || ""),
});
await apiRequest("POST", "/admin/api/models/profiles/" + encodeURIComponent(pid) + "/secret", {
remember: rememberCb.checked,
secret: keyInp.value,
});
await refresh();
} catch (e) {
status.textContent = String(e.message || e);
}
}});
const btnDelete = el("button", { class: "btn btn--danger", text: t("models.delete"), onclick: async () => {
if (!canMutateProfiles) return;
const pid = String(modelsState.active_llm_profile_id || "");
const builtin = String(modelsState.builtin_ollama_profile_id || "");
if (pid === builtin) {
status.textContent = t("models.cannotDeleteBuiltin");
return;
}
if (!globalThis.confirm(t("models.deleteConfirm"))) return;
try {
await apiRequest("DELETE", "/admin/api/models/profiles/" + encodeURIComponent(pid), {});
await refresh();
} catch (e) {
status.textContent = String(e.message || e);
}
}});
const btnExpertCreate = el("button", {
class: "btn btn--primary",
text: "Create expert",
onclick: async () => {
if (!hasPermission("admin:tenant:write")) return;
const eid = String(expertNewId.value || "").trim();
if (!eid) {
expertsStatus.textContent = "expert id required";
return;
}
if (!String(expertNewNameEn.value || "").trim()) {
expertsStatus.textContent = "English name is required";
return;
}
if (!String(expertSoul.value || "").trim() && !String(expertRoleSystem.value || "").trim()) {
expertsStatus.textContent = "SOUL.md or ROLE_SYSTEM.md is required";
return;
}
try {
const res = await apiPost("/admin/api/experts", {
id: eid,
display_name_en: String(expertNewNameEn.value || "").trim(),
display_name_zh: String(expertNewNameZh.value || "").trim(),
role: "expert",
files: {
"SOUL.md": expertSoul.value,
"ROLE_SYSTEM.md": expertRoleSystem.value,
},
});
if (!res || res.ok !== true) throw new Error(String((res && res.error) || "create_failed"));
expertNewId.value = "";
expertNewNameEn.value = "";
expertNewNameZh.value = "";
expertsStatus.textContent = "expert created";
markPrewarmReminder("expert_created");
await refresh();
expertsSelect.value = String(res.created || "");
fillExpertFields(currentExpertItem());
} catch (e) {
expertsStatus.textContent = String((e && e.message) || e);
}
},
});
const btnExpertSave = el("button", {
class: "btn btn--primary",
text: "Save expert files",
onclick: async () => {
if (!hasPermission("admin:tenant:write")) return;
const item = currentExpertItem();
if (!item) return;
if (!String(expertNameEn.value || "").trim()) {
expertsStatus.textContent = "English name is required";
return;
}
if (!String(expertSoul.value || "").trim() && !String(expertRoleSystem.value || "").trim()) {
expertsStatus.textContent = "SOUL.md or ROLE_SYSTEM.md is required";
return;
}
try {
const eid = String(item.id || "");
const res = await apiRequest("PATCH", "/admin/api/experts/" + encodeURIComponent(eid), {
display_name_en: String(expertNameEn.value || "").trim(),
display_name_zh: String(expertNameZh.value || "").trim(),
role: String(expertRoleSel.value || "expert"),
files: {
"SOUL.md": expertSoul.value,
"ROLE_SYSTEM.md": expertRoleSystem.value,
},
});
if (!res || res.ok !== true) throw new Error(String((res && res.error) || "update_failed"));
expertsStatus.textContent = "expert saved";
markPrewarmReminder("expert_updated");
await refresh();
expertsSelect.value = eid;
fillExpertFields(currentExpertItem());
} catch (e) {
expertsStatus.textContent = String((e && e.message) || e);
}
},
});
const btnExpertDelete = el("button", {
class: "btn btn--danger",
text: "Delete expert",
onclick: async () => {
if (!hasPermission("admin:tenant:write")) return;
const item = currentExpertItem();
if (!item) return;
if (item.builtin || String(item.role || "") === "system") {
expertsStatus.textContent = "system expert cannot be deleted";
return;
}
const eid = String(item.id || "");
if (!globalThis.confirm(`Delete expert ${eid}?`)) return;
try {
const res = await apiRequest("DELETE", "/admin/api/experts/" + encodeURIComponent(eid), {});
if (!res || res.ok !== true) throw new Error(String((res && res.error) || "delete_failed"));
expertsStatus.textContent = "expert deleted";
markPrewarmReminder("expert_deleted");
await refresh();
} catch (e) {
expertsStatus.textContent = String((e && e.message) || e);
}
},
});
expertsSelect.addEventListener("change", () => {
fillExpertFields(currentExpertItem());
});
await refresh();
if (!modelsState) {
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.models") }),
el("div", { class: "muted", text: t("models.loadFailed") }),
el("pre", { class: "pre", text: status.textContent || "" }),
]),
]);
}
if (modelsState.ok === true && Array.isArray(modelsState.profiles) && modelsState.profiles.length === 0) {
const noProfBody = [
el("div", { class: "card__title", text: t("title.models") }),
el("div", { class: "muted", text: t("models.noProfiles") }),
];
if (modelsState.db_path) {
noProfBody.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${modelsState.db_path}` }));
}
noProfBody.push(status);
return el("div", {}, [el("div", { class: "card" }, noProfBody)]);
}
if (modelsState.ok !== true) {
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.models") }),
el("div", { class: "muted", text: t("models.loadFailed") }),
el("pre", { class: "pre", text: JSON.stringify(modelsState, null, 2) }),
status,
]),
]);
}
const topBits = [readonlyHint, status, modelsGrantsLinkRow];
if (modelsState.db_path) {
topBits.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${modelsState.db_path}` }));
}
// Show secret migration helper when legacy secrets exist.
topBits.push(secretsCard);
return renderPageShell({
title: t("title.models"),
subtitle: t("models.subtitle"),
sections: [
{ id: "models-overview", label: t("models.toc.overview") },
{ id: "models-bindings", label: t("models.toc.bindings") },
{ id: "models-ops-ai", label: t("models.toc.opsAi") },
{ id: "models-api", label: t("models.toc.api") },
{ id: "models-experts", label: "Experts" },
{ id: "models-eval", label: t("models.toc.eval") },
],
}, [
...topBits,
el("div", { class: "page-grid page-grid--two" }, [
renderSectionCard(t("models.sectionActive"), "", [
el("div", { class: "row" }, [el("label", { text: t("models.pickModel") }), activeSelect]),
el("div", { class: "row" }, [el("label", { text: t("models.chatModelSelector") }), chatModelSelectorVisibleCb]),
el("div", { class: "muted", text: t("models.chatModelSelectorHint") }),
], { id: "models-overview" }),
renderSectionCard(t("models.sectionNew"), "", [
el("div", { class: "row" }, [el("label", { text: t("models.profileName") }), newName]),
el("div", { class: "row" }, [el("label", { text: t("models.mode") }), newMode]),
el("div", { class: "row" }, [btnCreate]),
]),
]),
renderSectionCard(t("models.sectionBindings"), t("models.agentBindingHelp"), [
el("div", { class: "muted", text: t("models.bindingsExtraHint") }),
el("div", { class: "muted", text: t("models.bindingsScopeHint") }),
bindingWrap,
], { id: "models-bindings" }),
renderSectionCard(t("models.opsAiTitle"), t("models.opsAiHint"), [
el("div", { class: "row" }, [el("label", { text: t("models.labelSpecialist") }), opsAiSpecialistSelect]),
el("div", { class: "row" }, [el("label", { text: t("models.labelApiProfile") }), opsAiProfileSelect]),
], { id: "models-ops-ai" }),
renderSectionCard(t("models.sectionApi"), "", [
builtinCap,
readonlyProfileHint,
el("div", { class: "row" }, [el("label", { text: t("models.profileName") }), profName]),
el("div", { class: "row" }, [el("label", { text: t("models.mode") }), modeSel]),
el("div", { class: "row" }, [el("label", { text: t("models.model") }), modelInp]),
el("div", { class: "row" }, [el("label", { text: t("models.baseUrl") }), baseInp]),
el("div", { class: "row" }, [el("label", { text: t("models.thinkMode") }), thinkingModeCb]),
el("div", { class: "muted", text: t("models.thinkModeHint") }),
el("div", { class: "row" }, [el("label", { text: t("models.reasoningEffort") }), reasoningEffortSel]),
warnKey,
openaiHint,
ollamaHint,
el("div", { class: "row" }, [el("label", { text: t("models.apiKey") }), keyInp]),
el("div", { class: "row" }, [el("label", { text: t("models.rememberKey") }), rememberCb]),
el("div", { class: "row" }, [btnSave]),
el("div", { class: "row" }, [btnDelete]),
], { id: "models-api" }),
renderSectionCard("Experts", "Runtime registry and workspace prompt files are split into two sections below.", [
el("div", { class: "card u-pad-block" }, [
el("div", { class: "card__title", text: "Runtime Expert Registry" }),
el("div", { class: "row" }, [el("label", { text: "Existing" }), expertsSelect]),
el("div", { class: "row" }, [btnExpertDelete]),
el("div", { class: "row" }, [el("label", { text: "Create ID" }), expertNewId]),
el("div", { class: "row" }, [el("label", { text: "Create Name(en)" }), expertNewNameEn]),
el("div", { class: "row" }, [el("label", { text: "Create Name(zh)" }), expertNewNameZh]),
el("div", { class: "row" }, [btnExpertCreate]),
el("div", { class: "row" }, [el("label", { text: "Name(en)" }), expertNameEn]),
el("div", { class: "row" }, [el("label", { text: "Name(zh)" }), expertNameZh]),
el("div", { class: "row" }, [el("label", { text: "Role" }), expertRoleSel]),
]),
el("div", { class: "card u-pad-block" }, [
el("div", { class: "card__title", text: "Workspace Prompt Files" }),
el("div", { class: "muted", text: "SOUL.md or ROLE_SYSTEM.md is required." }),
el("div", { class: "row" }, [el("label", { text: "SOUL.md" }), expertSoul]),
el("div", { class: "row" }, [el("label", { text: "ROLE_SYSTEM.md" }), expertRoleSystem]),
el("div", { class: "row" }, [btnExpertSave]),
]),
expertsStatus,
], { id: "models-experts" }),
el("div", { class: "card section-card u-hidden", id: "models-eval" }, [evalDetails]),
]);
}
const PLUGINS_PAGE_SIZE = 15;
export { renderModels };