mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 10:33:18 +08:00
Ship Cursor-only MCP admin and modular Admin/Chat UI.
Plugins/Skills install and edit via mcpServers JSON, with clearer row actions, soft reloads, and instant loading placeholders; also drop MCP market and harden related scheduler/logging/exec guards. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2aff012b48
commit
f6f129931f
61 changed files with 18057 additions and 16327 deletions
833
interfaces/admin/static/js/pages/models.js
Normal file
833
interfaces/admin/static/js/pages/models.js
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
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 state = 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 = (state && state.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");
|
||||
state = 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) {
|
||||
state = 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 (!state || !state.bindings) return;
|
||||
const profiles = state.profiles || [];
|
||||
const profileIds = profiles.map((p) => String(p.id));
|
||||
const roleIds = Array.isArray(state.role_ids) ? state.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(state.bindings[rid] || "").trim();
|
||||
sel.value = profileIds.includes(v) ? v : "";
|
||||
sel.addEventListener("change", async () => {
|
||||
if (!canConfigureBindings) return;
|
||||
const next = Object.assign({}, state.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 (!state || !state.ops_ai_bindings) return;
|
||||
const profiles = state.profiles || [];
|
||||
const profileIds = profiles.map((p) => String(p.id));
|
||||
const roleIds = (Array.isArray(state.role_ids) ? state.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((state.ops_ai_bindings && state.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 (!state) return;
|
||||
if (state.ok !== true) {
|
||||
status.textContent = status.textContent || t("models.loadFailed");
|
||||
return;
|
||||
}
|
||||
const profiles = Array.isArray(state.profiles) ? state.profiles : [];
|
||||
const builtin = String(state.builtin_ollama_profile_id || "");
|
||||
activeSelect.innerHTML = "";
|
||||
profiles.forEach((p) => {
|
||||
activeSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
|
||||
});
|
||||
const aid = String(state.active_llm_profile_id || "");
|
||||
if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid;
|
||||
activeSelect.disabled = !canPickActive;
|
||||
chatModelSelectorVisibleCb.disabled = !canConfigureChatUi;
|
||||
chatModelSelectorVisibleCb.checked = state.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(state.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 (state.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 (state.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({}, (state && state.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 = (state.profiles || []).find((p) => String(p.id) === String(state.active_llm_profile_id || ""));
|
||||
if (sel && sel.mutable === false) return;
|
||||
const pid = String(state.active_llm_profile_id || "");
|
||||
const builtin = String(state.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(state.active_llm_profile_id || "");
|
||||
const builtin = String(state.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 (!state) {
|
||||
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 (state.ok === true && Array.isArray(state.profiles) && state.profiles.length === 0) {
|
||||
const noProfBody = [
|
||||
el("div", { class: "card__title", text: t("title.models") }),
|
||||
el("div", { class: "muted", text: t("models.noProfiles") }),
|
||||
];
|
||||
if (state.db_path) {
|
||||
noProfBody.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` }));
|
||||
}
|
||||
noProfBody.push(status);
|
||||
return el("div", {}, [el("div", { class: "card" }, noProfBody)]);
|
||||
}
|
||||
|
||||
if (state.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(state, null, 2) }),
|
||||
status,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
const topBits = [readonlyHint, status, modelsGrantsLinkRow];
|
||||
if (state.db_path) {
|
||||
topBits.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue