mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
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>
833 lines
34 KiB
JavaScript
833 lines
34 KiB
JavaScript
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 };
|