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:
oliver 2026-08-12 19:38:26 +08:00
parent 2aff012b48
commit f6f129931f
61 changed files with 18057 additions and 16327 deletions

View file

@ -0,0 +1,229 @@
import { t, el, apiGet, apiPost, renderPageShell, renderSectionCard } from "../core.js";
async function renderAttachments() {
const status = el("div", { class: "muted", text: "" });
const hint = el("div", { class: "muted", style: "line-height:1.45;", text: t("attachments.excelPolicyHint") });
const loadBtn = el("button", { class: "btn", type: "button", text: t("action.refresh") });
const saveBtn = el("button", { class: "btn btn--primary", type: "button", text: t("attachments.save") });
const resetBtn = el("button", { class: "btn", type: "button", text: t("attachments.resetDefaults") });
const rowInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const colInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const cellInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const maxSheetsInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const largePreviewRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const toolEnabledInput = el("input", { type: "checkbox" });
const toolMinRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const toolMaxBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const sqlTimeoutInput = el("input", { class: "input", type: "number", min: "100", max: "120000", step: "1" });
const imageReplayCapInput = el("input", { class: "input", type: "number", min: "600", max: "30000", step: "1" });
const videoReplayCapInput = el("input", { class: "input", type: "number", min: "600", max: "30000", step: "1" });
const videoTranscriptChunkSizeInput = el("input", { class: "input", type: "number", min: "1", max: "8000", step: "1" });
const videoTranscriptChunkOverlapInput = el("input", { class: "input", type: "number", min: "1", max: "4000", step: "1" });
const archiveMaxDepthInput = el("input", { class: "input", type: "number", min: "1", max: "10", step: "1" });
const archiveMaxFileCountInput = el("input", { class: "input", type: "number", min: "1", max: "20000", step: "1" });
const archiveMaxEntryBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const archiveMaxTotalBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
const clampTimeout = (raw, fallback = 8000) => {
const n = parseInt(String(raw ?? "").trim(), 10);
if (!Number.isFinite(n)) return fallback;
return Math.max(100, Math.min(120000, n));
};
const parsePositiveInt = (v) => {
const n = parseInt(String(v ?? "").trim(), 10);
if (!Number.isFinite(n) || n < 1) return null;
return n;
};
const readUiLimits = () => {
const rows = parsePositiveInt(rowInput.value);
const cols = parsePositiveInt(colInput.value);
const chars = parsePositiveInt(cellInput.value);
const maxSheets = parsePositiveInt(maxSheetsInput.value);
const previewRows = parsePositiveInt(largePreviewRowsInput.value);
const minRows = parsePositiveInt(toolMinRowsInput.value);
const maxBytes = parsePositiveInt(toolMaxBytesInput.value);
const sqlTimeoutMs = parsePositiveInt(sqlTimeoutInput.value);
const imageReplayCapChars = parsePositiveInt(imageReplayCapInput.value);
const videoReplayCapChars = parsePositiveInt(videoReplayCapInput.value);
const videoTranscriptChunkSize = parsePositiveInt(videoTranscriptChunkSizeInput.value);
const videoTranscriptChunkOverlap = parsePositiveInt(videoTranscriptChunkOverlapInput.value);
const archiveMaxDepth = parsePositiveInt(archiveMaxDepthInput.value);
const archiveMaxFileCount = parsePositiveInt(archiveMaxFileCountInput.value);
const archiveMaxEntryBytes = parsePositiveInt(archiveMaxEntryBytesInput.value);
const archiveMaxTotalBytes = parsePositiveInt(archiveMaxTotalBytesInput.value);
if (
rows === null ||
cols === null ||
chars === null ||
maxSheets === null ||
previewRows === null ||
minRows === null ||
maxBytes === null ||
sqlTimeoutMs === null ||
imageReplayCapChars === null ||
videoReplayCapChars === null ||
videoTranscriptChunkSize === null ||
videoTranscriptChunkOverlap === null ||
archiveMaxDepth === null ||
archiveMaxFileCount === null ||
archiveMaxEntryBytes === null ||
archiveMaxTotalBytes === null
) {
throw new Error(t("attachments.invalidNumber"));
}
if (previewRows > 200 && !window.confirm(t("attachments.highPreviewWarn"))) {
throw new Error("cancelled");
}
return {
max_rows_read: rows,
max_columns: cols,
max_cell_chars: chars,
max_excel_sheets: maxSheets,
large_table_preview_rows: previewRows,
tool_mode_enabled: !!toolEnabledInput.checked,
tool_mode_min_rows: minRows,
tool_mode_max_bytes: maxBytes,
sql_timeout_ms: clampTimeout(sqlTimeoutMs, 8000),
image_result_replay_cap_chars: Math.max(600, Math.min(30000, imageReplayCapChars)),
video_result_replay_cap_chars: Math.max(600, Math.min(30000, videoReplayCapChars)),
video_transcript_chunk_size: Math.max(1, Math.min(8000, videoTranscriptChunkSize)),
video_transcript_chunk_overlap: Math.max(1, Math.min(4000, videoTranscriptChunkOverlap)),
archive_max_depth: Math.max(1, Math.min(10, archiveMaxDepth)),
archive_max_file_count: Math.max(1, Math.min(20000, archiveMaxFileCount)),
archive_max_entry_bytes: Math.max(1, archiveMaxEntryBytes),
archive_max_total_uncompressed_bytes: Math.max(1, archiveMaxTotalBytes),
};
};
const applyUiLimits = (limits) => {
const l = limits && typeof limits === "object" ? limits : {};
rowInput.value = String(l.max_rows_read || 5000);
colInput.value = String(l.max_columns || 200);
cellInput.value = String(l.max_cell_chars || 500);
maxSheetsInput.value = String(l.max_excel_sheets || 50);
largePreviewRowsInput.value = String(l.large_table_preview_rows || 20);
toolEnabledInput.checked = !!l.tool_mode_enabled;
toolMinRowsInput.value = String(l.tool_mode_min_rows || 5000);
toolMaxBytesInput.value = String(l.tool_mode_max_bytes || 31457280);
sqlTimeoutInput.value = String(clampTimeout(l.sql_timeout_ms, 8000));
imageReplayCapInput.value = String(Math.max(600, Math.min(30000, parsePositiveInt(l.image_result_replay_cap_chars) || 4000)));
videoReplayCapInput.value = String(Math.max(600, Math.min(30000, parsePositiveInt(l.video_result_replay_cap_chars) || 4000)));
videoTranscriptChunkSizeInput.value = String(Math.max(1, Math.min(8000, parsePositiveInt(l.video_transcript_chunk_size) || 1600)));
videoTranscriptChunkOverlapInput.value = String(Math.max(1, Math.min(4000, parsePositiveInt(l.video_transcript_chunk_overlap) || 200)));
archiveMaxDepthInput.value = String(Math.max(1, Math.min(10, parsePositiveInt(l.archive_max_depth) || 2)));
archiveMaxFileCountInput.value = String(Math.max(1, Math.min(20000, parsePositiveInt(l.archive_max_file_count) || 200)));
archiveMaxEntryBytesInput.value = String(Math.max(1, parsePositiveInt(l.archive_max_entry_bytes) || 10485760));
archiveMaxTotalBytesInput.value = String(Math.max(1, parsePositiveInt(l.archive_max_total_uncompressed_bytes) || 52428800));
};
const load = async () => {
status.textContent = t("chat.loading");
try {
const r = await apiGet("/admin/api/chat/settings/attachment-limits");
const limits = r && typeof r.limits === "object" ? r.limits : {};
applyUiLimits(limits);
status.textContent = "";
} catch (_) {
status.textContent = t("attachments.loadError");
}
};
const save = async () => {
status.textContent = t("chat.sending");
try {
const next = readUiLimits();
await apiPost("/admin/api/chat/settings/attachment-limits", { limits: next });
status.textContent = t("attachments.saved");
setTimeout(() => {
if (status.textContent === t("attachments.saved")) status.textContent = "";
}, 1500);
} catch (e) {
if (String(e && e.message ? e.message : e) === "cancelled") {
status.textContent = "";
return;
}
status.textContent = `${t("common.error")}: ${String(e)}`;
}
};
const reset = async () => {
status.textContent = t("chat.sending");
try {
const resp = await apiPost("/admin/api/chat/settings/attachment-limits", { limits: null });
const limits = resp && typeof resp.limits === "object" ? resp.limits : {};
applyUiLimits(limits);
status.textContent = t("attachments.saved");
setTimeout(() => {
if (status.textContent === t("attachments.saved")) status.textContent = "";
}, 1500);
} catch (e) {
status.textContent = `${t("common.error")}: ${String(e)}`;
}
};
loadBtn.addEventListener("click", load);
saveBtn.addEventListener("click", save);
resetBtn.addEventListener("click", reset);
await load();
const inputRow = (labelText, inputEl) =>
el("div", { style: "display:grid;gap:6px;margin-top:8px;" }, [
el("div", { class: "muted", text: labelText }),
inputEl,
]);
return renderPageShell({
title: t("attachments.title"),
subtitle: t("attachments.subtitle"),
actions: [loadBtn, resetBtn, saveBtn],
sections: [
{ id: "attach-overview", label: t("attachments.toc.overview") },
{ id: "attach-table", label: t("attachments.toc.table") },
{ id: "attach-media", label: t("attachments.toc.media") },
{ id: "attach-archive", label: t("attachments.toc.archive") },
],
}, [
renderSectionCard(t("attachments.card.policy"), t("attachments.excelPolicy"), [hint], { id: "attach-overview" }),
el("div", { class: "page-grid page-grid--two" }, [
renderSectionCard(t("attachments.card.tableMode"), "", [
inputRow(t("attachments.maxRowsRead"), rowInput),
inputRow(t("attachments.maxColumns"), colInput),
inputRow(t("attachments.maxCellChars"), cellInput),
inputRow(t("attachments.maxExcelSheets"), maxSheetsInput),
inputRow(t("attachments.largePreviewRows"), largePreviewRowsInput),
el("label", { class: "muted", style: "display:flex;gap:8px;align-items:center;margin-top:10px;cursor:pointer;" }, [
toolEnabledInput,
el("span", { text: t("attachments.toolModeEnabled") }),
]),
inputRow(t("attachments.toolModeMinRows"), toolMinRowsInput),
inputRow(t("attachments.toolModeMaxBytes"), toolMaxBytesInput),
inputRow(t("attachments.sqlTimeoutMs"), sqlTimeoutInput),
el("div", { class: "muted u-muted-block", text: t("attachments.sqlTimeoutHint") }),
], { id: "attach-table" }),
renderSectionCard(t("attachments.card.media"), "", [
inputRow(t("attachments.imageReplayCapChars"), imageReplayCapInput),
el("div", { class: "muted u-muted-block", text: t("attachments.imageReplayCapHint") }),
inputRow(t("attachments.videoReplayCapChars"), videoReplayCapInput),
el("div", { class: "muted u-muted-block", text: t("attachments.videoReplayCapHint") }),
inputRow(t("attachments.videoTranscriptChunkSize"), videoTranscriptChunkSizeInput),
inputRow(t("attachments.videoTranscriptChunkOverlap"), videoTranscriptChunkOverlapInput),
el("div", { class: "muted u-muted-block", text: t("attachments.videoTranscriptChunkHint") }),
], { id: "attach-media" }),
]),
renderSectionCard(t("attachments.card.archive"), "", [
inputRow(t("attachments.archiveMaxDepth"), archiveMaxDepthInput),
inputRow(t("attachments.archiveMaxFileCount"), archiveMaxFileCountInput),
inputRow(t("attachments.archiveMaxEntryBytes"), archiveMaxEntryBytesInput),
inputRow(t("attachments.archiveMaxTotalBytes"), archiveMaxTotalBytesInput),
el("div", { class: "muted u-muted-block", text: t("attachments.archivePolicyHint") }),
el("div", { class: "u-h-8" }),
status,
], { id: "attach-archive" }),
]);
}
export { renderAttachments };

View file

@ -0,0 +1,416 @@
import { t, el, tdCell, apiGet, formatIds, yesNo } from "../core.js";
async function renderAudit(initialSessionId = "") {
const input = el("input", { class: "input", placeholder: t("audit.sessionIdPlaceholder") });
const resultWrap = el("div");
let selectedTraceId = "";
const onlyFailures = el("input", { type: "checkbox" });
const formatTracePayload = (row) => {
const et = String((row && row.event_type) || "");
const p = (row && typeof row.payload === "object" && row.payload) ? row.payload : {};
if (et === "tool_wire_filter") {
const before = Number(p.tools_before || 0);
const after = Number(p.tools_after || 0);
const hidden = Number(p.hidden_total || 0);
const hiddenMcp = Number(p.hidden_mcp_total || 0);
const preview = Array.isArray(p.hidden_mcp_preview) ? p.hidden_mcp_preview.slice(0, 8).join(", ") : "";
const head = `wire_filter before=${before} after=${after} hidden=${hidden} hidden_mcp=${hiddenMcp}`;
return preview ? `${head} | mcp: ${preview}` : head;
}
if (et === "llm_first_token") {
const ms = Number(p.first_token_ms || 0);
return `ttft_ms=${ms}`;
}
if (et === "memory_retrieval_finished") {
return `memory short=${Number(p.short_term_count || 0)} semantic=${Number(p.semantic_hit_count || 0)} enabled=${Boolean(p.enabled)}`;
}
if (et === "router_decision") {
return `route mode=${String(p.mode || "")} reason=${String(p.reason || "")}`;
}
if (et === "run_started") {
return `run_started run_id=${String(p.run_id || "")} max_attempts=${Number(p.max_attempts || 0)}`;
}
if (et === "attempt_started") {
return `attempt_started run_id=${String(p.run_id || "")} no=${Number(p.attempt_no || 0)}`;
}
if (et === "attempt_finished") {
return `attempt_finished run_id=${String(p.run_id || "")} no=${Number(p.attempt_no || 0)} status=${String(p.status || "")}`;
}
if (et === "run_retry") {
return `run_retry run_id=${String(p.run_id || "")} next_attempt=${Number(p.next_attempt_no || 0)}`;
}
if (et === "run_compact") {
return `run_compact run_id=${String(p.run_id || "")} count=${Number(p.compact_count || 0)}`;
}
if (et === "run_finished") {
return `run_finished run_id=${String(p.run_id || "")} status=${String(p.status || "")} attempts=${Number(p.attempts || 0)}`;
}
if (et === "task_enqueued" || et === "task_claimed" || et === "task_finished" || et === "task_failed") {
return `${et} task_id=${String(p.task_id || "")} ok=${p.ok === undefined ? "-" : Boolean(p.ok)}`;
}
if (et === "turn_stage" && String(p.stage || "") === "finish") {
const ttft = p.first_token_ms;
const elapsed = Number(p.turn_elapsed_ms || 0);
const tools = Number(p.tool_trace_count || 0);
return `finish ttft_ms=${ttft === null || ttft === undefined ? "-" : Number(ttft)} elapsed_ms=${elapsed} tools=${tools}`;
}
return JSON.stringify(p || {});
};
const auditColumnDefs = [
{ key: "timestamp", label: t("table.timestamp"), getter: (r) => r.timestamp || "", maxLen: 24 },
{ key: "specialist", label: t("table.specialist"), getter: (r) => r.specialist || "", maxLen: 24 },
{ key: "action", label: t("table.action"), getter: (r) => r.action || "", maxLen: 24 },
{ key: "status", label: t("table.statusText"), getter: (r) => r.status || "", maxLen: 16 },
{ key: "reason", label: t("table.reason"), getter: (r) => r.reason || "", maxLen: 40 },
{ key: "duration", label: t("table.durationMs"), getter: (r) => String(r.duration_ms || ""), maxLen: 20 },
{
key: "noOutput",
label: t("table.noOutputAttachment"),
getter: (r) => yesNo(((r.payload && typeof r.payload === "object") ? r.payload.no_output_attachment : false)),
maxLen: 8,
},
{
key: "attachments",
label: "attachments",
getter: (r) => {
const payload = (r && typeof r.payload === "object" && r.payload) ? r.payload : {};
const routeIds = formatIds(payload.attachment_ids);
const inIds = formatIds(payload.input_attachment_ids);
const outIds = formatIds(payload.output_attachment_ids);
const outUrls = formatIds(payload.output_attachment_urls);
return [routeIds ? `route:${routeIds}` : "", inIds ? `in:${inIds}` : "", outIds ? `out:${outIds}` : "", outUrls ? `url:${outUrls}` : ""]
.filter(Boolean)
.join(" | ") || "-";
},
maxLen: 120,
},
{
key: "payload",
label: t("table.payload"),
getter: (r) => JSON.stringify(r.payload || {}),
maxLen: 120,
},
];
const traceColumnDefs = [
{ key: "timestamp", label: t("table.timestamp"), getter: (r) => r.timestamp || "", maxLen: 24 },
{ key: "eventType", label: t("table.eventType"), getter: (r) => r.event_type || "", maxLen: 28 },
{ key: "traceId", label: t("table.traceId"), getter: (r) => r.trace_id || "", maxLen: 20 },
{ key: "spanId", label: t("table.spanId"), getter: (r) => r.span_id || "", maxLen: 20 },
{ key: "parentSpanId", label: t("table.parentSpanId"), getter: (r) => r.parent_span_id || "", maxLen: 20 },
{
key: "errorCode",
label: "error_code",
getter: (r) => String((r.payload && r.payload.error_code) || ""),
maxLen: 32,
},
{ key: "payload", label: t("table.payload"), getter: (r) => formatTracePayload(r), maxLen: 120 },
];
const auditVisible = new Set(auditColumnDefs.map((x) => x.key));
const traceVisible = new Set(traceColumnDefs.map((x) => x.key));
const buildColumnToggleRow = (defs, visibleSet) => {
const wrap = el("div", { class: "row" });
defs.forEach((d) => {
const cb = el("input", { type: "checkbox" });
cb.checked = visibleSet.has(d.key);
cb.addEventListener("change", () => {
if (cb.checked) visibleSet.add(d.key);
else visibleSet.delete(d.key);
});
wrap.appendChild(el("label", { class: "kv" }, [cb, document.createTextNode(" " + d.label)]));
});
return wrap;
};
const auditColsToggle = buildColumnToggleRow(auditColumnDefs, auditVisible);
const traceColsToggle = buildColumnToggleRow(traceColumnDefs, traceVisible);
const runQuery = async () => {
const sid = input.value.trim();
const a = await apiGet("/admin/api/audit" + (sid ? ("?session_id=" + encodeURIComponent(sid)) : ""));
const tr = await apiGet("/admin/api/trace" + (sid ? ("?session_id=" + encodeURIComponent(sid)) : ""));
const tasks = await apiGet("/admin/api/oclaw/tasks?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&") : "") + "limit=120");
const runs = await apiGet("/admin/api/oclaw/runs?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&") : "") + "limit=80&include_attempts=1");
const health = await apiGet(
"/admin/api/audit/session-health?" + (sid ? ("session_id=" + encodeURIComponent(sid) + "&limit=20") : "limit=40"),
);
const auditRows = Array.isArray(a.audit) ? a.audit : [];
const traceRows = Array.isArray(tr.trace) ? tr.trace : [];
const traceIds = Array.from(new Set(traceRows.map((r) => String(r.trace_id || "")).filter(Boolean)));
if (selectedTraceId && !traceIds.includes(selectedTraceId)) selectedTraceId = "";
const filteredByTrace = selectedTraceId ? traceRows.filter((r) => String(r.trace_id || "") === selectedTraceId) : traceRows;
const filteredTraceRows = onlyFailures.checked
? filteredByTrace.filter((r) => {
const p = (r && typeof r.payload === "object") ? r.payload : {};
return p.ok === false || String(p.error_code || "").trim() !== "";
})
: filteredByTrace;
const auditBody = el("tbody");
const visibleAuditCols = auditColumnDefs.filter((c) => auditVisible.has(c.key));
if (!auditRows.length) {
auditBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: String(Math.max(1, visibleAuditCols.length)) })]));
} else {
auditRows.forEach((r) => {
const row = el("tr");
visibleAuditCols.forEach((c) => row.appendChild(tdCell(c.getter(r), c.maxLen || 120)));
auditBody.appendChild(row);
});
}
const traceBody = el("tbody");
const visibleTraceCols = traceColumnDefs.filter((c) => traceVisible.has(c.key));
if (!filteredTraceRows.length) {
traceBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: String(Math.max(1, visibleTraceCols.length)) })]));
} else {
filteredTraceRows.forEach((r) => {
const row = el("tr");
visibleTraceCols.forEach((c) => row.appendChild(tdCell(c.getter(r), c.maxLen || 120)));
traceBody.appendChild(row);
});
}
const pre = el("div", { class: "pre", text: JSON.stringify({ audit: a, trace: tr, health }, null, 2) });
resultWrap.innerHTML = "";
const healthItems = Array.isArray(health.items) ? health.items : [];
const healthRows = healthItems.map((x) => el("tr", {}, [
tdCell(String(x.session_id || ""), 26),
tdCell(String(x.title || ""), 26),
tdCell(String(x.assistant_count || 0), 8),
tdCell(String(x.tool_count || 0), 8),
tdCell(String(x.mcp_tool_count || 0), 8),
tdCell(String(x.last_tool_at || "-"), 20),
tdCell(String(x.status || ""), 20),
]));
resultWrap.appendChild(el("div", { class: "card" }, [
el("div", { class: "card__title", text: "Session Tool Health" }),
el("div", { class: "muted", text: `warn=${Number(health.warn_count || 0)} (assistant replied but no tool uses)` }),
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: "session_id" }),
el("th", { text: "title" }),
el("th", { text: "assistant_msgs" }),
el("th", { text: "tool_uses" }),
el("th", { text: "mcp_calls" }),
el("th", { text: "last_tool_at" }),
el("th", { text: "status" }),
])]),
el("tbody", {}, healthRows.length ? healthRows : [el("tr", {}, [el("td", { text: "-", colspan: "7" })])]),
])]),
]));
const taskRows = Array.isArray(tasks.tasks) ? tasks.tasks : [];
const queued = Number((tasks.counts || {}).queued || 0);
const claimed = Number((tasks.counts || {}).claimed || 0);
const done = Number((tasks.counts || {}).done || 0);
const failed = Number((tasks.counts || {}).failed || 0);
resultWrap.appendChild(el("div", { class: "card" }, [
el("div", { class: "card__title", text: "oclaw Tasks" }),
el("div", { class: "muted", text: `queued=${queued} claimed=${claimed} done=${done} failed=${failed}` }),
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: "task_id" }),
el("th", { text: "status" }),
el("th", { text: "attempt_count" }),
el("th", { text: "session_id" }),
el("th", { text: "updated_at" }),
el("th", { text: "last_error" }),
])]),
el(
"tbody",
{},
taskRows.length
? taskRows.slice(0, 80).map((x) => el("tr", {}, [
tdCell(String(x.id || ""), 24),
tdCell(String(x.status || ""), 10),
tdCell(String(x.attempt_count || 0), 10),
tdCell(String(x.session_id || ""), 20),
tdCell(String(x.updated_at || ""), 22),
tdCell(String(x.last_error || ""), 40),
]))
: [el("tr", {}, [el("td", { text: "-", colspan: "6" })])],
),
])]),
]));
const runRows = Array.isArray(runs.runs) ? runs.runs : [];
const runRunning = Number((runs.counts || {}).running || 0);
const runSuccess = Number((runs.counts || {}).success || 0);
const runFailed = Number((runs.counts || {}).failed || 0);
const rp = (runs && typeof runs.retry_policy === "object" && runs.retry_policy) ? runs.retry_policy : {};
const effRetryCodes = Array.isArray(rp.effective_retryable_error_codes) ? rp.effective_retryable_error_codes.join(", ") : "";
resultWrap.appendChild(el("div", { class: "card" }, [
el("div", { class: "card__title", text: "oclaw Runs" }),
el("div", { class: "muted", text: `running=${runRunning} success=${runSuccess} failed=${runFailed}` }),
el("div", { class: "muted", text: effRetryCodes ? `retryable_error_codes=${effRetryCodes}` : "" }),
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: "run_id" }),
el("th", { text: "status" }),
el("th", { text: "session_id" }),
el("th", { text: "attempts" }),
el("th", { text: "updated_at" }),
])]),
el(
"tbody",
{},
runRows.length
? runRows.slice(0, 60).map((x) => {
const attempts = Array.isArray(x.attempts) ? x.attempts : [];
const attemptSummary = attempts.length
? attempts.map((a) => `${Number(a.attempt_no || 0)}:${String(a.status || "")}`).join(" | ")
: "-";
return el("tr", {}, [
tdCell(String(x.run_id || ""), 24),
tdCell(String(x.status || ""), 10),
tdCell(String(x.session_id || ""), 20),
tdCell(attemptSummary, 60),
tdCell(String(x.updated_at || ""), 22),
]);
})
: [el("tr", {}, [el("td", { text: "-", colspan: "5" })])],
),
])]),
]));
const deliveryRows = auditRows
.filter((r) => r && typeof r === "object" && r.action === "specialist_step")
.map((r) => {
const payload = (r && typeof r.payload === "object" && r.payload) ? r.payload : {};
const d = (payload && typeof payload.specialist_delivery === "object" && payload.specialist_delivery)
? payload.specialist_delivery
: null;
if (!d) return null;
const traces = Array.isArray(d.tool_traces) ? d.tool_traces : [];
const toolsText = traces.length
? traces
.map((x) => `${String(x.name || "")}(ok=${Boolean(x.ok)}, ${Number(x.latency_ms || 0)}ms)`)
.join(" | ")
: "-";
return {
timestamp: String(r.timestamp || ""),
specialist: String(r.specialist || d.specialist || ""),
stepId: String(d.step_id || payload.step_id || ""),
answer: String(d.answer_text || ""),
tools: toolsText,
notes: String(d.notes || ""),
};
})
.filter(Boolean);
resultWrap.appendChild(el("div", { class: "card" }, [
el("div", { class: "card__title", text: "Specialist Delivery Timeline" }),
el("div", { class: "muted", text: t("audit.specialistSummaryHint") }),
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: "timestamp" }),
el("th", { text: "specialist" }),
el("th", { text: "step_id" }),
el("th", { text: "answer_for_user" }),
el("th", { text: "tools_executed_inside_specialist" }),
el("th", { text: "notes" }),
])]),
el(
"tbody",
{},
deliveryRows.length
? deliveryRows.map((x) =>
el("tr", {}, [
tdCell(x.timestamp, 24),
tdCell(x.specialist, 14),
tdCell(x.stepId, 20),
tdCell(x.answer, 120),
tdCell(x.tools, 120),
tdCell(x.notes, 24),
]),
)
: [el("tr", {}, [el("td", { text: "-", colspan: "6" })])],
),
])]),
]));
// Turn summary (group by trace_id) for quick navigation.
if (traceIds.length) {
const sel = el("select", { class: "input" });
sel.appendChild(el("option", { value: "", text: `trace_id (${traceIds.length})` }));
traceIds.forEach((tid) => sel.appendChild(el("option", { value: tid, text: tid })));
sel.value = selectedTraceId;
const summary = el("div", { class: "muted", text: "" });
const failSummary = el("div", { class: "muted", text: "" });
const recomputeSummary = () => {
const rows = filteredTraceRows;
const counts = {};
rows.forEach((r) => {
const et = String(r.event_type || "");
counts[et] = (counts[et] || 0) + 1;
});
const get = (k) => Number(counts[k] || 0);
summary.textContent = [
`route_decided=${get("route_decided")}`,
`plan_created=${get("plan_created")}`,
`tool_wire_filter=${get("tool_wire_filter")}`,
`llm_first_token=${get("llm_first_token")}`,
`llm_called=${get("llm_called")}`,
`llm_result=${get("llm_result")}`,
`tool_use_called=${get("tool_called")}`,
`tool_result=${get("tool_result")}`,
].join(" | ");
const agg = {};
rows.forEach((r) => {
const p = (r && typeof r.payload === "object") ? r.payload : {};
const ec = String(p.error_code || "").trim();
if (!ec) return;
agg[ec] = (agg[ec] || 0) + 1;
});
const parts = Object.entries(agg).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, v]) => `${k}:${v}`);
failSummary.textContent = parts.length ? `Failure summary: ${parts.join(" | ")}` : "Failure summary: -";
};
sel.addEventListener("change", () => {
selectedTraceId = String(sel.value || "");
runQuery();
});
recomputeSummary();
resultWrap.appendChild(el("div", { class: "card" }, [
el("div", { class: "card__title", text: "Turn summary" }),
el("div", { class: "row" }, [sel, el("label", { class: "kv" }, [onlyFailures, document.createTextNode(" only failures")])]),
summary,
failSummary,
]));
}
resultWrap.appendChild(el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("audit.auditTable") }),
el("div", { class: "muted", text: t("audit.columns") }),
auditColsToggle,
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, auditColumnDefs.filter((c) => auditVisible.has(c.key)).map((c) => el("th", { text: c.label })))]),
auditBody,
])]),
]));
resultWrap.appendChild(el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("audit.traceTable") }),
el("div", { class: "muted", text: t("audit.columns") }),
traceColsToggle,
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, traceColumnDefs.filter((c) => traceVisible.has(c.key)).map((c) => el("th", { text: c.label })))]),
traceBody,
])]),
]));
const details = el("details", { class: "details" }, [
el("summary", { text: t("audit.rawJson") }),
pre,
]);
resultWrap.appendChild(el("div", { class: "card" }, [details]));
};
const btn = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: async () => {
await runQuery();
}});
onlyFailures.addEventListener("change", runQuery);
auditColsToggle.addEventListener("change", runQuery);
traceColsToggle.addEventListener("change", runQuery);
const card = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("audit.title") }),
el("div", { class: "row" }, [input, btn]),
el("div", { class: "muted", text: t("audit.note") }),
]);
if (initialSessionId) {
input.value = initialSessionId;
await runQuery();
}
return el("div", {}, [card, resultWrap]);
}
export { renderAudit };

View file

@ -0,0 +1,32 @@
import { state } from "../core.js";
function hasPermission(permission) {
const role = String((state.authSession && state.authSession.role) || "");
const perms = new Set(Array.isArray(state.authSession && state.authSession.permissions) ? state.authSession.permissions : []);
if (role === "owner") return true;
return perms.has(permission);
}
/** 与 models_api._can_manage_llm_grants 一致:仅控制台 administrator 用户可管理授权 */
function canManageApiGrants() {
const un = String((state.authSession && state.authSession.username) || "").trim().toLowerCase();
if (un !== "administrator") return false;
const role = String((state.authSession && state.authSession.role) || "").trim();
if (role === "owner") return true;
return hasPermission("admin:tenant:write");
}
function isAdministratorUsername() {
return String((state.authSession && state.authSession.username) || "").trim().toLowerCase() === "administrator";
}
/** 与 models_api._profile_shareable_for_admin_grant 一致 */
function profileShareableForGrant(p, sessionUserId) {
if (!p || p.is_builtin) return false;
const own = String(p.owner_user_id || "").trim();
if (!own) return true;
return own === String(sessionUserId || "").trim();
}
export { hasPermission, canManageApiGrants, isAdministratorUsername, profileShareableForGrant };

View file

@ -0,0 +1,668 @@
import { t, tf, el, tdCell, apiGet, resolveAdminApiUrl, getStoredAuthToken, SESSION_MONITOR_ROLE_FILTER_KEY, formatAuditActor, renderPageShell, renderSectionCard, enableTableColumnResize } from "../core.js";
async function renderAdminAudit() {
const action = el("input", { class: "input", placeholder: t("adminAudit.action") });
const actor = el("input", { class: "input", placeholder: t("adminAudit.actor") });
const status = el("input", { class: "input", placeholder: t("adminAudit.status") });
const tbody = el("tbody");
const pager = el("div", { class: "row", style: "gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px;" });
const pageInfo = el("span", { class: "muted", text: tf("sessionMonitor.pageInfo", { page: 1, totalPages: 1 }) });
const totalInfo = el("span", { class: "muted", text: tf("adminAudit.total", { total: 0 }) });
const pageInput = el("input", {
class: "input",
type: "number",
min: "1",
step: "1",
placeholder: t("adminAudit.jumpPlaceholder"),
style: "width:90px;",
});
let page = 1;
const pageSize = 50;
let total = 0;
const totalPages = () => Math.max(1, Math.ceil((Number(total) || 0) / pageSize));
const setPager = () => {
const tp = totalPages();
pageInfo.textContent = tf("sessionMonitor.pageInfo", { page, totalPages: tp });
totalInfo.textContent = tf("adminAudit.total", { total });
pageInput.value = String(page);
btnPrev.disabled = page <= 1;
btnNext.disabled = page >= tp;
};
const load = async () => {
const p = new URLSearchParams();
p.set("limit", String(pageSize));
p.set("offset", String((Math.max(1, page) - 1) * pageSize));
if (action.value.trim()) p.set("action", action.value.trim());
if (actor.value.trim()) p.set("actor_user_id", actor.value.trim());
if (status.value.trim()) p.set("status", status.value.trim());
const resp = await apiGet("/admin/api/admin-audit?" + p.toString());
total = Math.max(0, Number(resp.total || 0) || 0);
const rows = Array.isArray(resp.items) ? resp.items : [];
tbody.innerHTML = "";
if (!rows.length) {
tbody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })]));
setPager();
return;
}
rows.forEach((r) => {
tbody.appendChild(el("tr", {}, [
tdCell(r.timestamp || "", 24),
tdCell(formatAuditActor(r), 28),
tdCell(r.action || "", 20),
tdCell(r.target_type || "", 16),
tdCell(r.target_id || "", 24),
tdCell(r.status || "", 12),
tdCell(r.actor_tenant_id || "", 24),
tdCell(JSON.stringify(r.detail || {}), 120),
]));
});
setPager();
};
const btn = el("button", {
class: "btn btn--primary",
text: t("audit.query"),
onclick: async () => {
page = 1;
await load();
},
});
const btnPrev = el("button", {
class: "btn btn--small",
text: t("sessionMonitor.pagePrev"),
disabled: true,
onclick: async () => {
if (page <= 1) return;
page -= 1;
await load();
},
});
const btnNext = el("button", {
class: "btn btn--small",
text: t("sessionMonitor.pageNext"),
disabled: true,
onclick: async () => {
const tp = totalPages();
if (page >= tp) return;
page += 1;
await load();
},
});
const btnJump = el("button", {
class: "btn btn--small",
text: t("adminAudit.jump"),
onclick: async () => {
const tp = totalPages();
let target = parseInt(String(pageInput.value || "").trim(), 10);
if (!Number.isFinite(target)) target = page;
target = Math.max(1, Math.min(tp, target));
if (target === page) {
setPager();
return;
}
page = target;
await load();
},
});
pageInput.addEventListener("keydown", async (ev) => {
if (ev.key !== "Enter") return;
ev.preventDefault();
btnJump.click();
});
pager.appendChild(btnPrev);
pager.appendChild(btnNext);
pager.appendChild(pageInfo);
pager.appendChild(totalInfo);
pager.appendChild(pageInput);
pager.appendChild(btnJump);
await load();
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("adminAudit.title") }),
el("div", { class: "row" }, [action, actor, status, btn]),
pager,
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("table.timestamp") }),
el("th", { text: t("adminAudit.actor") }),
el("th", { text: t("adminAudit.action") }),
el("th", { text: "target_type" }),
el("th", { text: "target_id" }),
el("th", { text: t("adminAudit.status") }),
el("th", { text: "tenant_id" }),
el("th", { text: t("table.payload") }),
])]),
tbody,
])]),
]),
]);
}
async function renderSessionMonitor() {
const userQ = el("input", { class: "input", placeholder: t("sessionMonitor.filterUser") });
const sessQ = el("input", { class: "input", placeholder: t("sessionMonitor.filterSession") });
const activeOnlyChk = el("input", { type: "checkbox" });
const selectedHint = el("div", { class: "muted", text: t("sessionMonitor.selectUserFirst") });
const userBody = el("tbody");
const sessBody = el("tbody");
const sessionPagerTop = el("div", { class: "row" });
const sessionPagerBottom = el("div", { class: "row" });
const detailBody = el("tbody");
const detailStatus = el("div", { class: "muted", text: t("sessionMonitor.noMessages") });
const detailPager = el("div", { class: "row" });
const detailRoleFilter = el("select", { class: "input" });
const detailModal = el("div", { class: "session-monitor-modal u-hidden" });
const detailModalCard = el("div", { class: "session-monitor-modal__card" });
const totalsWrap = el("div", { class: "row" });
let selectedUserId = "";
let selectedSessionId = "";
let sessionLimit = 20;
let sessionOffset = 0;
let sessionTotal = 0;
let detailMessages = [];
let detailPage = 1;
const detailPageSize = 20;
let sessionMenuEl = null;
const rebuildDetailRoleFilter = () => {
const saved = String(localStorage.getItem(SESSION_MONITOR_ROLE_FILTER_KEY) || "").trim();
const prev = String(detailRoleFilter.value || saved || "all");
detailRoleFilter.innerHTML = "";
detailRoleFilter.appendChild(el("option", { value: "all", text: t("sessionMonitor.roleAll") }));
detailRoleFilter.appendChild(el("option", { value: "user", text: t("sessionMonitor.roleUser") }));
detailRoleFilter.appendChild(el("option", { value: "assistant", text: t("sessionMonitor.roleAssistant") }));
detailRoleFilter.appendChild(el("option", { value: "tool", text: t("sessionMonitor.roleTool") }));
detailRoleFilter.value = ["all", "user", "assistant", "tool"].includes(prev) ? prev : "all";
};
const buildPageInfo = (page, totalPages) => tf("sessionMonitor.pageInfo", { page, totalPages });
const downloadSessionExport = async (sessionId, format) => {
const q = format === "json" ? "format=json" : "format=md";
const path = `/admin/api/chat/sessions/${encodeURIComponent(sessionId)}/export?${q}`;
const url = resolveAdminApiUrl(path);
const tok = getStoredAuthToken();
const res = await fetch(url, {
headers: tok ? { authorization: `Bearer ${tok}` } : {},
});
if (!res.ok) throw new Error(`export_failed_${res.status}`);
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `chat-${String(sessionId || "").slice(0, 8)}.${format === "json" ? "json" : "md"}`;
a.click();
URL.revokeObjectURL(a.href);
};
const closeSessionMenu = () => {
if (sessionMenuEl && sessionMenuEl.parentNode) {
sessionMenuEl.remove();
}
sessionMenuEl = null;
};
const closeDetailModal = () => {
detailModal.style.display = "none";
};
const renderTotals = (totals) => {
totalsWrap.innerHTML = "";
const cards = [
{ key: "total_tokens_est", label: t("sessionMonitor.totalTokensEst") },
{ key: "active_sessions_30m", label: t("sessionMonitor.activeSessions30m") },
{ key: "active_logins_30m", label: t("sessionMonitor.activeLogins30m") },
{ key: "users_count", label: t("sessionMonitor.usersCount") },
];
cards.forEach((c) => {
totalsWrap.appendChild(
el("div", { class: "kv", text: `${c.label}: ${String((totals && totals[c.key]) ?? 0)}` }),
);
});
};
const renderDetailPager = () => {
detailPager.innerHTML = "";
const role = String(detailRoleFilter.value || "all");
const filtered = Array.isArray(detailMessages)
? detailMessages.filter((m) => {
if (role === "all") return true;
const r = String(m.role || "").toLowerCase();
return role === "tool" ? r === "tool" || r === "function" : r === role;
})
: [];
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / detailPageSize));
const info = el("span", { class: "muted", text: buildPageInfo(detailPage, totalPages) });
const btnPrev = el("button", {
class: "btn btn--small",
text: t("sessionMonitor.pagePrev"),
disabled: detailPage <= 1,
onclick: () => {
if (detailPage <= 1) return;
detailPage -= 1;
renderDetailMessages();
},
});
const btnNext = el("button", {
class: "btn btn--small",
text: t("sessionMonitor.pageNext"),
disabled: detailPage >= totalPages,
onclick: () => {
if (detailPage >= totalPages) return;
detailPage += 1;
renderDetailMessages();
},
});
detailPager.appendChild(btnPrev);
detailPager.appendChild(btnNext);
detailPager.appendChild(info);
};
const renderDetailMessages = () => {
detailBody.innerHTML = "";
if (!selectedSessionId) {
detailStatus.textContent = t("sessionMonitor.selectUserFirst");
detailPager.innerHTML = "";
return;
}
const role = String(detailRoleFilter.value || "all");
const all = Array.isArray(detailMessages) ? detailMessages : [];
const filtered = all.filter((m) => {
if (role === "all") return true;
const r = String(m.role || "").toLowerCase();
return role === "tool" ? r === "tool" || r === "function" : r === role;
});
if (!filtered.length) {
detailStatus.textContent = t("sessionMonitor.noMessages");
detailPager.innerHTML = "";
return;
}
const totalPages = Math.max(1, Math.ceil(filtered.length / detailPageSize));
if (detailPage > totalPages) detailPage = totalPages;
const start = (detailPage - 1) * detailPageSize;
const rows = filtered.slice(start, start + detailPageSize);
detailStatus.textContent = `session_id: ${selectedSessionId} | ${filtered.length}/${all.length}`;
rows.forEach((m) => {
detailBody.appendChild(
el("tr", {}, [
tdCell(m.id || "", 8),
tdCell(m.role || "", 10),
tdCell(m.content || "", 120),
tdCell(m.timestamp || "", 24),
]),
);
});
renderDetailPager();
};
const loadSessionDetail = async (sessionId, opts = {}) => {
const openModal = Boolean(opts && opts.openModal);
selectedSessionId = String(sessionId || "");
detailPage = 1;
detailBody.innerHTML = "";
detailStatus.textContent = `${t("chat.loading")}...`;
detailPager.innerHTML = "";
if (!selectedSessionId) {
detailStatus.textContent = t("sessionMonitor.noMessages");
if (openModal) detailModal.style.display = "flex";
return;
}
try {
const resp = await apiGet(`/admin/api/chat/sessions/${encodeURIComponent(selectedSessionId)}/messages`);
detailMessages = Array.isArray(resp.messages) ? resp.messages : [];
renderDetailMessages();
} catch (err) {
detailMessages = [];
detailBody.innerHTML = "";
detailPager.innerHTML = "";
detailStatus.textContent = `${t("chat.error")}: ${String(err && err.message ? err.message : err)}`;
}
if (openModal) detailModal.style.display = "flex";
};
const renderSessionPager = () => {
const totalPages = Math.max(1, Math.ceil(Math.max(0, sessionTotal) / sessionLimit));
const current = Math.floor(sessionOffset / sessionLimit) + 1;
const row = el("div", { class: "row" }, [
el("button", {
class: "btn btn--small",
text: t("sessionMonitor.pagePrev"),
disabled: sessionOffset <= 0,
onclick: async () => {
if (sessionOffset <= 0) return;
sessionOffset = Math.max(0, sessionOffset - sessionLimit);
await loadSessions();
},
}),
el("button", {
class: "btn btn--small",
text: t("sessionMonitor.pageNext"),
disabled: sessionOffset + sessionLimit >= sessionTotal,
onclick: async () => {
if (sessionOffset + sessionLimit >= sessionTotal) return;
sessionOffset += sessionLimit;
await loadSessions();
},
}),
el("span", { class: "muted", text: buildPageInfo(current, totalPages) }),
]);
return row;
};
const updateSessionPagers = () => {
sessionPagerTop.innerHTML = "";
sessionPagerBottom.innerHTML = "";
sessionPagerTop.appendChild(renderSessionPager());
sessionPagerBottom.appendChild(renderSessionPager());
};
detailRoleFilter.addEventListener("change", () => {
try {
localStorage.setItem(SESSION_MONITOR_ROLE_FILTER_KEY, String(detailRoleFilter.value || "all"));
} catch (_) {}
detailPage = 1;
renderDetailMessages();
});
const loadSessions = async () => {
sessBody.innerHTML = "";
if (!selectedUserId) {
sessBody.appendChild(el("tr", {}, [el("td", { text: t("sessionMonitor.selectUserFirst"), colspan: "8" })]));
selectedHint.textContent = t("sessionMonitor.selectUserFirst");
sessionTotal = 0;
updateSessionPagers();
return;
}
const sp = new URLSearchParams();
sp.set("user_id", selectedUserId);
sp.set("limit", String(sessionLimit));
sp.set("offset", String(sessionOffset));
if (sessQ.value.trim()) sp.set("q", sessQ.value.trim());
if (activeOnlyChk.checked) sp.set("active_only", "1");
const resp = await apiGet("/admin/api/chat/admin/sessions?" + sp.toString());
const rows = Array.isArray(resp.sessions) ? resp.sessions : [];
sessionTotal = Number(resp.total || 0);
selectedHint.textContent = `${t("table.userId")}: ${selectedUserId}`;
if (!rows.length) {
sessBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "8" })]));
if (selectedSessionId && !rows.some((x) => String(x.session_id || "") === selectedSessionId)) {
selectedSessionId = "";
detailMessages = [];
renderDetailMessages();
}
updateSessionPagers();
return;
}
rows.forEach((r) => {
const sid = String(r.session_id || "");
const btnMore = el("button", {
class: "chat-sess-more" + (sid === selectedSessionId ? " chat-sess-more--active" : ""),
text: "⋯",
title: t("chat.sessionMenu"),
onclick: (ev) => {
ev.stopPropagation();
closeSessionMenu();
const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [
el("button", {
class: "chat-sess-menu-item",
text: t("sessionMonitor.viewDetail"),
onclick: async () => {
closeSessionMenu();
await loadSessionDetail(sid, { openModal: true });
},
}),
el("button", {
class: "chat-sess-menu-item",
text: t("sessionMonitor.viewAudit"),
onclick: () => {
closeSessionMenu();
location.hash = `#/audit?session_id=${encodeURIComponent(sid)}`;
},
}),
el("button", {
class: "chat-sess-menu-item",
text: t("sessionMonitor.exportMd"),
onclick: async () => {
closeSessionMenu();
await downloadSessionExport(sid, "md");
},
}),
el("button", {
class: "chat-sess-menu-item",
text: t("sessionMonitor.exportJson"),
onclick: async () => {
closeSessionMenu();
await downloadSessionExport(sid, "json");
},
}),
]);
const rect = ev.currentTarget.getBoundingClientRect();
document.body.appendChild(menu);
// Clamp into viewport; flip above if near bottom.
const mrect = menu.getBoundingClientRect();
const pad = 8;
let left = rect.left - 120;
let top = rect.bottom + 4;
if (top + mrect.height > window.innerHeight - pad) {
top = rect.top - 4 - mrect.height;
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
sessionMenuEl = menu;
const close = (e) => {
if (!menu.contains(e.target)) {
closeSessionMenu();
document.removeEventListener("click", close);
}
};
setTimeout(() => document.addEventListener("click", close), 0);
},
});
sessBody.appendChild(
el(
"tr",
{
"data-session-id": sid,
class: sid === selectedSessionId ? "session-monitor-row--active" : "",
onclick: async () => {
selectedSessionId = sid;
closeSessionMenu();
const sessionRows = Array.from(sessBody.querySelectorAll("tr[data-session-id]"));
sessionRows.forEach((tr) => {
const curr = String(tr.getAttribute("data-session-id") || "");
tr.classList.toggle("session-monitor-row--active", curr === selectedSessionId);
});
},
},
[
tdCell(sid, 24),
tdCell(r.title || "", 30),
tdCell(r.username || "", 16),
tdCell(r.message_count || "", 10),
tdCell(r.last_message_at || "", 24),
tdCell(r.is_active_30m ? "yes" : "no", 8),
el("td", { class: "table__cell-actions" }, [btnMore]),
],
),
);
});
updateSessionPagers();
};
const loadUsers = async () => {
const p = new URLSearchParams();
p.set("limit", "200");
if (userQ.value.trim()) p.set("q", userQ.value.trim());
const resp = await apiGet("/admin/api/chat/admin/user-stats?" + p.toString());
renderTotals(resp.totals || {});
const rows = Array.isArray(resp.users) ? resp.users : [];
userBody.innerHTML = "";
if (!rows.length) {
userBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "9" })]));
selectedUserId = "";
selectedSessionId = "";
detailMessages = [];
await loadSessions();
return;
}
if (!selectedUserId || !rows.some((x) => String(x.user_id || "") === selectedUserId)) {
selectedUserId = String(rows[0].user_id || "");
}
rows.forEach((r) => {
const uid = String(r.user_id || "");
const pickBtn = el("button", {
class: "btn btn--small",
"data-user-pick": "1",
"data-user-id": uid,
text: uid === selectedUserId ? "●" : "○",
onclick: async () => {
selectedUserId = uid;
sessionOffset = 0;
selectedSessionId = "";
detailMessages = [];
const pickBtns = Array.from(userBody.querySelectorAll("button[data-user-pick='1']"));
pickBtns.forEach((btnEl) => {
const id = String(btnEl.getAttribute("data-user-id") || "");
btnEl.textContent = id === selectedUserId ? "●" : "○";
});
await loadSessions();
},
});
userBody.appendChild(
el("tr", {}, [
el("td", {}, [pickBtn]),
tdCell(r.username || "", 16),
tdCell(r.display_name || "", 16),
tdCell(r.role || "", 10),
tdCell(r.sessions_count || "", 10),
tdCell(r.active_sessions_30m || "", 10),
tdCell(r.active_login_30m || "", 10),
tdCell(r.total_tokens_est || "", 14),
tdCell(r.last_message_at || "", 24),
]),
);
});
await loadSessions();
};
const btnQueryUsers = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: loadUsers });
const btnQuerySessions = el("button", {
class: "btn",
text: t("audit.query"),
onclick: async () => {
sessionOffset = 0;
selectedSessionId = "";
detailMessages = [];
await loadSessions();
},
});
const activeOnlyLabel = el("label", { class: "row" }, [
activeOnlyChk,
el("span", { class: "muted", text: t("sessionMonitor.activeSessions30m") }),
]);
await loadUsers();
rebuildDetailRoleFilter();
const btnCloseDetail = el("button", {
class: "btn btn--small",
text: t("sessionMonitor.closeDetail"),
onclick: () => {
selectedSessionId = "";
detailMessages = [];
detailBody.innerHTML = "";
detailStatus.textContent = t("sessionMonitor.noMessages");
detailPager.innerHTML = "";
closeDetailModal();
loadSessions().catch(() => {});
},
});
const detailTable = el("table", { class: "table table--compact session-monitor-detail-table" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: "id" }),
el("th", { text: "role" }),
el("th", { text: "content" }),
el("th", { text: t("table.timestamp") }),
]),
]),
detailBody,
]);
detailModalCard.appendChild(
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("sessionMonitor.messages") }),
el("div", { class: "row" }, [
el("span", { class: "muted", text: t("sessionMonitor.roleFilter") }),
detailRoleFilter,
btnCloseDetail,
]),
detailStatus,
detailPager,
el("div", { class: "table-wrap" }, [
detailTable,
]),
]),
);
enableTableColumnResize(detailTable, [2, 3]);
detailModal.appendChild(detailModalCard);
detailModal.addEventListener("click", (ev) => {
if (ev.target === detailModal) closeDetailModal();
});
return renderPageShell({
title: t("title.sessionMonitor"),
subtitle: "按用户、会话与消息详情进行巡检与导出",
sections: [
{ id: "sm-totals", label: "总览" },
{ id: "sm-users", label: "用户" },
{ id: "sm-sessions", label: "会话" },
],
}, [
renderSectionCard(t("sessionMonitor.totals"), "", [totalsWrap], { id: "sm-totals" }),
renderSectionCard(t("sessionMonitor.userStats"), "", [
el("div", { class: "row" }, [userQ, btnQueryUsers]),
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: "#" }),
el("th", { text: t("table.username") }),
el("th", { text: t("table.name") }),
el("th", { text: t("tenants.role") }),
el("th", { text: t("table.sessionsCount") }),
el("th", { text: t("table.activeSessions30m") }),
el("th", { text: t("table.activeLogins30m") }),
el("th", { text: t("table.totalTokensEst") }),
el("th", { text: t("table.timestamp") }),
]),
]),
userBody,
]),
]),
], { id: "sm-users" }),
renderSectionCard(t("sessionMonitor.sessions"), "", [
el("div", { class: "row" }, [sessQ, activeOnlyLabel, btnQuerySessions]),
selectedHint,
sessionPagerTop,
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact session-monitor-session-table" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: "session_id" }),
el("th", { text: t("table.name") }),
el("th", { text: t("table.username") }),
el("th", { text: "msg_count" }),
el("th", { text: t("table.timestamp") }),
el("th", { text: t("table.activeSessions30m") }),
el("th", { text: t("table.action") }),
]),
]),
sessBody,
]),
]),
sessionPagerBottom,
], { id: "sm-sessions" }),
detailModal,
]);
}
export { renderAdminAudit, renderSessionMonitor };

View file

@ -0,0 +1,253 @@
import { state, t, el, apiGet, apiPost, apiRequest, formatUserLabel } from "../core.js";
import { profileShareableForGrant } from "./authz.js";
async function renderApiGrants() {
const status = el("div", { class: "muted", text: "" });
const profileSel = el("select", { class: "input" });
const grantListEl = el("div", {});
const grantUserSel = el("select", { class: "input" });
const grantMsg = el("div", { class: "muted", text: "" });
const grantBtn = el("button", { class: "btn btn--primary", text: t("models.grantsGrantBtn") });
const tenantGrantStatus = el("span", { class: "muted", text: "" });
let state = null;
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) : "";
}
async function paintGrants() {
grantMsg.textContent = "";
const pid = String(profileSel.value || "").trim();
if (!pid) {
grantListEl.innerHTML = "";
tenantGrantStatus.textContent = "";
return;
}
try {
const tg = await apiGet("/admin/api/models/grants/tenant?profile_id=" + encodeURIComponent(pid));
tenantGrantStatus.textContent = tg.granted ? t("models.tenantGrantOn") : t("models.tenantGrantOff");
const g = await apiGet("/admin/api/models/grants?profile_id=" + encodeURIComponent(pid));
const rows = Array.isArray(g.grants) ? g.grants : [];
grantListEl.innerHTML = "";
if (!rows.length) {
grantListEl.appendChild(el("div", { class: "muted", text: t("models.grantsEmpty") }));
return;
}
rows.forEach((r) => {
const disp = String(r.display_name || r.username || r.user_id || "").trim();
const rev = el("button", { class: "btn", text: t("models.grantsRevoke"), onclick: async () => {
try {
await apiRequest(
"DELETE",
"/admin/api/models/grants?profile_id=" + encodeURIComponent(pid) + "&user_id=" + encodeURIComponent(String(r.user_id || "")),
undefined,
);
await paintGrants();
} catch (e) {
grantMsg.textContent = String(e.message || e);
}
} });
grantListEl.appendChild(el("div", { class: "row" }, [
el("span", { text: disp || r.user_id }),
rev,
]));
});
} catch (e) {
grantMsg.textContent = String(e.message || e);
}
}
const btnTenantGrant = el("button", {
class: "btn btn--primary",
text: t("models.grantTenantBtn"),
onclick: async () => {
const pid = String(profileSel.value || "").trim();
if (!pid) return;
try {
await apiPost("/admin/api/models/grants/tenant", { profile_id: pid });
await paintGrants();
} catch (e) {
grantMsg.textContent = String(e.message || e);
}
},
});
const btnTenantRevoke = el("button", {
class: "btn",
text: t("models.revokeTenantGrant"),
onclick: async () => {
const pid = String(profileSel.value || "").trim();
if (!pid) return;
try {
await apiRequest(
"DELETE",
"/admin/api/models/grants/tenant?profile_id=" + encodeURIComponent(pid),
undefined,
);
await paintGrants();
} catch (e) {
grantMsg.textContent = String(e.message || e);
}
},
});
const grantTenantRow = el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [
btnTenantGrant,
btnTenantRevoke,
tenantGrantStatus,
]);
const membersBody = el("div", {});
let membersLoaded = false;
const membersDetails = el("details", { class: "details" });
membersDetails.appendChild(el("summary", { text: t("models.membersTitle") }));
membersDetails.appendChild(membersBody);
const membersWrap = el("div", { class: "card" }, [membersDetails]);
membersDetails.addEventListener("toggle", async () => {
if (!membersDetails.open || membersLoaded) return;
try {
const r = await apiGet("/admin/api/models/members");
membersLoaded = true;
membersBody.innerHTML = "";
const list = Array.isArray(r.members) ? r.members : [];
if (!list.length) {
membersBody.appendChild(el("div", { class: "muted", text: "—" }));
return;
}
list.forEach((m) => {
const names = (m.profiles || []).map((p) => {
const nm = String(p.name || p.id || "").trim();
const vr = p.visibility_reason ? ` (${visibilityTag(p.visibility_reason)})` : "";
return nm + vr;
});
membersBody.appendChild(el("div", { class: "card", style: "margin-bottom:10px" }, [
el("div", { class: "card__title", text: formatUserLabel(m) }),
el("div", { class: "muted", text: names.filter(Boolean).length ? names.join(";") : "—" }),
]));
});
} catch (e) {
membersLoaded = false;
membersBody.appendChild(el("div", { class: "muted", text: String(e.message || e) }));
}
});
async function ensureGrantUsers() {
grantUserSel.innerHTML = "";
const tid = String((state.authSession && state.authSession.tenant_id) || "").trim();
if (!tid) return;
try {
const u = await apiGet("/admin/api/users?tenant_id=" + encodeURIComponent(tid) + "&limit=500");
grantUserSel.appendChild(el("option", { value: "", text: "—" }));
(u.users || []).forEach((x) => {
const uid = String(x.id || "").trim();
if (!uid) return;
grantUserSel.appendChild(el("option", { value: uid, text: formatUserLabel(x) || uid }));
});
} catch (e) {
grantMsg.textContent = String(e.message || e);
}
}
function fillProfileSelect() {
profileSel.innerHTML = "";
const uid = String((state.authSession && state.authSession.user_id) || "").trim();
const all = (state && Array.isArray(state.profiles)) ? state.profiles : [];
const list = all.filter((p) => profileShareableForGrant(p, uid));
if (!list.length) {
profileSel.appendChild(el("option", { value: "", text: t("apiGrants.noShareable") }));
profileSel.disabled = true;
return;
}
profileSel.disabled = false;
list.forEach((p) => {
const own = String(p.owner_user_id || "").trim();
const tag = own ? t("models.vis.owned") : t("models.vis.global");
const name = String(p.name || p.id || "").trim();
profileSel.appendChild(el("option", { value: String(p.id), text: `${name} (${tag})` }));
});
}
profileSel.addEventListener("change", async () => {
await paintGrants();
});
grantBtn.addEventListener("click", async () => {
const pid = String(profileSel.value || "").trim();
const uid = String(grantUserSel.value || "").trim();
if (!pid || !uid) return;
try {
await apiPost("/admin/api/models/grants", { profile_id: pid, user_id: uid });
await paintGrants();
} catch (e) {
grantMsg.textContent = String(e.message || e);
}
});
async function refresh() {
try {
const data = await apiGet("/admin/api/models");
state = data;
status.textContent = "";
if (!data || data.can_manage_llm_grants !== true) return;
fillProfileSelect();
await ensureGrantUsers();
await paintGrants();
} catch (e) {
state = null;
status.textContent = String(e.message || e);
}
}
await refresh();
const forbiddenCard = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.apiGrants") }),
el("div", { class: "muted", text: t("apiGrants.forbidden") }),
el("div", { class: "row" }, [
el("a", { href: "#/models", text: t("apiGrants.openModels") }),
]),
]);
if (!state || state.can_manage_llm_grants !== true) {
const bits = [forbiddenCard];
if (status.textContent) bits.push(el("div", { class: "card" }, [el("pre", { class: "pre", text: status.textContent })]));
return el("div", {}, bits);
}
const introCard = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.apiGrants") }),
el("div", { class: "muted", text: t("apiGrants.intro") }),
status,
]);
const pickCard = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("apiGrants.pickApi") }),
el("div", { class: "row" }, [profileSel]),
]);
const teamCard = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("apiGrants.teamSection") }),
el("div", { class: "muted", text: t("apiGrants.teamHint") }),
grantTenantRow,
]);
const userCard = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("apiGrants.userSection") }),
el("div", { class: "muted", text: t("apiGrants.userHint") }),
el("div", { class: "row" }, [grantUserSel, grantBtn]),
grantListEl,
grantMsg,
]);
return el("div", {}, [introCard, pickCard, teamCard, userCard, membersWrap]);
}
export { renderApiGrants };

View file

@ -0,0 +1,52 @@
import { state, t, el, applyI18nStatic, apiPost, authStoreSet, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, navigateAdmin } from "../core.js";
async function renderLogin() {
applyI18nStatic();
const username = el("input", { class: "input", placeholder: t("auth.username"), value: "" });
const userHint = el("div", { class: "muted", text: t("auth.consoleUsernameHint") });
const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") });
const status = el("div", { class: "muted", text: "" });
const doLogin = async () => {
const resp = await apiPost("/admin/api/auth/login", {
tenant_id: "",
username: username.value.trim(),
password: password.value.trim(),
purpose: "console",
});
if (!resp.ok || !resp.token) {
const err = String(resp.error || "");
status.textContent = err === "user_disabled" ? t("auth.disabled") : t("auth.invalid");
return;
}
const newTok = String(resp.token || "").trim();
authStoreSet(AUTH_TOKEN_KEY, newTok);
authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
state.authSession = resp.session || null;
await navigateAdmin();
};
const onEnterLogin = (ev) => {
if (ev.key !== "Enter") return;
ev.preventDefault();
doLogin();
};
username.addEventListener("keydown", onEnterLogin);
password.addEventListener("keydown", onEnterLogin);
const btn = el("button", { class: "btn btn--primary", text: t("auth.login"), onclick: doLogin });
// Focus after mount so users can type immediately.
setTimeout(() => {
try {
username.focus();
} catch (_) {}
}, 0);
return el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("auth.login") }),
el("div", { class: "row" }, [username]),
userHint,
el("div", { class: "row" }, [password]),
el("div", { class: "row" }, [btn]),
status,
]);
}
export { renderLogin };

View file

@ -0,0 +1,261 @@
import { t, el, tdCell, apiGet, apiPost, CHAT_MEMORY_MODE_KEY, navigateAdmin } from "../core.js";
async function renderMemory() {
const route = getRoute();
const tenantQ = route.params.get("tenant_id") || "";
const userQ = route.params.get("user_id") || "";
const buildQS = (limit = 50) => {
const p = new URLSearchParams();
p.set("limit", String(limit));
if (tenantInput.value.trim()) p.set("tenant_id", tenantInput.value.trim());
if (userInput.value.trim()) p.set("user_id", userInput.value.trim());
return p.toString();
};
const tenantInput = el("input", { class: "input", placeholder: t("memory.tenantId"), value: tenantQ });
const userInput = el("input", { class: "input", placeholder: t("memory.userId"), value: userQ });
const status = el("div", { class: "muted", text: "" });
const statsWrap = el("div", { class: "muted", text: "" });
const hitsWrap = el("div");
const itemsWrap = el("div");
const wikiStatus = el("div", { class: "muted", text: "" });
const memoryModeSelect = el("select", { class: "input", style: "max-width:320px;" });
memoryModeSelect.appendChild(el("option", { value: "default", text: t("profile.memoryModeDefault") }));
memoryModeSelect.appendChild(el("option", { value: "store_only", text: t("profile.memoryModeStoreOnly") }));
const memoryCuratorSelect = el("select", { class: "input", style: "max-width:220px;" });
memoryCuratorSelect.appendChild(el("option", { value: "1", text: t("profile.memoryCuratorEnabled") }));
memoryCuratorSelect.appendChild(el("option", { value: "0", text: t("profile.memoryCuratorDisabled") }));
const normalizeMemoryMode = (raw) => {
const mm = String(raw || "").trim().toLowerCase();
return mm === "store_only" ? "store_only" : "default";
};
const enabled = el("input", { type: "checkbox" });
const backend = el("select", { class: "input" }, [
el("option", { value: "sqlite", text: "sqlite" }),
]);
const topk = el("input", { class: "input", type: "number", value: "5", min: "1", max: "20" });
const writerEnabled = el("input", { type: "checkbox" });
const ragMode = el("select", { class: "input" }, [
el("option", { value: "keyword", text: "keyword" }),
el("option", { value: "vector", text: "vector" }),
]);
const embeddingMode = el("select", { class: "input" }, [
el("option", { value: "", text: "openai(default/fallback)" }),
el("option", { value: "openai", text: "openai" }),
el("option", { value: "hash", text: "hash/offline" }),
]);
const episodicTtlDays = el("input", { class: "input", type: "number", value: "90", min: "1", max: "3650" });
const minConf = el("input", {
class: "input",
type: "number",
value: "0.75",
min: "0",
max: "1",
step: "0.05",
});
const btnSave = el("button", { class: "btn btn--primary", text: t("memory.save"), onclick: async () => {
const saved = await apiPost("/admin/api/memory/config", {
enabled: enabled.checked,
backend: backend.value,
top_k: Number(topk.value || 5),
writer_enabled: writerEnabled.checked,
write_min_confidence: Number(minConf.value || 0.75),
rag_mode: String(ragMode.value || "keyword"),
rag_embedding_mode: String(embeddingMode.value || ""),
memory_episodic_ttl_days: Number(episodicTtlDays.value || 90),
});
localStorage.setItem(CHAT_MEMORY_MODE_KEY, normalizeMemoryMode(memoryModeSelect.value));
await apiPost("/admin/api/chat/settings/specialist-flags", {
flags: {
generalist: true,
memory: String(memoryCuratorSelect.value || "1") !== "0",
},
});
status.textContent = JSON.stringify(saved.config || {});
}});
const btnReindex = el("button", { class: "btn", text: t("memory.reindex"), onclick: async () => {
const resp = await apiPost("/admin/api/memory/reindex", {});
status.textContent = `reindexed=${Number(resp.reindexed || 0)}`;
}});
const btnCleanup = el("button", { class: "btn btn--danger", text: t("memory.cleanup"), onclick: async () => {
const resp = await apiPost("/admin/api/memory/cleanup-low-confidence", { max_confidence: 0.35 });
status.textContent = `deleted=${Number(resp.deleted || 0)}`;
navigateAdmin();
}});
const loadData = async () => {
const qs = buildQS(50);
const [cfgResp, hitsResp, itemsResp, statsResp] = await Promise.all([
apiGet("/admin/api/memory/config"),
apiGet("/admin/api/memory/hits?" + qs),
apiGet("/admin/api/memory/items?" + qs),
apiGet("/admin/api/memory/stats?" + qs),
]);
const cfg = cfgResp.config || {};
enabled.checked = !!cfg.enabled;
backend.value = String(cfg.backend || "sqlite");
topk.value = String(cfg.top_k || 5);
writerEnabled.checked = !!cfg.writer_enabled;
minConf.value = String(cfg.write_min_confidence ?? 0.75);
ragMode.value = String(cfg.rag_mode || "keyword");
embeddingMode.value = String(cfg.rag_embedding_mode || "");
episodicTtlDays.value = String(cfg.memory_episodic_ttl_days || 90);
memoryModeSelect.value = normalizeMemoryMode(localStorage.getItem(CHAT_MEMORY_MODE_KEY));
try {
const sf = await apiGet("/admin/api/chat/settings/specialist-flags");
const flags = sf && sf.flags && typeof sf.flags === "object" ? sf.flags : {};
memoryCuratorSelect.value = flags.memory === false ? "0" : "1";
} catch (_) {
memoryCuratorSelect.value = "1";
}
const hits = Array.isArray(hitsResp.hits) ? hitsResp.hits : [];
const items = Array.isArray(itemsResp.items) ? itemsResp.items : [];
const stats = (statsResp && statsResp.stats) || {};
const srcText = Array.isArray(stats.top_sources) ? stats.top_sources.map((x) => `${x.source}:${x.count}`).join(", ") : "";
statsWrap.textContent = `${t("memory.hitCount")}: ${Number(stats.hit_count || 0)} | ${t("memory.itemCount")}: ${Number(stats.item_count || 0)} | ${t("memory.avgScore")}: ${Number(stats.avg_score || 0).toFixed(3)} | ${t("memory.topSources")}: ${srcText || "-"}`;
const hitBody = el("tbody");
if (!hits.length) {
hitBody.appendChild(el("tr", {}, [el("td", { text: t("memory.noData"), colspan: "6" })]));
} else {
hits.forEach((h) => {
hitBody.appendChild(el("tr", {}, [
tdCell(h.timestamp || "", 24),
tdCell(h.tenant_id || "", 18),
tdCell(h.user_id || "", 18),
tdCell(h.query_text || "", 40),
tdCell(h.memory_id || "", 18),
tdCell(String(h.score || ""), 10),
]));
});
}
hitsWrap.innerHTML = "";
hitsWrap.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: "tenant_id" }),
el("th", { text: "user_id" }),
el("th", { text: "query" }),
el("th", { text: "memory_id" }),
el("th", { text: "score" }),
])]),
hitBody,
])]));
const itemBody = el("tbody");
if (!items.length) {
itemBody.appendChild(el("tr", {}, [el("td", { text: t("memory.noData"), colspan: "8" })]));
} else {
items.forEach((it) => {
const btnDelete = el("button", { class: "btn btn--danger", text: t("memory.delete"), onclick: async () => {
await apiPost("/admin/api/memory/delete", { memory_id: it.memory_id });
await loadData();
}});
itemBody.appendChild(el("tr", {}, [
tdCell(it.memory_id || "", 18),
tdCell(it.tenant_id || "", 18),
tdCell(it.user_id || "", 18),
tdCell(it.memory_type || "", 14),
tdCell(String(it.confidence ?? ""), 8),
tdCell(it.content || "", 80),
tdCell(it.updated_at || "", 24),
el("td", {}, [btnDelete]),
]));
});
}
itemsWrap.innerHTML = "";
itemsWrap.appendChild(el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: "memory_id" }),
el("th", { text: "tenant_id" }),
el("th", { text: "user_id" }),
el("th", { text: "type" }),
el("th", { text: "confidence" }),
el("th", { text: "content" }),
el("th", { text: t("table.timestamp") }),
el("th", { text: t("memory.delete") }),
])]),
itemBody,
])]));
try {
const pluginsResp = await apiGet("/admin/api/plugins");
const rows = Array.isArray(pluginsResp.plugins) ? pluginsResp.plugins : [];
const wikiPlugin = rows.find((x) => String((x && x.plugin_name) || "").toLowerCase() === "memory-wiki");
if (wikiPlugin) {
wikiStatus.textContent = t("memory.wikiPluginFound", {
name: String(wikiPlugin.plugin_name || "memory-wiki"),
version: String(wikiPlugin.plugin_version || "-"),
});
} else {
wikiStatus.textContent = t("memory.wikiPluginMissing");
}
} catch (_) {
wikiStatus.textContent = t("memory.wikiPluginMissing");
}
};
const btnApplyFilters = el("button", { class: "btn", text: t("memory.applyFilters"), onclick: async () => {
const p = new URLSearchParams();
if (tenantInput.value.trim()) p.set("tenant_id", tenantInput.value.trim());
if (userInput.value.trim()) p.set("user_id", userInput.value.trim());
location.hash = "#/memory" + (p.toString() ? ("?" + p.toString()) : "");
await loadData();
}});
const btnClearFilters = el("button", { class: "btn", text: t("memory.clearFilters"), onclick: async () => {
tenantInput.value = "";
userInput.value = "";
location.hash = "#/memory";
await loadData();
}});
await loadData();
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("memory.config") }),
el("div", { class: "row" }, [el("label", { text: t("memory.enabled") }), enabled]),
el("div", { class: "row" }, [el("label", { text: t("memory.backend") }), backend]),
el("div", { class: "row" }, [el("label", { text: t("memory.topk") }), topk]),
el("div", { class: "row" }, [el("label", { text: t("memory.writerEnabled") }), writerEnabled]),
el("div", { class: "row" }, [el("label", { text: "RAG mode" }), ragMode]),
el("div", { class: "row" }, [el("label", { text: "Embedding mode" }), embeddingMode]),
el("div", { class: "row" }, [el("label", { text: "Episodic TTL days" }), episodicTtlDays]),
el("div", { class: "row" }, [el("label", { text: t("memory.minConfidence") }), minConf]),
el("div", { class: "row" }, [btnSave, btnReindex, btnCleanup]),
status,
]),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("memory.wikiCard") }),
el("div", { class: "row" }, [el("label", { text: t("profile.memoryMode") }), memoryModeSelect]),
el("div", { class: "row" }, [el("label", { text: t("profile.memoryCurator") }), memoryCuratorSelect]),
el("div", { class: "row" }, [el("label", { text: t("memory.wikiStatus") }), wikiStatus]),
el("div", { class: "row" }, [
el("button", {
class: "btn",
text: t("memory.openPlugins"),
onclick: () => {
location.hash = "#/plugins";
},
}),
]),
]),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("memory.filters") }),
el("div", { class: "row" }, [tenantInput, userInput, btnApplyFilters, btnClearFilters]),
statsWrap,
]),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("memory.hits") }),
hitsWrap,
]),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("memory.items") }),
itemsWrap,
]),
]);
}
export { renderMemory };

View 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 };

View file

@ -0,0 +1,885 @@
import { t, el, tdCell, apiGet, apiPost, renderPageShell, markPrewarmReminder, tf, rowActions } from "../core.js";
const PLUGINS_PAGE_SIZE = 15;
function pluginsFold(summaryText, innerNodes) {
const det = el("details", { class: "details plugins-fold" });
det.appendChild(el("summary", { text: summaryText }));
const inner = el("div", { class: "plugins-fold__inner" });
// Keep interactive controls inside the fold from bubbling to <details>.
inner.addEventListener("click", (e) => e.stopPropagation());
innerNodes.forEach((n) => inner.appendChild(n));
det.appendChild(inner);
return det;
}
/** totalHolder.value = row count; pageRef.value = 1-based page */
function pluginsPagerBar(totalHolder, pageRef, onRepaint) {
const wrap = el("div", {
class: "row plugins-pager",
style: "gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap;",
});
const lab = el("span", { class: "muted", text: "" });
const maxP = () => Math.max(1, Math.ceil(Math.max(0, totalHolder.value) / PLUGINS_PAGE_SIZE));
const prevBtn = el("button", { class: "btn btn--small", text: t("plugins.prevPage") });
const nextBtn = el("button", { class: "btn btn--small", text: t("plugins.nextPage") });
const sync = () => {
const total = totalHolder.value;
let cur = pageRef.value;
const mp = maxP();
if (cur > mp) {
cur = mp;
pageRef.value = cur;
}
if (cur < 1) {
cur = 1;
pageRef.value = 1;
}
const start = total === 0 ? 0 : (cur - 1) * PLUGINS_PAGE_SIZE + 1;
const end = Math.min(cur * PLUGINS_PAGE_SIZE, total);
lab.textContent = total ? tf("plugins.pageRange", { start, end, total }) : t("plugins.noData");
prevBtn.disabled = cur <= 1 || total === 0;
nextBtn.disabled = cur >= mp || total === 0;
};
prevBtn.addEventListener("click", () => {
if (pageRef.value > 1) {
pageRef.value--;
onRepaint();
sync();
}
});
nextBtn.addEventListener("click", () => {
if (pageRef.value < maxP()) {
pageRef.value++;
onRepaint();
sync();
}
});
wrap.appendChild(prevBtn);
wrap.appendChild(lab);
wrap.appendChild(nextBtn);
sync();
return { wrap, sync };
}
function cursorCommandSummary(cursor) {
if (!cursor || typeof cursor !== "object") return "-";
const url = String(cursor.url || cursor.baseUrl || "").trim();
if (url) return url;
const cmd = String(cursor.command || "").trim();
const args = Array.isArray(cursor.args) ? cursor.args.map((x) => String(x)).join(" ") : "";
const line = (cmd + (args ? " " + args : "")).trim();
return line || "-";
}
async function renderPlugins() {
document.querySelectorAll("[data-mcp-edit-modal]").forEach((n) => n.remove());
document.querySelectorAll("body > .row-actions__menu, body > .chat-sess-menu-pop.row-actions__menu").forEach((n) => n.remove());
const p = await apiGet("/admin/api/plugins");
let toolPolicy = {
disable_tool_confirm: false,
enforced_retry_mode: "first_round_only",
tool_loop_state_machine: true,
tool_signature_budget: 2,
turn_max_tool_workers: 8,
turn_max_tool_rounds: 100,
turn_max_context_messages: 80,
turn_runner_impl: "oclaw",
sse_queue_maxsize: 2000,
tool_log_max_chars: 200000,
enable_mcp_tools: true,
enable_plugin_tools: false,
enable_run_command: true,
tool_context_truncate_enabled: true,
chat_show_ttft_debug: false,
tool_llm_message_max_chars: 0,
mcp_filesystem_extra_roots: "",
mcp_env_allowlist: "",
oclaw_retryable_error_codes: "",
oclaw_retry_codes_strict_mode: false,
wecom_longconn_workers: 2,
wecom_longconn_inbound_queue_maxsize: 200,
};
let mcp = { servers: [] };
let mcpBinding = { available_specialists: ["generalist"], servers: [], mapping: {} };
try {
mcp = await apiGet("/admin/api/mcp/servers");
} catch (_) {}
try {
mcpBinding = await apiGet("/admin/api/mcp/binding");
} catch (_) {}
try {
toolPolicy = await apiGet("/admin/api/tool-policy");
} catch (_) {}
const pluginCatalog = Array.isArray(p.plugins) ? p.plugins : [];
const pluginPageRef = { value: 1 };
const pluginTotalHolder = { value: pluginCatalog.length };
const pluginTbody = el("tbody");
const buildPluginRow = (x) =>
el("tr", {}, [
el("td", { text: String(x.plugin_name || "") }),
el("td", { text: String(x.plugin_version || "") }),
el("td", { text: String(x.entry_point || "") }),
el("td", { text: String(x.enabled ? 1 : 0) }),
]);
const repaintPlugins = () => {
pluginTbody.innerHTML = "";
const start = (pluginPageRef.value - 1) * PLUGINS_PAGE_SIZE;
pluginCatalog.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => {
pluginTbody.appendChild(buildPluginRow(x));
});
};
repaintPlugins();
const pluginPager = pluginsPagerBar(pluginTotalHolder, pluginPageRef, repaintPlugins);
const bindingStatus = el("div", { class: "muted", text: "" });
const installStatus = el("div", { class: "muted", text: "" });
const toolPolicyStatus = el("div", { class: "muted", text: "" });
const turnMaxWorkersInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "32",
value: String(Number(toolPolicy.turn_max_tool_workers || 8)),
});
const turnMaxRoundsInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "300",
value: String(Number(toolPolicy.turn_max_tool_rounds || 100)),
});
const turnMaxCtxInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "10",
max: "400",
value: String(Number(toolPolicy.turn_max_context_messages || 80)),
});
const sseQueueMaxsizeInput = el("input", {
class: "input u-max-w-140",
type: "number",
min: "200",
max: "50000",
value: String(Number(toolPolicy.sse_queue_maxsize || 2000)),
});
const toolLogMaxCharsInput = el("input", {
class: "input u-max-w-140",
type: "number",
min: "20000",
max: "2000000",
value: String(Number(toolPolicy.tool_log_max_chars || 200000)),
});
const enableMcpToolsCb = el("input", { type: "checkbox" });
enableMcpToolsCb.checked = !!toolPolicy.enable_mcp_tools;
const enablePluginToolsCb = el("input", { type: "checkbox" });
enablePluginToolsCb.checked = !!toolPolicy.enable_plugin_tools;
const enableRunCommandCb = el("input", { type: "checkbox" });
enableRunCommandCb.checked = !!toolPolicy.enable_run_command;
const toolContextTruncateCb = el("input", { type: "checkbox" });
toolContextTruncateCb.checked = !!toolPolicy.tool_context_truncate_enabled;
const chatShowTtftDebugCb = el("input", { type: "checkbox" });
chatShowTtftDebugCb.checked = !!toolPolicy.chat_show_ttft_debug;
const toolLlmMessageMaxCharsInput = el("input", {
class: "input u-max-w-140",
type: "number",
min: "0",
max: "500000",
value: String(Number(toolPolicy.tool_llm_message_max_chars ?? 0)),
});
const mcpFilesystemExtraRootsInput = el("input", {
class: "input",
value: String(toolPolicy.mcp_filesystem_extra_roots || ""),
placeholder: "D:\\work|D:\\docs",
});
const mcpEnvAllowlistInput = el("input", {
class: "input",
value: String(toolPolicy.mcp_env_allowlist || ""),
placeholder: "BRAVE_API_KEY,GOOGLE_OAUTH_CREDENTIALS,...",
});
const oclawRetryableErrorCodesInput = el("input", {
class: "input",
value: String(toolPolicy.oclaw_retryable_error_codes || ""),
placeholder: "provider_timeout,provider_rate_limited,...",
});
const oclawRetryCodesStrictModeCb = el("input", { type: "checkbox" });
oclawRetryCodesStrictModeCb.checked = !!toolPolicy.oclaw_retry_codes_strict_mode;
const wecomLongconnWorkersInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "8",
value: String(Number(toolPolicy.wecom_longconn_workers || 2)),
});
const wecomLongconnInboundQueueInput = el("input", {
class: "input u-max-w-140",
type: "number",
min: "20",
max: "5000",
value: String(Number(toolPolicy.wecom_longconn_inbound_queue_maxsize || 200)),
});
const saveToolPolicyBtn = el("button", {
class: "btn",
text: "Save Tool Policy",
onclick: async () => {
const r = await apiPost("/admin/api/tool-policy", {
turn_max_tool_workers: Number(turnMaxWorkersInput.value || 8),
turn_max_tool_rounds: Number(turnMaxRoundsInput.value || 100),
turn_max_context_messages: Number(turnMaxCtxInput.value || 80),
sse_queue_maxsize: Number(sseQueueMaxsizeInput.value || 2000),
tool_log_max_chars: Number(toolLogMaxCharsInput.value || 200000),
enable_mcp_tools: !!enableMcpToolsCb.checked,
enable_plugin_tools: !!enablePluginToolsCb.checked,
enable_run_command: !!enableRunCommandCb.checked,
tool_context_truncate_enabled: !!toolContextTruncateCb.checked,
chat_show_ttft_debug: !!chatShowTtftDebugCb.checked,
tool_llm_message_max_chars: Number(toolLlmMessageMaxCharsInput.value || 0),
mcp_filesystem_extra_roots: String(mcpFilesystemExtraRootsInput.value || ""),
mcp_env_allowlist: String(mcpEnvAllowlistInput.value || ""),
oclaw_retryable_error_codes: String(oclawRetryableErrorCodesInput.value || ""),
oclaw_retry_codes_strict_mode: !!oclawRetryCodesStrictModeCb.checked,
wecom_longconn_workers: Number(wecomLongconnWorkersInput.value || 2),
wecom_longconn_inbound_queue_maxsize: Number(wecomLongconnInboundQueueInput.value || 200),
});
const unknownRetry = Array.isArray(r.unknown_retryable_error_codes) ? r.unknown_retryable_error_codes : [];
if (unknownRetry.length) {
toolPolicyStatus.textContent =
`[tool-policy] saved with warnings: unknown_retryable_error_codes=${unknownRetry.join(", ")} | ` + JSON.stringify(r);
} else {
toolPolicyStatus.textContent = `[tool-policy] ` + JSON.stringify(r);
}
toolPolicyStatus.textContent += " | restart gateway/desktop to apply run_command toggle";
},
});
const availableSpecialists =
Array.isArray(mcpBinding.available_specialists) && mcpBinding.available_specialists.length
? mcpBinding.available_specialists.map((x) => String(x))
: ["generalist"];
let bindingServers = Array.isArray(mcpBinding.servers)
? mcpBinding.servers.filter((x) => x && String(x.server_id || "").trim())
: [];
let bindingDraft = mcpBinding.mapping && typeof mcpBinding.mapping === "object" ? { ...mcpBinding.mapping } : {};
const allSpecialistIds = () => {
const s = new Set(availableSpecialists.map((x) => String(x)));
Object.keys(bindingDraft).forEach((k) => s.add(String(k)));
return Array.from(s).sort();
};
let repaintExpertBindingDashboard = () => {};
const specialistSelect = el(
"select",
{ class: "input" },
availableSpecialists.map((sp) => el("option", { value: sp, text: sp })),
);
const bindingListWrap = el("div");
const bindingListRowsMount = el("div");
const bindingListPagerMount = el("div");
bindingListWrap.appendChild(bindingListRowsMount);
bindingListWrap.appendChild(bindingListPagerMount);
const bindingListPageRef = { value: 1 };
const bindingListTotalHolder = { value: 0 };
const bindingReverseWrap = el("div");
const bindingReverseTableMount = el("div");
const bindingReversePagerMount = el("div");
bindingReverseWrap.appendChild(bindingReverseTableMount);
bindingReverseWrap.appendChild(bindingReversePagerMount);
const bindingReversePageRef = { value: 1 };
const bindingReverseTotalHolder = { value: 0 };
const flashInstalledRow = (sid) => {
const row = document.getElementById(`mcp-installed-${sid}`);
if (!row) {
bindingStatus.textContent = `[binding] installed row not found: ${sid}`;
return;
}
row.scrollIntoView({ behavior: "smooth", block: "center" });
const oldBg = row.style.backgroundColor;
row.style.backgroundColor = "rgba(255, 215, 0, 0.22)";
setTimeout(() => {
row.style.backgroundColor = oldBg || "";
}, 1800);
};
const renderBindingReverse = () => {
bindingReverseTotalHolder.value = bindingServers.length;
const start = (bindingReversePageRef.value - 1) * PLUGINS_PAGE_SIZE;
const slice = bindingServers.slice(start, start + PLUGINS_PAGE_SIZE);
const rows = slice.map((srv) => {
const sid = String(srv.server_id || "");
const specialists = allSpecialistIds().filter((sp) => {
const mapped = Array.isArray(bindingDraft[sp]) ? bindingDraft[sp].map((x) => String(x)) : [];
return mapped.includes(sid);
});
const locateBtn = el("button", {
class: "btn btn--small",
text: "Locate",
onclick: () => flashInstalledRow(sid),
});
return el("tr", {}, [
tdCell(sid, 38),
tdCell(specialists.length ? specialists.join(", ") : "-", 62),
el("td", {}, [locateBtn]),
]);
});
bindingReverseTableMount.innerHTML = "";
bindingReverseTableMount.appendChild(
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: "server_id" }),
el("th", { text: "bound specialists" }),
el("th", { text: "action" }),
]),
]),
el("tbody", {}, rows.length ? rows : [el("tr", {}, [el("td", { text: "-", colspan: "3" })])]),
]),
]),
);
bindingReversePagerMount.innerHTML = "";
const brBar = pluginsPagerBar(bindingReverseTotalHolder, bindingReversePageRef, renderBindingReverse);
bindingReversePagerMount.appendChild(brBar.wrap);
repaintExpertBindingDashboard();
};
const renderBindingList = () => {
const current = String(specialistSelect.value || "");
const existing = Array.isArray(bindingDraft[current]) ? bindingDraft[current].map((x) => String(x)) : [];
const selected = new Set(existing);
bindingListTotalHolder.value = bindingServers.length;
const start = (bindingListPageRef.value - 1) * PLUGINS_PAGE_SIZE;
const slice = bindingServers.slice(start, start + PLUGINS_PAGE_SIZE);
bindingListRowsMount.innerHTML = "";
if (!bindingServers.length) {
bindingListRowsMount.appendChild(el("div", { class: "muted", text: "No MCP servers installed yet." }));
bindingListPagerMount.innerHTML = "";
const blBar0 = pluginsPagerBar(bindingListTotalHolder, bindingListPageRef, renderBindingList);
bindingListPagerMount.appendChild(blBar0.wrap);
repaintExpertBindingDashboard();
return;
}
slice.forEach((srv) => {
const sid = String(srv.server_id || "");
const cb = el("input", { type: "checkbox" });
cb.checked = selected.has(sid);
cb.addEventListener("change", () => {
const prev = new Set(
Array.isArray(bindingDraft[current]) ? bindingDraft[current].map((x) => String(x)) : [],
);
if (cb.checked) prev.add(sid);
else prev.delete(sid);
bindingDraft[current] = Array.from(prev);
renderBindingReverse();
});
const label = `${sid}${srv.enabled ? "" : " (disabled)"}`;
bindingListRowsMount.appendChild(el("label", { class: "row" }, [cb, el("span", { text: label })]));
});
bindingListPagerMount.innerHTML = "";
const blBar = pluginsPagerBar(bindingListTotalHolder, bindingListPageRef, renderBindingList);
bindingListPagerMount.appendChild(blBar.wrap);
repaintExpertBindingDashboard();
};
specialistSelect.addEventListener("change", () => {
bindingListPageRef.value = 1;
bindingReversePageRef.value = 1;
renderBindingList();
renderBindingReverse();
});
const selectAllBindingBtn = el("button", {
class: "btn",
text: "Select All",
onclick: () => {
const current = String(specialistSelect.value || "");
bindingDraft[current] = bindingServers.map((x) => String(x.server_id || "")).filter((x) => x);
renderBindingList();
renderBindingReverse();
},
});
const clearBindingBtn = el("button", {
class: "btn",
text: "Clear",
onclick: () => {
const current = String(specialistSelect.value || "");
bindingDraft[current] = [];
renderBindingList();
renderBindingReverse();
},
});
const saveBindingBtn = el("button", {
class: "btn",
text: "Save Binding",
onclick: async () => {
const r = await apiPost("/admin/api/mcp/binding", { mapping: bindingDraft });
bindingStatus.textContent = `[binding] ` + JSON.stringify(r);
markPrewarmReminder("mcp_binding_changed");
try {
const fresh = await apiGet("/admin/api/mcp/binding");
if (fresh && fresh.mapping && typeof fresh.mapping === "object") {
bindingDraft = { ...fresh.mapping };
}
if (Array.isArray(fresh && fresh.servers)) {
bindingServers = fresh.servers.filter((x) => x && String(x.server_id || "").trim());
}
} catch (_) {}
renderBindingList();
renderBindingReverse();
},
});
renderBindingList();
renderBindingReverse();
const jsonInstallInput = el("textarea", {
class: "input",
placeholder:
'{\n "mcpServers": {\n "my-server": { "command": "npx", "args": ["-y", "mcp-fetch-server"] }\n }\n}',
rows: "10",
});
const installBtn = el("button", {
class: "btn",
text: "Install",
onclick: async () => {
const raw = String(jsonInstallInput.value || "").trim();
if (!raw) {
installStatus.textContent = "[install] empty JSON";
return;
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
installStatus.textContent = `[install] invalid JSON: ${String((err && err.message) || err || "parse_failed")}`;
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
installStatus.textContent = "[install] body must be a JSON object containing mcpServers";
return;
}
if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
installStatus.textContent = "[install] mcpServers required";
return;
}
installStatus.textContent = "[install] installing...";
try {
const res = await apiPost("/admin/api/mcp/install", parsed);
installStatus.textContent = JSON.stringify(res);
if (res && res.ok) {
markPrewarmReminder("mcp_installed");
await softReloadMcpServers();
}
} catch (err) {
installStatus.textContent = `[install] failed: ${String((err && err.message) || err || "install_failed")}`;
}
},
});
const mcpExportJsonBtn = el("button", {
class: "btn",
text: "Export JSON",
title: "Download Cursor mcpServers snapshot for reinstall / migration",
onclick: async () => {
let r;
try {
r = await apiGet("/admin/api/mcp/export");
} catch (err) {
installStatus.textContent = "[export] " + String((err && err.message) || err);
return;
}
if (!r || r.ok !== true || !r.document) {
installStatus.textContent = "[export] failed: " + JSON.stringify(r);
return;
}
const text = JSON.stringify(r.document, null, 2) + "\n";
const blob = new Blob([text], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "mcp_registry_migrated.json";
a.click();
URL.revokeObjectURL(a.href);
installStatus.textContent =
"[export] downloaded mcp_registry_migrated.json" + (r.local_path ? " ; on server: " + r.local_path : "");
},
});
let mcpServerList = Array.isArray(mcp.servers) ? mcp.servers : [];
const expertBindingDashTbody = el("tbody");
repaintExpertBindingDashboard = () => {
expertBindingDashTbody.innerHTML = "";
const ids = allSpecialistIds();
if (!ids.length) {
expertBindingDashTbody.appendChild(el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "3" })]));
return;
}
ids.forEach((sp) => {
const sids = new Set((bindingDraft[sp] || []).map(String).filter(Boolean));
let toolCnt = 0;
mcpServerList.forEach((row) => {
const rsid = String(row.server_id || "");
if (!sids.has(rsid)) return;
const tools = Array.isArray(row.tools) ? row.tools : [];
toolCnt += tools.length;
});
expertBindingDashTbody.appendChild(
el("tr", {}, [tdCell(sp, 28), tdCell(String(sids.size), 10), tdCell(String(toolCnt), 12)]),
);
});
};
repaintExpertBindingDashboard();
let editingServer = null;
const editModal = el("div", { class: "session-monitor-modal u-hidden", "data-mcp-edit-modal": "1" });
const editTitle = el("div", { class: "card__title", text: "Edit MCP server" });
const editServerIdLab = el("div", { class: "muted", text: "" });
const editCursorInput = el("textarea", {
class: "input",
rows: "12",
placeholder: '{ "command": "npx", "args": ["-y", "..."] }',
});
const editEnabledCb = el("input", { type: "checkbox" });
const editTimeoutInput = el("input", {
class: "input u-max-w-120",
type: "number",
min: "1",
max: "600",
value: "30",
});
const editStatus = el("div", { class: "muted", text: "" });
const closeEditModal = () => {
editingServer = null;
editStatus.textContent = "";
editModal.classList.add("u-hidden");
editModal.style.display = "";
};
const openEditModal = (row) => {
if (!row) return;
editingServer = row;
const sid = String(row.server_id || "");
editTitle.textContent = `Edit MCP: ${sid}`;
editServerIdLab.textContent = `server_id: ${sid}`;
const cursor = row.cursor && typeof row.cursor === "object" ? row.cursor : {};
editCursorInput.value = JSON.stringify(cursor, null, 2);
editEnabledCb.checked = !!row.enabled;
editTimeoutInput.value = String(Number(row.timeout_s || 30));
editStatus.textContent = "";
if (editModal.parentNode !== document.body) document.body.appendChild(editModal);
editModal.classList.remove("u-hidden");
editModal.style.display = "";
setTimeout(() => editCursorInput.focus(), 0);
};
editModal.addEventListener("click", (e) => {
if (e.target === editModal) closeEditModal();
});
const editSaveBtn = el("button", {
class: "btn btn--primary",
text: "Save",
onclick: async () => {
if (!editingServer) return;
const sid = String(editingServer.server_id || "").trim();
if (!sid) return;
let parsed;
try {
parsed = JSON.parse(String(editCursorInput.value || "").trim() || "{}");
} catch (err) {
editStatus.textContent = `[edit] invalid JSON: ${String((err && err.message) || err || "parse_failed")}`;
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
editStatus.textContent = "[edit] Cursor config must be a JSON object";
return;
}
editStatus.textContent = "[edit] saving...";
try {
const r = await apiPost("/admin/api/mcp/config", {
server_id: sid,
server: parsed,
enabled: !!editEnabledCb.checked,
timeout_s: Number(editTimeoutInput.value || 30),
});
editStatus.textContent = JSON.stringify(r);
if (r && r.ok) {
closeEditModal();
markPrewarmReminder("mcp_config_updated");
await softReloadMcpServers();
repaintExpertBindingDashboard();
}
} catch (err) {
editStatus.textContent = `[edit] failed: ${String((err && err.message) || err || "save_failed")}`;
}
},
});
editModal.appendChild(
el("div", { class: "card session-monitor-modal__card", style: "width:min(720px,96vw);" }, [
editTitle,
editServerIdLab,
el("div", { class: "muted", text: "Cursor server config (single mcpServers value)" }),
editCursorInput,
el("div", { class: "row", style: "align-items:center;gap:10px;flex-wrap:wrap;" }, [
el("label", { class: "kv" }, [editEnabledCb, document.createTextNode(" Enabled")]),
el("label", { text: "timeout_s" }),
editTimeoutInput,
]),
editStatus,
el("div", { class: "row u-row-end", style: "gap:8px;margin-top:10px;" }, [
el("button", { class: "btn", text: "Cancel", onclick: closeEditModal }),
editSaveBtn,
]),
]),
);
const mcpInstalledPageRef = { value: 1 };
const mcpInstalledTotalHolder = { value: mcpServerList.length };
const mcpInstalledTbody = el("tbody");
let foldInstalledSummary = null;
const softReloadMcpServers = async () => {
const resp = await apiGet("/admin/api/mcp/servers");
mcpServerList = Array.isArray(resp.servers) ? resp.servers : [];
bindingServers = mcpServerList.filter((x) => x && String(x.server_id || "").trim());
mcpInstalledTotalHolder.value = mcpServerList.length;
if (foldInstalledSummary) {
foldInstalledSummary.textContent = tf("plugins.fold.installed", { n: mcpServerList.length });
}
repaintMcpInstalled();
mcpInstalledPager.sync();
renderBindingList();
renderBindingReverse();
};
const buildMcpInstalledRow = (x) => {
const sid = String(x.server_id || "");
const cursor = x.cursor && typeof x.cursor === "object" ? x.cursor : {};
const actions = rowActions("⋯", [
{
label: "Edit",
onClick: () => openEditModal(x),
},
{
label: x.enabled ? "Disable" : "Enable",
onClick: async () => {
await apiPost("/admin/api/mcp/toggle", { server_id: sid, enabled: !x.enabled });
markPrewarmReminder("mcp_toggled");
installStatus.textContent = `[toggle:${sid}] enabled=${!x.enabled ? 1 : 0}`;
await softReloadMcpServers();
},
},
{
label: "Health",
onClick: async () => {
const r = await apiPost("/admin/api/mcp/healthcheck", { server_id: sid });
installStatus.textContent = `[health:${sid}] ` + JSON.stringify(r);
await softReloadMcpServers();
},
},
{
label: "Sync Tools",
onClick: async () => {
const r = await apiPost("/admin/api/mcp/tools/sync", { server_id: sid });
installStatus.textContent = `[sync:${sid}] ` + JSON.stringify(r);
await softReloadMcpServers();
},
},
{
label: "Delete",
danger: true,
onClick: async () => {
if (!window.confirm(`Delete ${sid} from MCP registry?`)) return;
const r = await apiPost("/admin/api/mcp/delete", { server_id: sid });
installStatus.textContent = `[delete:${sid}] ` + JSON.stringify(r);
markPrewarmReminder("mcp_deleted");
await softReloadMcpServers();
},
},
]);
const tools = Array.isArray(x.tools) ? x.tools.map((t0) => String(t0.tool_name || "")).join(", ") : "";
const healthObj = x.health && typeof x.health === "object" ? x.health : {};
const healthStatus = String(healthObj.status || "-");
const healthDetail = healthObj.detail && typeof healthObj.detail === "object" ? healthObj.detail : {};
const healthErrCode = String(healthDetail.error_code || "");
const healthErrMsg = String(healthDetail.error || "");
const healthText = healthErrCode ? `${healthStatus}:${healthErrCode}` : healthStatus;
const healthTitle =
healthErrCode || healthErrMsg
? `${healthErrCode || "error"} ${healthErrMsg}`.trim()
: healthDetail.synced_tools != null
? `synced_tools=${Number(healthDetail.synced_tools || 0)}`
: healthStatus;
return el("tr", { id: `mcp-installed-${sid}` }, [
tdCell(sid, 28),
tdCell(cursorCommandSummary(cursor), 56),
tdCell(tools || "-", 48),
tdCell(String(x.enabled ? 1 : 0), 8),
el("td", { text: healthText, title: healthTitle }),
el("td", { class: "table__cell--actions", "data-copy-disabled": "1" }, [
el("div", { class: "table__cell-actions" }, [actions]),
]),
]);
};
const repaintMcpInstalled = () => {
mcpInstalledTbody.innerHTML = "";
const start = (mcpInstalledPageRef.value - 1) * PLUGINS_PAGE_SIZE;
mcpServerList.slice(start, start + PLUGINS_PAGE_SIZE).forEach((x) => {
mcpInstalledTbody.appendChild(buildMcpInstalledRow(x));
});
};
repaintMcpInstalled();
const mcpInstalledPager = pluginsPagerBar(mcpInstalledTotalHolder, mcpInstalledPageRef, repaintMcpInstalled);
const foldToolPolicy = pluginsFold(tf("plugins.fold.toolPolicy", { n: pluginCatalog.length }), [
el("div", { class: "muted", text: t("plugins.fold.toolPolicyHint") }),
el("div", { class: "row" }, [el("label", { text: "Turn max tool workers (1-32)" }), turnMaxWorkersInput]),
el("div", { class: "row" }, [el("label", { text: "Turn max tool rounds (1-300)" }), turnMaxRoundsInput]),
el("div", { class: "row" }, [el("label", { text: "Turn max context messages (10-400)" }), turnMaxCtxInput]),
el("div", { class: "row" }, [el("label", { text: "SSE queue maxsize (200-50000)" }), sseQueueMaxsizeInput]),
el("div", { class: "row" }, [el("label", { text: "Tool log max chars (20000-2000000)" }), toolLogMaxCharsInput]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [enableMcpToolsCb, document.createTextNode(" Enable MCP tools")]),
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [enablePluginToolsCb, document.createTextNode(" Enable plugin tools")]),
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [enableRunCommandCb, document.createTextNode(" Enable run_command (high-risk)")]),
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [
toolContextTruncateCb,
document.createTextNode(" Compress tool result in agent context (50 chars + hint)"),
]),
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [
chatShowTtftDebugCb,
document.createTextNode(" Show TTFT debug timings in chat status"),
]),
]),
el("div", { class: "row" }, [
el("label", { text: "Tool message max chars to LLM (0=unlimited, 4096-500000 recommended)" }),
toolLlmMessageMaxCharsInput,
]),
el("div", {
class: "muted",
text: "Set 0 to disable truncation. If some gateways return 400 for oversized tool messages, set back to 24000.",
}),
el("div", { class: "row" }, [
el("label", { text: "MCP filesystem extra roots (| separated)" }),
mcpFilesystemExtraRootsInput,
]),
el("div", { class: "row" }, [el("label", { text: "MCP env allowlist (comma separated)" }), mcpEnvAllowlistInput]),
el("div", { class: "row" }, [
el("label", { text: "oclaw retryable error codes (comma separated)" }),
oclawRetryableErrorCodesInput,
]),
el("div", { class: "row" }, [
el("label", { class: "kv" }, [
oclawRetryCodesStrictModeCb,
document.createTextNode(" Strict mode: reject unknown retry codes"),
]),
]),
el("div", { class: "row" }, [el("label", { text: "WeCom longconn workers (1-8)" }), wecomLongconnWorkersInput]),
el("div", { class: "row" }, [
el("label", { text: "WeCom inbound queue maxsize (20-5000)" }),
wecomLongconnInboundQueueInput,
]),
el("div", { class: "row" }, [saveToolPolicyBtn]),
toolPolicyStatus,
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("table.name") }),
el("th", { text: t("table.version") }),
el("th", { text: t("table.entryPoint") }),
el("th", { text: t("table.enabled") }),
]),
]),
pluginTbody,
]),
]),
pluginPager.wrap,
]);
const foldMcpInstall = pluginsFold(t("plugins.fold.installCursor"), [
el("div", { class: "muted", text: t("plugins.fold.installCursorHint") }),
jsonInstallInput,
el("div", { class: "row", style: "flex-wrap:wrap;gap:8px;align-items:center;" }, [
installBtn,
mcpExportJsonBtn,
]),
el("div", { class: "muted", text: t("plugins.fold.migrateHint") }),
]);
const foldMcpInstalled = pluginsFold(tf("plugins.fold.installed", { n: mcpServerList.length }), [
installStatus,
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: "server_id" }),
el("th", { text: "command / url" }),
el("th", { text: "tools" }),
el("th", { text: "enabled" }),
el("th", { text: "health" }),
el("th", { class: "table__cell--actions", text: t("tenants.rowActions") }),
]),
]),
mcpInstalledTbody,
]),
]),
mcpInstalledPager.wrap,
]);
foldMcpInstalled.open = true;
foldInstalledSummary = foldMcpInstalled.querySelector("summary");
const foldExpertBindingDash = pluginsFold(t("plugins.fold.bindingDash"), [
el("div", {
class: "muted",
text: t("plugins.fold.bindingDashHint"),
}),
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("plugins.colSpecialist") }),
el("th", { text: t("plugins.colBoundMcp") }),
el("th", { text: t("plugins.colTools") }),
]),
]),
expertBindingDashTbody,
]),
]),
]);
const foldMcpBinding = pluginsFold(t("plugins.fold.bindingEdit"), [
el("div", { class: "muted", text: "Bind MCP servers to specialists (many-to-many)." }),
el("div", { class: "row" }, [
el("label", { text: "Specialist" }),
specialistSelect,
selectAllBindingBtn,
clearBindingBtn,
saveBindingBtn,
]),
bindingListWrap,
el("div", { class: "muted", text: "Reverse view: server -> specialists" }),
bindingReverseWrap,
bindingStatus,
]);
return renderPageShell(
{
title: t("plugins.title"),
subtitle: tf("plugins.subtitle", { n: PLUGINS_PAGE_SIZE }),
sections: [
{ id: "plugins-tool-policy", label: t("plugins.toc.policy") },
{ id: "plugins-install", label: t("plugins.toc.install") },
{ id: "plugins-instances", label: t("plugins.toc.instances") },
{ id: "plugins-binding", label: t("plugins.toc.binding") },
],
},
[
el("div", { class: "page-grid page-grid--single" }, [
el("div", { id: "plugins-tool-policy" }, [foldToolPolicy]),
el("div", { id: "plugins-install" }, [foldMcpInstall]),
el("div", { id: "plugins-instances" }, [foldMcpInstalled]),
foldExpertBindingDash,
el("div", { id: "plugins-binding" }, [foldMcpBinding]),
]),
],
);
}
export { pluginsFold, pluginsPagerBar, renderPlugins };

View file

@ -0,0 +1,151 @@
import { state, t, el, apiGet, apiRequest, apiPostFormData, apiDeleteJson, resolveAdminApiUrl, getStoredAuthToken, authStoreGet, authStoreSet, AUTH_SESSION_KEY } from "../core.js";
async function renderProfile() {
const status = el("div", { class: "muted", text: "" });
const dnInput = el("input", { class: "input", type: "text", maxlength: "120" });
const metaUser = el("div", { class: "muted pre", text: "" });
const metaIds = el("div", { class: "muted pre", text: "" });
const avatarPreview = el("img", {
class: "profile-avatar-preview u-hidden u-avatar-chip",
alt: "",
});
const avatarRow = el("div", { class: "row", style: "align-items:center;gap:16px;flex-wrap:wrap;" }, [
el("div", {}, [avatarPreview]),
el("div", { style: "min-width:200px;flex:1;" }, [
el("div", { class: "muted", style: "margin-bottom:6px;", text: t("profile.avatar") }),
el("div", { class: "muted", style: "margin-bottom:8px;font-size:12px;line-height:1.4;", text: t("profile.avatarHint") }),
]),
]);
const fileInput = el("input", { class: "u-hidden", type: "file", accept: "image/png,image/jpeg,image/jpg,image/webp,image/gif" });
const chooseBtn = el("button", { class: "btn", type: "button", text: t("profile.chooseImage") });
const removeBtn = el("button", { class: "btn", type: "button", text: t("profile.removeAvatar") });
const saveBtn = el("button", { class: "btn btn--primary", type: "button", text: t("profile.save") });
const chatBtn = el("button", { class: "btn", type: "button", text: t("profile.openChat") });
function showBuiltinDefaultAvatar() {
try {
const prev = avatarPreview.dataset.blobUrl;
if (prev) URL.revokeObjectURL(prev);
} catch (_) {}
delete avatarPreview.dataset.blobUrl;
avatarPreview.src = resolveAdminApiUrl("/admin/assets/default-user-avatar.svg");
avatarPreview.style.objectFit = "contain";
avatarPreview.style.padding = "5px";
avatarPreview.style.background = "rgba(255,255,255,0.08)";
avatarPreview.style.display = "block";
}
/** <img> cannot send Authorization; load avatar via fetch + blob */
async function refreshAvatarBlob(p) {
const aid = p && String(p.avatar_attachment_id || "").trim();
if (!aid) {
showBuiltinDefaultAvatar();
return;
}
try {
const tok = getStoredAuthToken();
const u = resolveAdminApiUrl(`/admin/api/chat/attachments/${encodeURIComponent(aid)}`);
const res = await fetch(u, { headers: tok ? { authorization: `Bearer ${tok}` } : {} });
if (!res.ok) throw new Error("avatar fetch");
const blob = await res.blob();
try {
const prev = avatarPreview.dataset.blobUrl;
if (prev) URL.revokeObjectURL(prev);
} catch (_) {}
const url = URL.createObjectURL(blob);
avatarPreview.dataset.blobUrl = url;
avatarPreview.src = url;
avatarPreview.style.objectFit = "cover";
avatarPreview.style.padding = "0";
avatarPreview.style.background = "transparent";
avatarPreview.style.display = "block";
} catch (_) {
showBuiltinDefaultAvatar();
}
}
async function load() {
status.textContent = t("chat.loading");
try {
const r = await apiGet("/admin/api/chat/profile");
if (!r || !r.ok || !r.profile) throw new Error("profile");
const p = r.profile;
dnInput.value = String(p.display_name || "");
metaUser.textContent = `${t("profile.username")}: ${String(p.username || "—")}\n${t("profile.role")}: ${String(p.role || "—")}`;
metaIds.textContent = `${t("profile.userId")}: ${String(p.id || "—")}\n${t("profile.tenantId")}: ${String(p.tenant_id || "—")}\n${t("profile.createdAt")}: ${String(p.created_at || "—")}`;
await refreshAvatarBlob(p);
status.textContent = "";
} catch (e) {
status.textContent = `${t("profile.loadError")}: ${String(e && e.message ? e.message : e)}`;
}
}
chooseBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const f = fileInput.files && fileInput.files[0];
fileInput.value = "";
if (!f) return;
status.textContent = t("chat.sending");
try {
const fd = new FormData();
fd.append("file", f, f.name || "avatar.png");
const r = await apiPostFormData("/admin/api/chat/profile/avatar", fd);
if (!r || !r.ok) throw new Error("upload");
status.textContent = t("profile.saved");
await load();
} catch (e) {
status.textContent = `${t("profile.uploadError")}: ${String(e && e.message ? e.message : e)}`;
}
});
removeBtn.addEventListener("click", async () => {
status.textContent = t("chat.sending");
try {
await apiDeleteJson("/admin/api/chat/profile/avatar");
status.textContent = t("profile.saved");
await load();
} catch (e) {
status.textContent = String(e && e.message ? e.message : e);
}
});
saveBtn.addEventListener("click", async () => {
status.textContent = t("chat.sending");
try {
await apiRequest("PATCH", "/admin/api/chat/profile", { display_name: dnInput.value.trim() });
status.textContent = t("profile.saved");
await load();
try {
const sess = JSON.parse(authStoreGet(AUTH_SESSION_KEY) || "{}");
if (sess && typeof sess === "object") {
sess.display_name = dnInput.value.trim();
authStoreSet(AUTH_SESSION_KEY, JSON.stringify(sess));
state.authSession = sess;
}
} catch (_) {}
} catch (e) {
status.textContent = String(e && e.message ? e.message : e);
}
});
chatBtn.addEventListener("click", () => {
window.location.assign(resolveChatUrl());
});
await load();
const profileCard = el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.profile") }),
el("div", { class: "muted", style: "margin-bottom:10px;line-height:1.45;", text: t("profile.help") }),
el("div", { class: "row" }, [el("label", { text: t("profile.displayName") }), dnInput]),
el("div", { class: "u-h-8" }),
avatarRow,
el("div", { class: "row", style: "margin-top:10px;gap:8px;flex-wrap:wrap;" }, [fileInput, chooseBtn, removeBtn]),
el("div", { class: "row", style: "margin-top:12px;gap:8px;flex-wrap:wrap;" }, [saveBtn, chatBtn]),
el("div", { class: "u-h-8" }),
el("div", { style: "margin-top:14px" }, [metaUser]),
el("div", { class: "u-mt-6" }, [metaIds]),
el("div", { class: "muted u-mt-10" }, [status]),
]);
return el("div", {}, [profileCard]);
}
export { renderProfile };

View file

@ -0,0 +1,517 @@
import { t, tf, el, tdCell, apiGet, apiPost, apiRequest, resolveAdminApiUrl, getStoredAuthToken, formatSystemLocalDateTime } from "../core.js";
import { hasPermission } from "./authz.js";
function formatScheduledJobDelivery(job) {
const d = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
const parts = [];
if (d.weixin && d.weixin.enabled) parts.push(t("scheduledJobs.deliveryWeixin"));
if (d.whatsapp && d.whatsapp.enabled) parts.push(t("scheduledJobs.deliveryWhatsapp"));
return parts.length ? parts.join(" + ") : "—";
}
function formatScheduledJobPlaybook(job) {
const steps = Number(job && job.steps_n != null ? job.steps_n : 0) || 0;
if (job && job.playbook) return tf("scheduledJobs.playbookYes", { n: String(steps) });
if (job && job.has_recipe) return tf("scheduledJobs.playbookPartial", { n: String(steps) });
return t("scheduledJobs.playbookNo");
}
function buildScheduledJobDeliveryPayload(job, waChatId) {
const existing = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
const wa = existing.whatsapp && typeof existing.whatsapp === "object" ? { ...existing.whatsapp } : {};
const wx =
existing.weixin && typeof existing.weixin === "object"
? { ...existing.weixin }
: { enabled: true, fixed: true };
const chat = String(waChatId || "").trim();
wa.enabled = Boolean(chat);
wa.chat_id = chat;
wa.target_type = chat.includes("@g.us") ? "group" : "direct";
if (wx.enabled === undefined) wx.enabled = true;
return { whatsapp: wa, weixin: wx };
}
async function renderScheduledJobs() {
let scheduledJobMenuEl = null;
let editingJob = null;
const canWrite = hasPermission("admin:runtime:write");
const closeScheduledJobMenu = () => {
if (scheduledJobMenuEl && scheduledJobMenuEl.parentNode) {
scheduledJobMenuEl.remove();
}
scheduledJobMenuEl = null;
};
const status = el("select", { class: "input input--compact" }, [
el("option", { value: "", text: t("scheduledJobs.all") }),
el("option", { value: "active", text: t("scheduledJobs.statusActive") }),
el("option", { value: "paused", text: t("scheduledJobs.statusPaused") }),
]);
const tbody = el("tbody", {});
const runsTbody = el("tbody", {});
const runsEmpty = el("div", { class: "muted", text: t("scheduledJobs.noRuns") });
const runsTable = el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("scheduledJobs.colJob") }),
el("th", { text: t("scheduledJobs.colRunStatus") }),
el("th", { text: t("scheduledJobs.colFailClass") }),
el("th", { text: t("scheduledJobs.colFinished") }),
el("th", { text: t("scheduledJobs.colError") }),
]),
]),
runsTbody,
]),
]);
const runsBox = el("div", {}, [runsEmpty, runsTable]);
runsTable.style.display = "none";
const statsBox = el("div", { class: "muted", text: "" });
const attentionBox = el("div", { class: "muted", text: "" });
const msg = el("div", { class: "muted", text: "" });
const weixinInfo = el("div", { class: "muted", text: "" });
const failClassLabel = (fc) => {
const key = `scheduledJobs.failClass.${String(fc || "").trim()}`;
const labeled = t(key);
return labeled === key ? String(fc || "—") : labeled;
};
const renderRunsTable = (items, jobNameFallback) => {
const rows = Array.isArray(items) ? items : [];
runsTbody.replaceChildren();
if (!rows.length) {
runsEmpty.style.display = "";
runsTable.style.display = "none";
runsEmpty.textContent = t("scheduledJobs.noRuns");
return;
}
runsEmpty.style.display = "none";
runsTable.style.display = "";
for (const run of rows.slice(0, 30)) {
const st = String(run.status || "");
const fc = String(run.failure_class || "");
const err = String(run.error || "").trim();
const name = String(run.job_name || jobNameFallback || run.job_id || "—");
const finished = run.finished_at
? formatSystemLocalDateTime(String(run.finished_at))
: (run.created_at ? formatSystemLocalDateTime(String(run.created_at)) : "—");
const statusCell = el("td", { text: st || "—" });
if (st.toLowerCase() === "failed" || st.toLowerCase() === "error") {
statusCell.style.color = "var(--danger, #c44)";
} else if (st.toLowerCase() === "success") {
statusCell.style.color = "var(--success, #2a7)";
}
runsTbody.appendChild(
el("tr", {}, [
el("td", { text: name.slice(0, 40) }),
statusCell,
el("td", { text: fc ? failClassLabel(fc) : "—" }),
el("td", { text: finished }),
el("td", { text: err ? err.slice(0, 120) : "—" }),
])
);
}
};
const loadStats = async () => {
try {
const stats = await apiGet("/admin/api/scheduled-jobs/stats?recent_limit=80");
const finished = Number(stats.recent_success || 0) + Number(stats.recent_failed || 0);
const pct = Number(stats.recent_fail_rate_pct || 0);
const classes = stats.recent_fail_classes || {};
const classParts = Object.keys(classes).map((k) => `${failClassLabel(k)} ${classes[k]}`);
statsBox.textContent = [
tf("scheduledJobs.statsFailRate", {
pct: String(pct),
failed: String(stats.recent_failed || 0),
finished: String(finished),
}),
classParts.length
? `${t("scheduledJobs.statsClasses")}: ${classParts.join(" · ")}`
: t("scheduledJobs.statsNone"),
tf("scheduledJobs.statsJobsFailed", { n: String((stats.jobs_last_failed || []).length) }),
].join(" | ");
const failedJobs = stats.jobs_last_failed || [];
if (!failedJobs.length) {
attentionBox.textContent = "";
} else {
attentionBox.textContent =
`${t("scheduledJobs.attentionTitle")}: ` +
failedJobs
.slice(0, 8)
.map((j) => `${j.name || j.id}${j.last_run_at ? ` @ ${formatSystemLocalDateTime(String(j.last_run_at))}` : ""}`)
.join(" · ");
}
const fails = stats.recent_failures || [];
if (fails.length) {
renderRunsTable(fails, "");
return true;
}
return false;
} catch (e) {
statsBox.textContent = String(e && e.message ? e.message : e);
return false;
}
};
const field = (label, control, wide) =>
el("div", { class: wide ? "form-field form-field--full" : "form-field" }, [
el("label", { class: "form-field__label", text: label }),
control,
]);
const editModal = el("div", { class: "session-monitor-modal u-hidden" });
const editTitle = el("div", { class: "card__title", text: t("scheduledJobs.editTitle") });
const editNameInput = el("input", { class: "input", placeholder: t("scheduledJobs.phName") });
const editKindInput = el("select", { class: "input" }, [
el("option", { value: "cron", text: "cron" }),
el("option", { value: "once", text: "once" }),
el("option", { value: "interval", text: "interval" }),
]);
const editExprInput = el("input", { class: "input", placeholder: t("scheduledJobs.phExpr") });
const editPromptInput = el("textarea", { class: "input", rows: "5", placeholder: t("scheduledJobs.phPrompt") });
const editSpecialistInput = el("input", { class: "input", placeholder: t("scheduledJobs.phSpecialist") });
const editWaChatInput = el("input", { class: "input", placeholder: t("scheduledJobs.phWhatsapp") });
const editWeixinInfo = el("div", { class: "muted", text: "" });
const closeEditModal = () => {
editingJob = null;
editModal.style.display = "none";
};
const openEditModal = (job) => {
if (!job || !job.id) return;
editingJob = job;
editTitle.textContent = `${t("scheduledJobs.editTitle")}: ${String(job.name || job.id)}`;
editNameInput.value = String(job.name || "");
editKindInput.value = String(job.schedule_kind || "cron");
editExprInput.value = String(job.schedule_expr || "");
editPromptInput.value = String(job.prompt_text || "");
editSpecialistInput.value = String(job.specialist || "generalist");
const wa = job.delivery && job.delivery.whatsapp ? job.delivery.whatsapp : {};
editWaChatInput.value = String(wa.chat_id || "");
const wx = job.delivery && job.delivery.weixin ? job.delivery.weixin : {};
editWeixinInfo.textContent = wx.external_user_id
? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
: weixinInfo.textContent || t("scheduledJobs.weixinMissing");
editModal.style.display = "flex";
setTimeout(() => editNameInput.focus(), 0);
};
editModal.addEventListener("click", (e) => {
if (e.target === editModal) closeEditModal();
});
const editSaveBtn = el("button", {
class: "btn btn--primary",
text: t("action.save"),
onclick: async () => {
if (!editingJob || !editingJob.id) return;
const name = String(editNameInput.value || "").trim();
const promptText = String(editPromptInput.value || "").trim();
const scheduleExpr = String(editExprInput.value || "").trim();
if (!name || !promptText || !scheduleExpr) {
msg.textContent = t("scheduledJobs.requiredFields");
return;
}
try {
await apiRequest("PATCH", `/admin/api/scheduled-jobs/${encodeURIComponent(String(editingJob.id))}`, {
name,
schedule_kind: editKindInput.value,
schedule_expr: scheduleExpr,
prompt_text: promptText,
specialist: String(editSpecialistInput.value || "generalist").trim() || "generalist",
delivery: buildScheduledJobDeliveryPayload(editingJob, editWaChatInput.value),
});
closeEditModal();
msg.textContent = t("scheduledJobs.updated");
await loadJobs();
} catch (e) {
msg.textContent = String(e && e.message ? e.message : e);
}
},
});
const editModalCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(640px,96vw);" }, [
editTitle,
editWeixinInfo,
el("div", { class: "form-stack" }, [
field(t("scheduledJobs.fieldName"), editNameInput, true),
el("div", { class: "form-grid" }, [
field(t("scheduledJobs.fieldKind"), editKindInput),
field(t("scheduledJobs.fieldExpr"), editExprInput),
]),
field(t("scheduledJobs.fieldPrompt"), editPromptInput, true),
el("div", { class: "form-grid" }, [
field(t("scheduledJobs.fieldSpecialist"), editSpecialistInput),
field(t("scheduledJobs.fieldWhatsapp"), editWaChatInput),
]),
el("div", { class: "form-actions" }, [
el("button", { class: "btn", text: t("scheduledJobs.cancel"), onclick: closeEditModal }),
editSaveBtn,
]),
]),
]);
editModal.appendChild(editModalCard);
async function loadLatestRuns(items) {
const jobs = Array.isArray(items) ? items : [];
if (!jobs.length) {
renderRunsTable([], "");
return;
}
const withLast = jobs.filter((j) => String(j.last_run_at || "").trim());
const target = withLast.length ? withLast[0] : jobs[0];
if (!target || !target.id) {
renderRunsTable([], "");
return;
}
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(String(target.id))}/runs?limit=10`);
const runItems = (runs.items || []).map((r) => ({ ...r, job_name: target.name || target.id }));
renderRunsTable(runItems, String(target.name || target.id || ""));
}
async function loadJobs() {
closeScheduledJobMenu();
msg.textContent = t("scheduledJobs.loading");
const st = String(status.value || "").trim();
const q = st ? `?status=${encodeURIComponent(st)}` : "";
const resp = await apiGet(`/admin/api/scheduled-jobs${q}`);
tbody.replaceChildren();
for (const job of resp.items || []) {
const jobId = String(job.id || "");
const btnMore = el("button", {
class: "chat-sess-more",
text: "⋯",
title: t("scheduledJobs.menuTitle"),
onclick: (ev) => {
ev.stopPropagation();
closeScheduledJobMenu();
const menu = el("div", { class: "chat-sess-menu-pop u-overlay-fixed" }, [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.viewRuns"),
onclick: async () => {
closeScheduledJobMenu();
const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/runs`);
const runItems = (runs.items || []).map((r) => ({ ...r, job_name: job.name || jobId }));
renderRunsTable(runItems, String(job.name || jobId));
},
}),
...(canWrite
? [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.edit"),
onclick: () => {
closeScheduledJobMenu();
openEditModal(job);
},
}),
]
: []),
el("button", {
class: "chat-sess-menu-item",
text: job.status === "active" ? t("scheduledJobs.pause") : t("scheduledJobs.resume"),
onclick: async () => {
closeScheduledJobMenu();
const path =
job.status === "active"
? `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/pause`
: `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/resume`;
await apiPost(path, {});
await loadJobs();
},
}),
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.runNow"),
onclick: async () => {
closeScheduledJobMenu();
const out = await apiPost(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/run-now`, {});
if (out && out.skipped) {
msg.textContent = t("scheduledJobs.skippedOverlap");
} else {
msg.textContent = t("scheduledJobs.triggered");
}
await loadJobs();
},
}),
...(canWrite
? [
el("button", {
class: "chat-sess-menu-item",
text: t("scheduledJobs.delete"),
onclick: async () => {
closeScheduledJobMenu();
if (!window.confirm(t("scheduledJobs.deleteConfirm"))) return;
await fetch(resolveAdminApiUrl(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}`), {
method: "DELETE",
headers: { authorization: `Bearer ${getStoredAuthToken()}`, accept: "application/json" },
});
await loadJobs();
},
}),
]
: []),
]);
const rect = ev.currentTarget.getBoundingClientRect();
document.body.appendChild(menu);
const mrect = menu.getBoundingClientRect();
const pad = 8;
let left = rect.left - 120;
let top = rect.bottom + 4;
if (top + mrect.height > window.innerHeight - pad) {
top = rect.top - 4 - mrect.height;
}
left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
scheduledJobMenuEl = menu;
const close = (e) => {
if (!menu.contains(e.target)) {
closeScheduledJobMenu();
document.removeEventListener("click", close);
}
};
setTimeout(() => document.addEventListener("click", close), 0);
},
});
const lastStatus = String(job.last_run_status || "").trim();
const lastRun = job.last_run_at
? `${formatSystemLocalDateTime(String(job.last_run_at))}${lastStatus ? ` (${lastStatus})` : ""}`
: "—";
const lastCell = el("td", { text: lastRun });
if (lastStatus.toLowerCase() === "failed") {
lastCell.style.color = "var(--danger, #c44)";
} else if (lastStatus.toLowerCase() === "success") {
lastCell.style.color = "var(--success, #2a7)";
}
const tr = el("tr", {}, [
tdCell(job.name || "", 24),
tdCell(`${job.schedule_kind}:${job.schedule_expr}`, 28),
tdCell(formatScheduledJobPlaybook(job), 18),
tdCell(job.status || "", 10),
tdCell(job.next_run_at || "—", 20),
lastCell,
tdCell(job.specialist || "", 14),
tdCell(formatScheduledJobDelivery(job), 12),
el("td", { class: "table__cell--actions" }, [
el("div", { class: "table__cell-actions" }, [btnMore]),
]),
]);
tbody.appendChild(tr);
}
msg.textContent = tf("scheduledJobs.count", { count: String((resp.items || []).length) });
const showedFails = await loadStats();
if (!showedFails) {
await loadLatestRuns(resp.items || []);
}
}
const nameInput = el("input", { class: "input", placeholder: t("scheduledJobs.phName") });
const kindInput = el("select", { class: "input" }, [
el("option", { value: "cron", text: "cron" }),
el("option", { value: "once", text: "once" }),
el("option", { value: "interval", text: "interval" }),
]);
const exprInput = el("input", { class: "input", placeholder: t("scheduledJobs.phExpr") });
const promptInput = el("textarea", { class: "input", rows: "5", placeholder: t("scheduledJobs.phPrompt") });
const specialistInput = el("input", { class: "input", placeholder: t("scheduledJobs.phSpecialist"), value: "generalist" });
const waChatInput = el("input", { class: "input", placeholder: t("scheduledJobs.phWhatsapp") });
async function loadMeta() {
const meta = await apiGet("/admin/api/scheduled-jobs/meta/targets");
const wx = meta.weixin_binding || {};
weixinInfo.textContent = wx.external_user_id
? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
: t("scheduledJobs.weixinMissing");
}
await loadMeta();
await loadJobs();
const createCardChildren = [
el("div", { class: "card__title", text: t("scheduledJobs.createTitle") }),
weixinInfo,
el("div", { class: "form-stack" }, [
field(t("scheduledJobs.fieldName"), nameInput, true),
el("div", { class: "form-grid" }, [
field(t("scheduledJobs.fieldKind"), kindInput),
field(t("scheduledJobs.fieldExpr"), exprInput),
]),
field(t("scheduledJobs.fieldPrompt"), promptInput, true),
el("div", { class: "form-grid" }, [
field(t("scheduledJobs.fieldSpecialist"), specialistInput),
field(t("scheduledJobs.fieldWhatsapp"), waChatInput),
]),
el("div", { class: "form-actions" }, [
el("button", {
class: "btn btn--primary",
text: t("scheduledJobs.create"),
onclick: async () => {
await apiPost("/admin/api/scheduled-jobs", {
name: nameInput.value,
schedule_kind: kindInput.value,
schedule_expr: exprInput.value,
prompt_text: promptInput.value,
specialist: specialistInput.value,
whatsapp_chat_id: waChatInput.value,
delivery: buildScheduledJobDeliveryPayload(null, waChatInput.value),
});
await loadJobs();
},
}),
]),
]),
];
return el("div", {}, [
editModal,
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.scheduledJobs") }),
el("div", { class: "row", style: "gap:8px;align-items:center;flex-wrap:wrap;" }, [
status,
el("button", { class: "btn", text: t("action.refresh"), onclick: () => loadJobs() }),
msg,
]),
el("div", { class: "card__title", style: "margin-top:10px;font-size:13px;", text: t("scheduledJobs.statsTitle") }),
statsBox,
attentionBox,
el("div", { class: "table-wrap" }, [
el("table", { class: "table table--compact" }, [
el("colgroup", {}, [
el("col", { style: "width:14%" }),
el("col", { style: "width:14%" }),
el("col", { class: "u-w-12p" }),
el("col", { style: "width:8%" }),
el("col", { class: "u-w-12p" }),
el("col", { class: "u-w-12p" }),
el("col", { style: "width:8%" }),
el("col", { style: "width:10%" }),
el("col", { style: "width:10%" }),
]),
el("thead", {}, [
el("tr", {}, [
el("th", { text: t("scheduledJobs.colName") }),
el("th", { text: t("scheduledJobs.colSchedule") }),
el("th", { text: t("scheduledJobs.colPlaybook") }),
el("th", { text: t("scheduledJobs.colStatus") }),
el("th", { text: t("scheduledJobs.colNextRun") }),
el("th", { text: t("scheduledJobs.colLastRun") }),
el("th", { text: t("scheduledJobs.colSpecialist") }),
el("th", { text: t("scheduledJobs.colDelivery") }),
el("th", { text: t("scheduledJobs.colActions") }),
]),
]),
tbody,
]),
]),
]),
...(canWrite ? [el("div", { class: "card" }, createCardChildren)] : []),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: `${t("scheduledJobs.runHistory")} · ${t("scheduledJobs.runHistoryHint")}` }),
runsBox,
]),
]);
}
export { formatScheduledJobDelivery, formatScheduledJobPlaybook, buildScheduledJobDeliveryPayload, renderScheduledJobs };

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,784 @@
import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, apiDeleteJson, formatSystemLocalDateTime, renderMetaChips, navigateAdmin, tf } from "../core.js";
async function renderStack() {
const results = await Promise.allSettled([
apiGetNoHang("/admin/api/runtime/scan-artifacts"),
apiGetNoHang("/admin/api/runtime/prewarm/status"),
apiGetNoHang("/admin/api/runtime/prewarm/prompts?role=generalist"),
apiGetNoHang("/admin/api/chat/settings/specialist-flags"),
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/weixin"),
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/whatsapp"),
apiGetNoHang("/admin/api/whatsapp/groups?tenant_id=default"),
apiGetNoHang("/admin/api/whatsapp/access?tenant_id=default"),
]);
const scanResp = results[0].status === "fulfilled" ? results[0].value : null;
const prewarmStatusResp = results[1].status === "fulfilled" ? results[1].value : null;
const prewarmPromptsResp = results[2].status === "fulfilled" ? results[2].value : null;
const channelSpecResp = results[3].status === "fulfilled" ? results[3].value : null;
const weixinDispatchResp = results[4].status === "fulfilled" ? results[4].value : null;
const whatsappDispatchResp = results[5].status === "fulfilled" ? results[5].value : null;
const whatsappGroupsResp = results[6].status === "fulfilled" ? results[6].value : null;
const whatsappAccessResp = results[7].status === "fulfilled" ? results[7].value : null;
// If auth failed (401), show a gentle message instead of an infinite spinner.
if (!channelSpecResp && !weixinDispatchResp && !prewarmStatusResp) {
return el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.stack") || "Stack" }),
el("div", { class: "muted", text: t("common.notLoggedIn") }),
]);
}
const availableDispatchSpecialists = Array.isArray(channelSpecResp && channelSpecResp.available_specialists) && channelSpecResp.available_specialists.length
? channelSpecResp.available_specialists.map((x) => String(x || "").trim()).filter(Boolean)
: ["generalist"];
const createChannelDispatchCard = (channel, title, initial) => {
const curMode = String((initial && initial.interaction_mode) || "expert").trim() || "expert";
const curSpecialist = String((initial && initial.specialist) || "generalist").trim() || "generalist";
const curLang = String((initial && initial.lang) || "auto").trim().toLowerCase() || "auto";
const specialistSel = el("select", { class: "input" }, availableDispatchSpecialists.map((sid) =>
el("option", { value: sid, text: sid, selected: sid === curSpecialist ? "selected" : undefined }),
));
const langSel = el("select", { class: "input" }, [
el("option", { value: "auto", text: t("common.auto"), selected: curLang === "auto" ? "selected" : undefined }),
el("option", { value: "zh", text: t("common.chinese"), selected: curLang === "zh" ? "selected" : undefined }),
el("option", { value: "en", text: t("common.english"), selected: curLang === "en" ? "selected" : undefined }),
]);
const status = renderMetaChips({ mode: curMode, specialist: curSpecialist, lang: curLang });
const paintStatus = (obj) => {
const fresh = renderMetaChips(obj);
status.replaceChildren(...Array.from(fresh.childNodes));
};
const saveExpertBtn = el("button", {
class: "btn btn--primary",
text: t("wa.bindSpecialist"),
onclick: async () => {
const specialist = String(specialistSel.value || "generalist").trim() || "generalist";
const lang = String(langSel.value || "auto").trim().toLowerCase() || "auto";
const resp = await apiPost(`/admin/api/chat/settings/channel-dispatch/${encodeURIComponent(channel)}`, {
interaction_mode: "expert",
specialist,
lang,
});
paintStatus({
mode: String(resp.interaction_mode || "expert"),
specialist: String(resp.specialist || specialist),
lang: String(resp.lang || lang),
});
},
});
return el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: title }),
el("div", {
class: "card__subtitle",
text: t("wa.channelExpertOnly"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.specialist") }),
specialistSel,
]),
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.lang") }),
langSel,
]),
el("div", { class: "form-inline__actions" }, [saveExpertBtn]),
]),
status,
]);
};
const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {});
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
const waGroups = Array.isArray(whatsappGroupsResp && whatsappGroupsResp.items) ? whatsappGroupsResp.items : [];
const waBinding = (whatsappGroupsResp && whatsappGroupsResp.binding) || {};
const waGroupSel = el(
"select",
{ class: "input" },
[
el("option", { value: "", text: t("wa.selectGroup") }),
...waGroups.map((g) => {
const jid = String((g && g.group_jid) || "").trim();
const name = String((g && g.group_name) || "").trim();
const label = name ? `${name} (${jid})` : jid;
return el("option", {
value: jid,
text: label || jid,
selected: jid && jid === String(waBinding.group_jid || "") ? "selected" : undefined,
});
}),
],
);
const waBindingStatus = el("div", {
class: "muted",
text: waBinding.group_jid
? `binding=${waBinding.group_jid} enabled=${Boolean(waBinding.enabled)}`
: (t("wa.noAlertGroup")),
});
const waAccessCfg = (whatsappAccessResp && whatsappAccessResp.config) || {};
const waContacts = Array.isArray(whatsappAccessResp && whatsappAccessResp.contacts) ? whatsappAccessResp.contacts : [];
const waPending = Array.isArray(whatsappAccessResp && whatsappAccessResp.pending) ? whatsappAccessResp.pending : [];
const waDenied = Array.isArray(whatsappAccessResp && whatsappAccessResp.denied) ? whatsappAccessResp.denied : [];
const waPhoneDisplay = (row) => {
const phone = String((row && row.phone) || "").trim();
if (phone) return phone;
const jid = String((row && row.external_user_id) || "").trim();
const base = jid.split("@")[0] || "";
const digits = base.replace(/\D/g, "");
return digits || base || "-";
};
const waAccessModeSel = el("select", { class: "input" }, [
el("option", {
value: "blacklist",
text: t("wa.blacklistMode"),
selected: String(waAccessCfg.access_mode || "blacklist") === "blacklist" ? "selected" : undefined,
}),
el("option", {
value: "whitelist",
text: t("wa.whitelistMode"),
selected: String(waAccessCfg.access_mode || "") === "whitelist" ? "selected" : undefined,
}),
]);
const waAccessLangSel = el("select", { class: "input" }, [
el("option", { value: "en", text: "English", selected: String(waAccessCfg.lang || "en") === "en" ? "selected" : undefined }),
el("option", { value: "zh", text: t("common.chinese"), selected: String(waAccessCfg.lang || "") === "zh" ? "selected" : undefined }),
]);
const waCounts = { admin: 0, whitelist: 0, blacklist: 0 };
waContacts.forEach((c) => {
const lt = String((c && c.list_type) || "").trim().toLowerCase();
if (lt === "admin") waCounts.admin += 1;
else if (lt === "whitelist") waCounts.whitelist += 1;
else if (lt === "blacklist") waCounts.blacklist += 1;
});
const waAccessStatus = renderMetaChips({
mode: String(waAccessCfg.access_mode || "blacklist"),
lang: String(waAccessCfg.lang || "en"),
admin: waCounts.admin,
whitelist: waCounts.whitelist,
blacklist: waCounts.blacklist,
pending: waPending.length,
denied: waDenied.length,
});
const waContactFilterSel = el("select", { class: "input" }, [
el("option", { value: "all", text: t("common.all") }),
el("option", { value: "admin", text: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.blacklist") }),
]);
const waContactsTbody = el("tbody", {});
const waContactPhone = (row) => {
const phone = String((row && row.phone) || "").trim();
if (phone) return phone;
return waPhoneDisplay(row);
};
const waSaveContact = async (phone, pushName, listType) => {
const phoneVal = String(phone || "").trim();
const list_type = String(listType || "").trim().toLowerCase();
if (!phoneVal) return;
if (!list_type) {
window.alert(t("common.selectType"));
return;
}
try {
const resp = await apiPost("/admin/api/whatsapp/access/contacts", {
phone: phoneVal,
push_name: String(pushName || "").trim(),
list_type,
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (t("common.updateFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
};
const renderWaContactRows = (filterValue) => {
const filter = String(filterValue || "all").trim().toLowerCase() || "all";
const rows = waContacts
.filter((c) => {
const lt = String((c && c.list_type) || "").trim().toLowerCase();
if (filter === "all") return true;
return lt === filter;
})
.map((c) => {
const name = String((c && c.push_name) || "");
const lt = String((c && c.list_type) || "").trim().toLowerCase();
const phone = waContactPhone(c);
const typeSel = el("select", { class: "input" }, [
el("option", { value: "admin", text: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.blacklist") }),
]);
typeSel.value = lt || "whitelist";
const actionBtns = [
el("button", {
class: "btn",
text: t("common.update"),
onclick: async () => {
await waSaveContact(phone, name, String(typeSel.value || "").trim());
},
}),
];
if (lt === "blacklist") {
actionBtns.push(el("span", { text: " " }));
actionBtns.push(el("button", {
class: "btn btn--primary",
text: t("wa.addWhitelist"),
onclick: async () => {
await waSaveContact(phone, name, "whitelist");
},
}));
}
actionBtns.push(el("span", { text: " " }));
actionBtns.push(el("button", {
class: "btn btn--danger",
text: t("common.remove"),
onclick: async () => {
try {
const resp = await apiDeleteJson(
`/admin/api/whatsapp/access/contacts?phone=${encodeURIComponent(phone)}`,
);
if (resp && resp.deleted === false) {
window.alert(t("wa.contactNotFound"));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
},
}));
return el("tr", {}, [
el("td", { "data-copy-disabled": "1" }, [typeSel]),
el("td", { text: name || "-" }),
el("td", { text: phone || "-" }),
el("td", { "data-copy-disabled": "1" }, actionBtns),
]);
});
waContactsTbody.replaceChildren(
...(rows.length
? rows
: [el("tr", {}, [el("td", { colspan: "4", text: t("wa.noContacts") })])]),
);
};
waContactFilterSel.addEventListener("change", () => {
renderWaContactRows(String(waContactFilterSel.value || "all"));
});
renderWaContactRows("all");
const waContactPhoneInput = el("input", { class: "input", placeholder: t("wa.phonePlaceholder") });
const waContactNameInput = el("input", { class: "input", placeholder: t("wa.namePlaceholder") });
const waContactTypeSel = el("select", { class: "input" }, [
el("option", { value: "admin", text: t("common.admin") }),
el("option", { value: "whitelist", text: t("common.whitelist") }),
el("option", { value: "blacklist", text: t("common.blacklist") }),
]);
const waPendingPickSel = el(
"select",
{ class: "input" },
[
el("option", { value: "", text: t("wa.pickPending") }),
...waPending.map((p, idx) => {
const phone = waPhoneDisplay(p);
const name = String((p && p.push_name) || "").trim();
const label = name ? `${name} (${phone})` : phone;
return el("option", { value: String(idx), text: label || phone });
}),
],
);
waPendingPickSel.addEventListener("change", () => {
const idx = Number(String(waPendingPickSel.value || "").trim());
if (!Number.isFinite(idx) || idx < 0 || idx >= waPending.length) return;
const picked = waPending[idx] || {};
const phone = waPhoneDisplay(picked);
const name = String((picked && picked.push_name) || "").trim();
if (phone && phone !== "-") waContactPhoneInput.value = phone;
waContactNameInput.value = name || "";
});
const waResolvePending = async (pendingId, action) => {
if (!pendingId) return;
try {
const resp = await apiPost("/admin/api/whatsapp/access/pending/resolve", {
pending_id: pendingId,
action,
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (t("common.requestFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
};
const waDeletePending = async (pendingId) => {
if (!pendingId) return;
try {
const resp = await apiDeleteJson(
`/admin/api/whatsapp/access/pending?pending_id=${encodeURIComponent(pendingId)}`,
);
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (t("common.deleteFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
};
const waDeniedRows = waDenied.map((p) => {
const pendingId = String((p && p.id) || "").trim();
const phone = waPhoneDisplay(p);
const name = String((p && p.push_name) || "").trim();
return el("tr", {}, [
el("td", { text: name || "-" }),
el("td", { text: phone }),
el("td", { text: String((p && p.request_text) || "").slice(0, 80) }),
el("td", { text: String((p && p.resolved_at) || p.created_at || "") }),
el("td", {}, [
el("button", {
class: "btn btn--primary",
text: t("wa.addWhitelist"),
onclick: async () => { await waResolvePending(pendingId, "approve"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn btn--danger",
text: t("common.delete"),
onclick: async () => { await waDeletePending(pendingId); },
}),
]),
]);
});
const waPendingRows = waPending.map((p) => {
const pendingId = String((p && p.id) || "").trim();
return el("tr", {}, [
el("td", { text: String((p && p.push_name) || "-") }),
el("td", { text: waPhoneDisplay(p) }),
el("td", { text: String((p && p.external_user_id) || "") }),
el("td", { text: String((p && p.request_text) || "").slice(0, 80) }),
el("td", { text: String((p && p.created_at) || "") }),
el("td", {}, [
el("button", {
class: "btn btn--primary",
text: t("wa.approve"),
onclick: async () => { await waResolvePending(pendingId, "approve"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn",
text: t("wa.deny"),
onclick: async () => { await waResolvePending(pendingId, "deny"); },
}),
el("span", { text: " " }),
el("button", {
class: "btn btn--danger",
text: t("common.delete"),
onclick: async () => { await waDeletePending(pendingId); },
}),
]),
]);
});
const whatsappAccessCard = el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("wa.accessTitle") }),
el("div", {
class: "card__subtitle",
text: t("wa.accessHint"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("common.mode") }),
waAccessModeSel,
]),
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.messageLang") }),
waAccessLangSel,
]),
el("div", { class: "form-inline__actions" }, [
el("button", {
class: "btn btn--primary",
text: t("wa.saveConfig"),
onclick: async () => {
const resp = await apiPost("/admin/api/whatsapp/access/config", {
tenant_id: "default",
access_mode: String(waAccessModeSel.value || "blacklist"),
lang: String(waAccessLangSel.value || "en"),
});
const cfg = (resp && resp.config) || {};
waAccessStatus.replaceChildren(
...Array.from(
renderMetaChips({
mode: String(cfg.access_mode || "blacklist"),
lang: String(cfg.lang || "en"),
admin: waCounts.admin,
whitelist: waCounts.whitelist,
blacklist: waCounts.blacklist,
pending: waPending.length,
denied: waDenied.length,
}).childNodes,
),
);
},
}),
]),
]),
waAccessStatus,
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("wa.filter") }),
waContactFilterSel,
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field form-inline__field--wide" }, [waPendingPickSel]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [waContactPhoneInput]),
el("div", { class: "form-inline__field" }, [waContactNameInput]),
el("div", { class: "form-inline__field" }, [waContactTypeSel]),
el("div", { class: "form-inline__actions" }, [
el("button", {
class: "btn",
text: t("wa.addContact"),
onclick: async () => {
const phone = String(waContactPhoneInput.value || "").trim();
if (!phone) return;
try {
const resp = await apiPost("/admin/api/whatsapp/access/contacts", {
phone,
push_name: String(waContactNameInput.value || "").trim(),
list_type: String(waContactTypeSel.value || "whitelist"),
});
if (!resp || resp.ok === false) {
window.alert(String((resp && resp.error) || (t("wa.addFailed"))));
return;
}
navigateAdmin();
} catch (err) {
window.alert(String(err));
}
},
}),
]),
]),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colType") }),
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: t("wa.colAction") }),
])]),
waContactsTbody,
]),
]),
el("div", { class: "card__title", text: t("wa.pendingTitle") }),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: "JID" }),
el("th", { text: t("wa.colMessage") }),
el("th", { text: t("wa.colTime") }),
el("th", { text: t("wa.colAction") }),
])]),
el("tbody", {}, waPendingRows.length ? waPendingRows : [el("tr", {}, [el("td", { colspan: "6", text: t("wa.nonePending") })])]),
]),
]),
el("div", { class: "card__title", text: t("wa.deniedTitle") }),
el("div", { class: "card__hint", text: t("wa.deniedHint") }),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("wa.colName") }),
el("th", { text: t("wa.colPhone") }),
el("th", { text: t("wa.colMessage") }),
el("th", { text: t("wa.colDeniedAt") }),
el("th", { text: t("wa.colAction") }),
])]),
el("tbody", {}, waDeniedRows.length ? waDeniedRows : [el("tr", {}, [el("td", { colspan: "5", text: t("wa.noneDenied") })])]),
]),
]),
]);
const whatsappAlertBindingCard = el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("wa.alertGroupTitle") }),
el("div", {
class: "card__subtitle",
text: t("wa.alertGroupHint"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field form-inline__field--wide" }, [waGroupSel]),
el("div", { class: "form-inline__actions" }, [
el("button", {
class: "btn btn--primary",
text: t("wa.saveBinding"),
onclick: async () => {
const group_jid = String(waGroupSel.value || "").trim();
if (!group_jid) return;
const picked = waGroups.find((g) => String((g && g.group_jid) || "").trim() === group_jid) || {};
const resp = await apiPost("/admin/api/whatsapp/alert-binding", {
tenant_id: "default",
group_jid,
group_name: String((picked && picked.group_name) || ""),
enabled: true,
});
const b = (resp && resp.binding) || {};
waBindingStatus.textContent = b.group_jid
? `binding=${b.group_jid} enabled=${Boolean(b.enabled)}`
: (t("wa.bindFailed"));
},
}),
el("button", {
class: "btn",
text: t("wa.testPush"),
onclick: async () => {
const resp = await apiPost("/admin/api/whatsapp/alert-binding/test", { tenant_id: "default" });
waBindingStatus.textContent = resp && resp.ok
? `test ok outbound_id=${String(resp.outbound_id || "")}`
: `test failed: ${String((resp && resp.error) || "unknown")}`;
},
}),
]),
]),
waBindingStatus,
]);
const scanItems = Array.isArray(scanResp && scanResp.items) ? scanResp.items : [];
const scanDir = String((scanResp && scanResp.dir) || "");
const scanStatus = el("div", { class: "muted", text: "" });
const keepLatestInput = el("input", { class: "input", type: "number", value: "20", min: "0", max: "500" });
const maxAgeDaysInput = el("input", { class: "input", type: "number", value: "7", min: "0", max: "3650" });
const btnCleanScan = el("button", {
class: "btn btn--danger",
text: t("stack.clearScanCache"),
onclick: async () => {
const resp = await apiPost("/admin/api/runtime/scan-artifacts/cleanup", {});
scanStatus.textContent = `removed=${Number((resp && resp.removed) || 0)}`;
navigateAdmin();
},
});
const btnPruneScan = el("button", {
class: "btn",
text: t("stack.clearByPolicy"),
onclick: async () => {
const keepLatest = Number(keepLatestInput.value || 20);
const maxAgeDays = Number(maxAgeDaysInput.value || 7);
const resp = await apiPost("/admin/api/runtime/scan-artifacts/prune", {
keep_latest: Number.isFinite(keepLatest) ? keepLatest : 20,
max_age_days: Number.isFinite(maxAgeDays) ? maxAgeDays : 7,
});
scanStatus.textContent = `removed=${Number((resp && resp.removed) || 0)} keep_latest=${Number((resp && resp.keep_latest) || 0)} max_age_days=${Number((resp && resp.max_age_days) || 0)}`;
navigateAdmin();
},
});
const scanRows = scanItems.length
? scanItems.map((x) => el("tr", {}, [
tdCell(String(x.name || ""), 26),
tdCell(String(x.bytes || 0), 12),
tdCell(String(x.modified_at || ""), 28),
]))
: [el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "3" })])];
const prewarmInfo = prewarmStatusResp && typeof prewarmStatusResp === "object" ? prewarmStatusResp : {};
const prewarmLast = prewarmInfo.last && typeof prewarmInfo.last === "object" ? prewarmInfo.last : {};
const prewarmHistory = Array.isArray(prewarmInfo.history) ? prewarmInfo.history : [];
const freeze = prewarmInfo.freeze && typeof prewarmInfo.freeze === "object" ? prewarmInfo.freeze : {};
const prewarmStatus = el("div", { class: "muted", text: "" });
const btnPrewarm = el("button", {
class: "btn btn--primary",
text: t("stack.prewarmNow"),
onclick: async () => {
prewarmStatus.textContent = t("stack.prewarmSubmitting");
const resp = await apiPost("/admin/api/runtime/prewarm", { mode: "async", reason: "admin_manual" });
if (!(resp && resp.accepted)) {
prewarmStatus.textContent = `[prewarm] accepted=${Boolean(resp && resp.accepted)}`;
return;
}
prewarmStatus.textContent =
t("auto.prewarm_running_in_background_refreshing_this_page_when_");
runtimePrewarmReminder = "";
const maxWaitMs = 120000;
const stepMs = 400;
let waited = 0;
while (waited < maxWaitMs) {
await new Promise((r) => setTimeout(r, stepMs));
waited += stepMs;
const st = await apiGet("/admin/api/runtime/prewarm/status");
if (!(st && st.running)) break;
}
await navigateAdmin();
},
});
const prewarmSummary = [
`running=${Boolean(prewarmInfo.running)}`,
`last_ok=${Boolean(prewarmLast.ok)}`,
`elapsed_ms=${Number(prewarmLast.elapsed_ms || 0)}`,
`freeze_enabled=${Boolean(freeze.enabled)}`,
`frozen=${Boolean(freeze.frozen)}`,
`last_warm=${
Number(freeze.last_warm_ts_ms || 0) > 0
? formatSystemLocalDateTime(new Date(Number(freeze.last_warm_ts_ms || 0)).toISOString())
: "-"
}`,
].join(" | ");
const historyRows = prewarmHistory.length
? prewarmHistory.slice(0, 20).map((x) =>
el("tr", {}, [
tdCell(formatSystemLocalDateTime(new Date(Number(x.finished_at_ms || 0)).toISOString()), 24),
tdCell(String(x.reason || "-"), 20),
tdCell(String(Boolean(x.ok)), 8),
tdCell(String(Number(x.elapsed_ms || 0)), 10),
tdCell(String(x.error || "-"), 38),
]))
: [el("tr", {}, [el("td", { class: "muted", text: "—", colspan: "5" })])];
const promptsSectionBody = el("div");
const promptsRoleSelect = el("select", { class: "input" }, [
el("option", { value: "generalist", text: "generalist (default)" }),
el("option", { value: "", text: t("stack.allRoles") }),
]);
const renderPromptCards = (resp) => {
const promptsObj = resp && typeof resp === "object" ? resp : {};
const promptsMap = promptsObj.prompts && typeof promptsObj.prompts === "object" ? promptsObj.prompts : {};
const promptRoleCards = Object.keys(promptsMap)
.sort((a, b) => String(a).localeCompare(String(b)))
.map((rid) => {
const item = promptsMap[rid] && typeof promptsMap[rid] === "object" ? promptsMap[rid] : {};
const finalSystem = String(item.system_prompt || "");
return el("div", { class: "card u-mt-10" }, [
el("div", { class: "card__title", text: `role: ${rid}` }),
el("div", {}, [
el("div", { class: "muted", text: "system_prompt" }),
el("textarea", {
class: "input",
rows: "12",
readonly: "readonly",
text: finalSystem,
style: "width:100%;box-sizing:border-box;",
}),
]),
]);
});
promptsSectionBody.innerHTML = "";
if (promptRoleCards.length) {
promptRoleCards.forEach((n) => promptsSectionBody.appendChild(n));
} else {
promptsSectionBody.appendChild(el("div", { class: "muted", text: "-" }));
}
};
const btnReloadPrompts = el("button", {
class: "btn",
text: t("stack.reloadPrompts"),
onclick: async () => {
const role = String(promptsRoleSelect.value || "").trim();
const q = role ? `?role=${encodeURIComponent(role)}` : "";
const resp = await apiGet(`/admin/api/runtime/prewarm/prompts${q}`);
renderPromptCards(resp);
},
});
promptsRoleSelect.addEventListener("change", async () => {
const role = String(promptsRoleSelect.value || "").trim();
const q = role ? `?role=${encodeURIComponent(role)}` : "";
const resp = await apiGet(`/admin/api/runtime/prewarm/prompts${q}`);
renderPromptCards(resp);
});
renderPromptCards(prewarmPromptsResp);
return el("div", { class: "page-shell" }, [
el("div", { class: "page-grid page-grid--two" }, [
weixinDispatchCard,
whatsappDispatchCard,
]),
whatsappAccessCard,
whatsappAlertBindingCard,
el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("stack.prewarmTitle") }),
el("div", { class: "card__subtitle", text: prewarmSummary }),
]),
el("div", { class: "card__actions" }, [btnPrewarm]),
]),
runtimePrewarmReminder ? el("div", { class: "alert alert--warning", text: runtimePrewarmReminder }) : null,
el(
"div",
{
class: "card__hint",
text:
t("auto.after_any_skill_tool_role_prompt_change_run_prewarm_imme"),
},
),
prewarmStatus,
].filter(Boolean)),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("stack.prewarmHistory") }),
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: "finished_at" }),
el("th", { text: "reason" }),
el("th", { text: "ok" }),
el("th", { text: "elapsed_ms" }),
el("th", { text: "error" }),
])]),
el("tbody", {}, historyRows),
]),
]),
]),
el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("stack.prewarmedPrompts") }),
el("div", {
class: "card__subtitle",
text: t("stack.prewarmedPromptsHint"),
}),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: t("stack.roleFilter") }),
promptsRoleSelect,
]),
el("div", { class: "form-inline__actions" }, [btnReloadPrompts]),
]),
promptsSectionBody,
]),
el("div", { class: "card" }, [
el("div", { class: "card__head" }, [
el("div", { class: "card__headline" }, [
el("div", { class: "card__title", text: t("stack.scanCacheTitle") }),
el("div", { class: "card__subtitle", text: tf("stack.scanCacheSubtitle", { dir: scanDir || "-" }) }),
]),
]),
el("div", { class: "form-inline" }, [
el("div", { class: "form-inline__field" }, [
el("label", { text: "keep_latest" }),
keepLatestInput,
]),
el("div", { class: "form-inline__field" }, [
el("label", { text: "max_age_days" }),
maxAgeDaysInput,
]),
el("div", { class: "form-inline__actions" }, [btnPruneScan, btnCleanScan]),
]),
scanStatus,
el("div", { class: "table-wrap" }, [
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [el("th", { text: "name" }), el("th", { text: "bytes" }), el("th", { text: "modified_at" })])]),
el("tbody", {}, scanRows),
]),
]),
]),
]);
}
export { renderStack };

View file

@ -0,0 +1,691 @@
import { state, t, tf, el, tdCell, apiGet, apiPost, formatUserLabel, formatSystemLocalDateTime, navigateAdmin } from "../core.js";
import { hasPermission } from "./authz.js";
async function renderUserManagement() {
const sessionTid = String((state.authSession && state.authSession.tenant_id) || "").trim();
if (!sessionTid) {
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("users.title") }),
el("div", { class: "muted", text: t("tenants.noSessionTenant") }),
]),
]);
}
let allTenants = [];
try {
const allTenantsResp = await apiGet("/admin/api/tenants");
allTenants = allTenantsResp.tenants || [];
} catch (err) {
if (!hasPermission("admin:user:read")) {
return el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("common.error") }),
el("div", { class: "pre", text: String(err) }),
]);
}
allTenants = [{ id: sessionTid, name: "", created_at: "" }];
}
if (!allTenants.length) {
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("users.title") }),
el("div", { class: "muted", text: t("tenants.noTenants") }),
]),
]);
}
const status = el("div", { class: "muted", text: "" });
const tenantRows = allTenants.map((x) => {
const tid = String(x.id || "");
const isLoginHome = tid === sessionTid;
const tname = String(x.name || "").trim() || tid.slice(0, 8);
const canDelete = allTenants.length > 1 && tid !== sessionTid;
const btnDel = el("button", {
type: "button",
class: "btn btn--danger",
text: t("tenants.deleteTenant"),
disabled: canDelete ? undefined : "disabled",
title: canDelete
? undefined
: allTenants.length <= 1
? t("tenants.deleteDisabledLast")
: t("tenants.deleteDisabledCurrent"),
onclick: async (e) => {
e.preventDefault();
e.stopPropagation();
if (!canDelete) return;
if (!window.confirm(tf("tenants.deleteTenantConfirm", { name: tname }))) return;
status.textContent = "";
try {
const resp = await apiPost("/admin/api/tenants/delete", { tenant_id: tid });
if (!resp.ok) {
const err = String(resp.error || "error");
status.textContent =
err === "last_tenant_cannot_delete"
? t("tenants.errLastTenant")
: err === "cannot_delete_current_tenant"
? t("tenants.errCannotDeleteCurrentTenant")
: err;
return;
}
if (!Number(resp.deleted || 0)) {
status.textContent = t("tenants.errTenantDeleteMiss");
return;
}
await navigateAdmin();
} catch (err) {
status.textContent = String(err && err.message ? err.message : err);
}
},
});
return el("tr", {}, [
el("td", { text: String(x.name || "") }),
el("td", { text: formatSystemLocalDateTime(String(x.created_at || "")) }),
el("td", {
class: isLoginHome ? "" : "muted",
text: isLoginHome ? t("tenants.scopeLoginHome") : "—",
}),
el("td", {}, [btnDel]),
]);
});
const selector = el("select", { class: "input" });
for (const item of allTenants) {
const optLabel = String(item.name || "").trim() || String(item.id || "").slice(0, 8);
selector.appendChild(el("option", { value: String(item.id || ""), text: optLabel }));
}
const preferredIdx = allTenants.findIndex((x) => String(x.id || "") === sessionTid);
selector.selectedIndex = preferredIdx >= 0 ? preferredIdx : 0;
const bindingsBody = el("tbody");
const codesBody = el("tbody");
const searchInput = el("input", { class: "input", placeholder: t("users.search") });
const userStatus = el("div", { class: "muted", text: "" });
const userBody = el("tbody");
const accountStatus = el("div", { class: "muted", text: "" });
const selectedUserField = el("input", {
class: "input input--readonly",
readonly: "readonly",
placeholder: t("users.wecomUserEmpty"),
});
const accountBody = el("tbody");
const accountChannelInput = el("select", { class: "input" }, [
el("option", { value: "wecom", text: "wecom" }),
el("option", { value: "weixin", text: "weixin" }),
el("option", { value: "whatsapp", text: "whatsapp" }),
]);
const accountIdInput = el("input", { class: "input", placeholder: t("users.wecomBotId") });
const accountNameInput = el("input", { class: "input", placeholder: t("users.wecomInstanceName") });
const botSecretInput = el("input", { class: "input", type: "password", placeholder: t("users.wecomBotSecret") });
const botSecretField = el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotSecret") }), botSecretInput]);
const clearBotChk = el("input", { type: "checkbox" });
const clearBotField = el("label", { class: "kv row--wecom-form__chk" }, [clearBotChk, document.createTextNode(" " + t("users.clearBotSecret"))]);
const accountActiveInput = el("input", { type: "checkbox" });
const accountSpecialistInput = el("select", { class: "input" }, [el("option", { value: "generalist", text: "generalist" })]);
accountActiveInput.checked = true;
let selectedUserId = "";
let selectedUsername = "";
let selectedDisplayName = "";
let availableAccountSpecialists = ["generalist"];
const refreshSelectedUserField = () => {
if (!selectedUserId) {
selectedUserField.value = "";
return;
}
const who = formatUserLabel({ username: selectedUsername, display_name: selectedDisplayName });
selectedUserField.value = who;
};
const clearWecomForm = () => {
accountIdInput.value = "";
accountNameInput.value = "";
botSecretInput.value = "";
clearBotChk.checked = false;
accountActiveInput.checked = true;
accountSpecialistInput.value = "generalist";
};
const currentAccountChannel = () => String(accountChannelInput.value || "wecom").trim().toLowerCase() || "wecom";
const refreshAccountFormByChannel = () => {
const isWecom = currentAccountChannel() === "wecom";
botSecretField.style.display = isWecom ? "" : "none";
clearBotField.style.display = isWecom ? "" : "none";
};
const setAccountSpecialistOptions = (specialists) => {
const opts = Array.isArray(specialists) && specialists.length
? specialists.map((x) => String(x || "").trim()).filter(Boolean)
: ["generalist"];
const uniq = Array.from(new Set(["generalist", ...opts]));
availableAccountSpecialists = uniq;
const old = String(accountSpecialistInput.value || "generalist").trim() || "generalist";
accountSpecialistInput.innerHTML = "";
uniq.forEach((sid) => {
accountSpecialistInput.appendChild(el("option", { value: sid, text: sid, selected: sid === old ? "selected" : undefined }));
});
if (!uniq.includes(old)) accountSpecialistInput.value = "generalist";
};
const newUserName = el("input", { class: "input", placeholder: t("users.createNamePlaceholder") });
const newUserRole = el("select", { class: "input" }, [
el("option", { value: "member", text: "member" }),
el("option", { value: "admin", text: "admin" }),
el("option", { value: "guest", text: "guest" }),
el("option", { value: "owner", text: "owner" }),
]);
const newUserPassword = el("input", { class: "input", type: "password", placeholder: t("auth.password") });
const getTenantId = () => String(selector.value || "");
const mergeWecomBindingAndInstances = (bindings, items) => {
const byAid = new Map();
for (const it of items) {
const aid = String(it.account_id || "").trim();
if (aid) byAid.set(aid, it);
}
const seen = new Set();
const out = [];
for (const b of bindings) {
const aid = String(b.account_id || "").trim();
if (!aid || seen.has(aid)) continue;
seen.add(aid);
out.push({ binding: b, instance: byAid.get(aid) || null });
}
for (const it of items) {
const aid = String(it.account_id || "").trim();
if (!aid || seen.has(aid)) continue;
out.push({ binding: null, instance: it });
}
return out;
};
const prefillFromMerged = (m) => {
const inst = m.instance;
const bind = m.binding;
const aid = inst ? String(inst.account_id || "").trim() : String(bind?.account_id || "").trim();
accountIdInput.value = aid;
const nameFromInst = inst ? String(inst.name || "").trim() : "";
const nameFromBind = bind ? String(bind.account_name || "").trim() : "";
accountNameInput.value = nameFromInst || nameFromBind;
if (inst) {
accountActiveInput.checked = !!inst.is_active;
} else {
accountActiveInput.checked = true;
}
const specialist = String(((inst && inst.config) || {}).specialist || "generalist").trim().toLowerCase() || "generalist";
accountSpecialistInput.value = availableAccountSpecialists.includes(specialist) ? specialist : "generalist";
accountStatus.textContent =
tf("users.accountModeExpert", { specialist });
botSecretInput.value = "";
clearBotChk.checked = false;
};
const loadAccounts = async () => {
accountBody.innerHTML = "";
clearWecomForm();
const tenantId = getTenantId();
if (!selectedUserId) {
accountStatus.textContent = "";
accountBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "10" })]));
return;
}
const ch = currentAccountChannel();
const bindUrl = `/admin/api/bindings?tenant_id=${encodeURIComponent(tenantId)}&channel=${encodeURIComponent(ch)}&user_id=${encodeURIComponent(selectedUserId)}`;
const accUrl = `/admin/api/user-channel-accounts?tenant_id=${encodeURIComponent(tenantId)}&user_id=${encodeURIComponent(selectedUserId)}&channel=${encodeURIComponent(ch)}&include_inactive=1`;
const [bindResp, accResp] = await Promise.all([ch === "wecom" ? apiGet(bindUrl) : Promise.resolve({ bindings: [] }), apiGet(accUrl)]);
const bindings = Array.isArray(bindResp && bindResp.bindings) ? bindResp.bindings : [];
const items = Array.isArray(accResp.items) ? accResp.items : [];
const merged = ch === "wecom" ? mergeWecomBindingAndInstances(bindings, items) : items.map((it) => ({ binding: null, instance: it }));
accountStatus.textContent = "";
if (!merged.length) {
accountBody.appendChild(el("tr", {}, [el("td", { text: t("users.noBindingsForUser"), colspan: "10" })]));
return;
}
merged.forEach((m) => {
const inst = m.instance;
const bind = m.binding;
const accountId = inst ? String(inst.account_id || "") : String(bind?.account_id || "");
const displayName = (inst && String(inst.name || "").trim()) || (bind && String(bind.account_name || "").trim()) || "";
const extUid = bind ? String(bind.external_user_id || "") : "";
const ts = (inst && String(inst.updated_at || "").trim()) || (bind && String(bind.created_at || "").trim()) || "";
const cfg = (inst && inst.config && typeof inst.config === "object") ? inst.config : {};
const modeText = String(cfg.interaction_mode || "expert");
const specialistText = String(cfg.specialist || "generalist");
const btnFill = el("button", {
class: "btn",
text: t("users.loadForm"),
onclick: (e) => {
e.stopPropagation();
prefillFromMerged(m);
},
});
const deleteCell = el("td", {});
if (inst) {
const btnBindExpert = el("button", {
class: "btn btn--primary",
text: t("wa.bindSpecialist"),
onclick: async (e) => {
e.stopPropagation();
const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist";
await apiPost("/admin/api/user-channel-accounts/upsert", {
tenant_id: tenantId,
user_id: selectedUserId,
channel: ch,
account_id: accountId,
name: displayName,
is_active: !!inst.is_active,
config: { interaction_mode: "expert", specialist },
});
await loadAccounts();
},
});
const btnDeleteAccount = el("button", { class: "btn btn--danger", text: t("users.accountDelete"), onclick: async (e) => {
e.stopPropagation();
await apiPost("/admin/api/user-channel-accounts/delete", {
tenant_id: tenantId,
user_id: selectedUserId,
channel: ch,
account_id: accountId,
});
await loadAccounts();
}});
deleteCell.appendChild(btnBindExpert);
deleteCell.appendChild(btnDeleteAccount);
} else {
deleteCell.appendChild(document.createTextNode("—"));
}
accountBody.appendChild(el("tr", {}, [
tdCell(accountId, 22),
tdCell(displayName, 16),
tdCell(extUid || "—", 18),
tdCell(inst ? (ch === "wecom" ? (inst.has_bot_secret ? "Y" : "-") : "-") : "—", 4),
tdCell(inst ? (inst.is_active ? "1" : "0") : "—", 4),
tdCell(modeText || "expert", 8),
tdCell(specialistText || "generalist", 10),
tdCell(ts, 20),
el("td", {}, [btnFill]),
deleteCell,
]));
});
};
const btnSaveAccount = el("button", { class: "btn", text: t("users.accountSave"), onclick: async () => {
if (!selectedUserId) {
accountStatus.textContent = "select user first";
return;
}
const aid = accountIdInput.value.trim();
if (!aid) {
accountStatus.textContent = t("users.botIdRequired");
return;
}
const tenantId = getTenantId();
const ch = currentAccountChannel();
const specialist = String(accountSpecialistInput.value || "generalist").trim() || "generalist";
const resp = await apiPost("/admin/api/user-channel-accounts/upsert", {
tenant_id: tenantId,
user_id: selectedUserId,
channel: ch,
account_id: aid,
name: accountNameInput.value.trim(),
wecom_mode: ch === "wecom" ? "bot_api" : undefined,
bot_secret: botSecretInput.value.trim(),
clear_bot_secret: !!clearBotChk.checked,
is_active: !!accountActiveInput.checked,
config: {
interaction_mode: "expert",
specialist,
},
});
accountStatus.textContent = resp.ok ? "ok" : String(resp.error || "error");
if (resp.ok) {
botSecretInput.value = "";
clearBotChk.checked = false;
await loadAccounts();
await loadList();
}
}});
accountChannelInput.addEventListener("change", async () => {
clearWecomForm();
refreshAccountFormByChannel();
await loadAccounts();
});
const loadList = async () => {
if (!hasPermission("admin:user:read")) {
userBody.innerHTML = "";
userBody.appendChild(el("tr", {}, [el("td", { text: t("users.needUserRead"), colspan: "7" })]));
return;
}
const tenantId = getTenantId();
const tMeta = allTenants.find((r) => String(r.id || "") === tenantId) || {};
const sessionTenantName = String(tMeta.name || "").trim();
const q = encodeURIComponent(searchInput.value.trim());
const resp = await apiGet(`/admin/api/users?tenant_id=${encodeURIComponent(tenantId)}&include_inactive=1&q=${q}&limit=300`);
const users = Array.isArray(resp.users) ? resp.users : [];
userBody.innerHTML = "";
if (!users.length) {
userBody.appendChild(el("tr", {}, [el("td", { text: t("audit.empty"), colspan: "7" })]));
return;
}
users.forEach((u) => {
const newRole = el("select", { class: "input" }, [
el("option", { value: "member", text: "member" }),
el("option", { value: "admin", text: "admin" }),
el("option", { value: "guest", text: "guest" }),
el("option", { value: "owner", text: "owner" }),
]);
newRole.value = String(u.role || "member");
const pwd = el("input", { class: "input", type: "password", placeholder: t("users.passwordOptional") });
const btnToggle = el("button", { class: "btn", text: u.is_active ? t("users.disable") : t("users.enable"), onclick: async () => {
await apiPost("/admin/api/users/update", {
tenant_id: tenantId,
user_id: u.id,
is_active: !u.is_active,
});
await loadList();
}});
const btnUpdate = el("button", { class: "btn", text: t("users.update"), onclick: async () => {
await apiPost("/admin/api/users/update", {
tenant_id: tenantId,
user_id: u.id,
role: newRole.value,
password: pwd.value.trim() || undefined,
});
await loadList();
}});
const btnDelete = el("button", { class: "btn btn--danger", text: t("users.delete"), onclick: async (e) => {
e.stopPropagation();
if (!window.confirm(t("users.deleteConfirm"))) return;
await apiPost("/admin/api/users/delete", { tenant_id: tenantId, user_id: u.id });
await loadList();
}});
const actionWrap = el("div", { class: "table__cell-actions" }, [btnToggle, btnUpdate, btnDelete]);
const tr = el("tr", {}, [
tdCell(sessionTenantName || "—", 14),
tdCell(formatUserLabel(u), 28),
tdCell(u.role || "", 10),
tdCell(u.is_active ? "1" : "0", 4),
el("td", { class: "table__cell--form" }, [newRole]),
el("td", { class: "table__cell--form" }, [pwd]),
el("td", { class: "table__cell--actions" }, [actionWrap]),
]);
tr.style.cursor = "pointer";
tr.addEventListener("click", async (e) => {
if (e.target && e.target.closest && e.target.closest("button,select,input")) return;
selectedUserId = String(u.id || "");
selectedUsername = String(u.username || "");
selectedDisplayName = String(u.display_name || "");
refreshSelectedUserField();
await loadAccounts();
});
userBody.appendChild(tr);
});
};
const btnCreateUser = el("button", { class: "btn btn--primary", text: t("users.create"), onclick: async () => {
if (!hasPermission("admin:user:write")) {
userStatus.textContent = "forbidden";
return;
}
try {
const raw = newUserName.value.trim();
if (!raw) {
userStatus.textContent = t("users.createNameRequired");
return;
}
const resp = await apiPost("/admin/api/users/create", {
tenant_id: getTenantId(),
username: raw,
display_name: raw,
role: newUserRole.value,
password: newUserPassword.value.trim(),
});
userStatus.textContent = resp.ok ? "ok" : String(resp.error || "error");
if (resp.ok) {
newUserName.value = "";
newUserPassword.value = "";
await loadList();
await loadBindingsAndCodes();
}
} catch (e) {
userStatus.textContent = String(e && e.message ? e.message : e);
}
}});
const btnSearchUsers = el("button", { class: "btn", text: t("audit.query"), onclick: loadList });
const createName = el("input", { class: "input", placeholder: t("tenants.createPlaceholder") });
const btnCreateTenant = el("button", { class: "btn", text: t("tenants.create"), onclick: async () => {
if (!hasPermission("admin:tenant:write")) {
status.textContent = "forbidden";
return;
}
await apiPost("/admin/api/tenants/create", { name: createName.value.trim() || "Team" });
await navigateAdmin();
}});
const roleInput = el("select", { class: "input" }, [
el("option", { value: "member", text: "member" }),
el("option", { value: "admin", text: "admin" }),
el("option", { value: "guest", text: "guest" }),
]);
const codeInput = el("input", { class: "input", placeholder: t("tenants.codeOptional") });
const btnCreateCode = el("button", { class: "btn", text: t("tenants.createCode"), onclick: async () => {
const tid = String(selector.value || "");
await apiPost("/admin/api/bind-codes/create", {
tenant_id: tid,
role: roleInput.value || "member",
code: codeInput.value.trim(),
});
codeInput.value = "";
await loadBindingsAndCodes();
}});
const btnDeleteUnbound = el("button", { class: "btn btn--danger", text: t("tenants.deleteUnbound"), onclick: async () => {
const tid = String(selector.value || "");
const resp = await apiPost("/admin/api/users/delete-unbound", { tenant_id: tid, channel: "wecom" });
status.textContent = tf("tenants.deleteUnboundResult", {
deleted: Number(resp.deleted || 0),
orphan: Number(resp.orphan_users || 0),
bound: Number(resp.bound_users || 0),
total: Number(resp.users_total || 0),
});
await loadBindingsAndCodes();
await loadList();
}});
const loadBindingsAndCodes = async () => {
const tid = String(selector.value || "");
const [bindingsResp, codesResp] = await Promise.all([
apiGet("/admin/api/bindings?tenant_id=" + encodeURIComponent(tid) + "&channel=wecom"),
apiGet("/admin/api/bind-codes?tenant_id=" + encodeURIComponent(tid)),
]);
const bindings = bindingsResp.bindings || [];
const codes = codesResp.codes || [];
const bindingTenantLabel = (tenantId) => {
const id = String(tenantId || "");
const meta = allTenants.find((x) => String(x.id || "") === id);
const name = String(meta && meta.name ? meta.name : "").trim();
return name || id.slice(0, 8) || "—";
};
const bindingUserLabel = (b) =>
formatUserLabel({
username: String(b.username != null && b.username !== "" ? b.username : b.user_id || ""),
display_name: String(b.display_name || ""),
});
bindingsBody.innerHTML = "";
if (!bindings.length) {
bindingsBody.appendChild(el("tr", {}, [el("td", { text: t("tenants.noBindings"), colspan: "6" })]));
} else {
bindings.forEach((b) => {
bindingsBody.appendChild(el("tr", {}, [
el("td", { text: bindingTenantLabel(b.tenant_id) }),
el("td", { text: bindingUserLabel(b) }),
el("td", { text: String(b.channel || "") }),
el("td", { text: String(b.account_id || "") }),
el("td", { text: String(b.account_name || "") }),
el("td", { text: String(b.external_user_id || "") }),
]));
});
}
codesBody.innerHTML = "";
if (!codes.length) {
codesBody.appendChild(el("tr", {}, [el("td", { text: "-", colspan: "6" })]));
} else {
codes.forEach((c) => {
const codeText = String(c.code || "");
const isUsed = Boolean(c.used_at);
const btnDeleteCode = el("button", { class: "btn btn--danger", text: t("tenants.deleteCode"), onclick: async () => {
if (!window.confirm(t("tenants.codeDeleteConfirm"))) return;
await apiPost("/admin/api/bind-codes/delete", { tenant_id: tid, code: codeText });
status.textContent = tf("tenants.codeDeleted", { code: codeText });
await loadBindingsAndCodes();
}});
codesBody.appendChild(el("tr", {}, [
el("td", { text: codeText }),
el("td", { text: String(c.role || "") }),
el("td", { text: isUsed ? t("tenants.codeUsed") : t("tenants.codeUnused") }),
el("td", { text: String(c.used_by_external_user_id || "") }),
el("td", { text: formatSystemLocalDateTime(String(c.created_at || "")) }),
el("td", {}, [btnDeleteCode]),
]));
});
}
};
selector.addEventListener("change", () => {
selectedUserId = "";
selectedUsername = "";
selectedDisplayName = "";
refreshSelectedUserField();
Promise.all([loadBindingsAndCodes(), loadList(), loadAccounts()]).catch((err) => {
mount(el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.error") }), el("div", { class: "pre", text: String(err) })]));
});
});
await loadBindingsAndCodes();
try {
const sf = await apiGet("/admin/api/chat/settings/specialist-flags");
setAccountSpecialistOptions(sf.available_specialists || ["generalist"]);
} catch (_) {
setAccountSpecialistOptions(["generalist"]);
}
await loadList();
refreshSelectedUserField();
refreshAccountFormByChannel();
await loadAccounts();
const tenantsListHint = allTenants.length > 1
? el("div", { class: "muted u-mt-8", text: t("tenants.allTenantsHint") })
: el("div", {});
return el("div", {}, [
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("tenants.title") }),
el("div", { class: "row" }, [createName, btnCreateTenant]),
el("div", { class: "row" }, [el("label", { text: t("tenants.select") }), selector]),
el("div", { class: "muted u-mt-6", text: t("tenants.scopeHint") }),
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("tenants.colTenantName") }),
el("th", { text: t("table.createdAt") }),
el("th", { text: t("tenants.colScope") }),
el("th", { text: t("tenants.rowActions") }),
])]),
el("tbody", {}, tenantRows),
]),
status,
tenantsListHint,
]),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("users.title") }),
el("div", { class: "row" }, [searchInput, btnSearchUsers]),
el("div", { class: "row" }, [newUserName, newUserRole, newUserPassword, btnCreateUser]),
userStatus,
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact table--users-mgmt" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("tenants.colTenantName") }),
el("th", { text: t("users.colUser") }),
el("th", { text: t("tenants.role") }),
el("th", { text: t("users.colActive") }),
el("th", { text: t("users.colSetRole") }),
el("th", { text: t("users.passwordOptional") }),
el("th", { text: t("tenants.rowActions") }),
])]),
userBody,
])]),
]),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("users.wecomAccounts") }),
el("div", {}, [el("div", { class: "muted", text: t("users.wecomUserLabel") }), selectedUserField]),
el("div", { class: "muted", style: "margin-bottom:8px", text: t("users.accountLoadHint") }),
el("div", { class: "row row--wecom-form" }, [
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: "channel" }), accountChannelInput]),
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomBotId") }), accountIdInput]),
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("users.wecomInstanceName") }), accountNameInput]),
el("div", { class: "row--wecom-form__field" }, [el("div", { class: "muted", text: t("wa.specialist") }), accountSpecialistInput]),
botSecretField,
el("label", { class: "kv row--wecom-form__chk" }, [accountActiveInput, document.createTextNode(" " + t("users.accountActive"))]),
clearBotField,
btnSaveAccount,
]),
accountStatus,
el("div", { class: "table-wrap" }, [el("table", { class: "table table--compact" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("users.wecomBotId") }),
el("th", { text: t("users.wecomInstanceName") }),
el("th", { text: t("table.externalUserId") }),
el("th", { text: t("users.hasBotSecret") }),
el("th", { text: t("users.accountActive") }),
el("th", { text: t("common.mode") }),
el("th", { text: t("table.specialist") }),
el("th", { text: t("table.timestamp") }),
el("th", { text: t("users.loadForm") }),
el("th", { text: t("users.accountActions") }),
])]),
accountBody,
])]),
]),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("tenants.bindings") }),
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: t("tenants.title") }),
el("th", { text: t("tenants.users") }),
el("th", { text: t("table.channel") }),
el("th", { text: "account_id" }),
el("th", { text: "account_name" }),
el("th", { text: t("table.externalUserId") }),
])]),
bindingsBody,
]),
]),
el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("tenants.bindCodes") }),
el("div", { class: "row" }, [
el("label", { text: t("tenants.role") }),
roleInput,
codeInput,
btnCreateCode,
btnDeleteUnbound,
]),
el("table", { class: "table" }, [
el("thead", {}, [el("tr", {}, [
el("th", { text: "code" }),
el("th", { text: t("tenants.role") }),
el("th", { text: t("tenants.codeStatus") }),
el("th", { text: t("tenants.codeUsedBy") }),
el("th", { text: t("table.createdAt") }),
el("th", { text: t("tenants.deleteCode") }),
])]),
codesBody,
]),
]),
]);
}
export { renderUserManagement };

View file

@ -0,0 +1,210 @@
import { state, t, el, apiGet, apiPost, formatUserLabel } from "../core.js";
import { hasPermission } from "./authz.js";
async function renderWorkspacePaths() {
const sessionTid = String((state.authSession && state.authSession.tenant_id) || "").trim();
const sessionUid = String((state.authSession && state.authSession.user_id) || "").trim();
const canUserRead = hasPermission("admin:user:read");
const canWsRead = hasPermission("admin:workspace_paths:read");
const canWsWrite = hasPermission("admin:workspace_paths:write");
const selfService = !canUserRead && canWsRead;
if (!sessionTid || (!canUserRead && !canWsRead)) {
return el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.workspacePaths") }),
el("div", { class: "muted", text: t("common.forbidden") }),
]);
}
if (selfService && !sessionUid) {
return el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.workspacePaths") }),
el("div", { class: "muted", text: t("tenants.noSessionTenant") }),
]);
}
let allTenants = [];
try {
const allTenantsResp = await apiGet("/admin/api/tenants");
allTenants = allTenantsResp.tenants || [];
} catch (_) {
allTenants = [{ id: sessionTid, name: "", created_at: "" }];
}
if (!allTenants.length) {
return el("div", { class: "card" }, [
el("div", { class: "card__title", text: t("title.workspacePaths") }),
el("div", { class: "muted", text: t("tenants.noTenants") }),
]);
}
const tenantSel = el("select", { class: "input" });
for (const item of allTenants) {
const optLabel = String(item.name || "").trim() || String(item.id || "").slice(0, 8);
tenantSel.appendChild(el("option", { value: String(item.id || ""), text: optLabel }));
}
const preferredIdx = allTenants.findIndex((x) => String(x.id || "") === sessionTid);
tenantSel.selectedIndex = preferredIdx >= 0 ? preferredIdx : 0;
if (selfService) {
tenantSel.disabled = true;
tenantSel.title = t("workspacePaths.selfOnly");
}
const userSel = el("select", { class: "input" });
const selfUserLabel = el("div", {
class: "muted",
text: formatUserLabel({
username: state.authSession && state.authSession.username,
display_name: state.authSession && state.authSession.display_name,
}) + (sessionUid ? ` · ${sessionUid.slice(0, 8)}…` : ""),
});
if (selfService) {
userSel.style.display = "none";
}
const extraInput = el("textarea", {
class: "input",
rows: "4",
placeholder: "D:\\\\|E:\\\\|D:\\\\repos\\\\other",
style: "min-height:88px;font-family:monospace;",
});
const allowAnyCb = el("input", { type: "checkbox" });
const allowHighToolsCb = el("input", { type: "checkbox" });
const status = el("div", { class: "muted", text: "" });
const canWrite = hasPermission("admin:user:write") || canWsWrite;
const canTogglePublicHigh = hasPermission("admin:user:write");
if (!canTogglePublicHigh) {
allowHighToolsCb.disabled = true;
}
const getEffectiveTid = () => (selfService ? sessionTid : String(tenantSel.value || ""));
const getEffectiveUid = () => (selfService ? sessionUid : String(userSel.value || ""));
const loadUsers = async () => {
if (selfService) return;
const tid = String(tenantSel.value || "");
userSel.innerHTML = "";
if (!tid) return;
const resp = await apiGet(
"/admin/api/users?tenant_id=" +
encodeURIComponent(tid) +
"&include_inactive=1&q=&limit=500",
);
const users = Array.isArray(resp.users) ? resp.users : [];
for (const u of users) {
const id = String(u.id || "");
if (!id) continue;
const label = formatUserLabel(u);
userSel.appendChild(el("option", { value: id, text: label }));
}
if (userSel.options.length) userSel.selectedIndex = 0;
};
const loadPolicy = async () => {
const tid = getEffectiveTid();
const uid = getEffectiveUid();
if (!tid || !uid) {
status.textContent = "";
return;
}
status.textContent = "…";
try {
const r = await apiGet(
"/admin/api/users/workspace-path-policy?tenant_id=" +
encodeURIComponent(tid) +
"&user_id=" +
encodeURIComponent(uid),
);
if (!r.ok) {
status.textContent = String(r.error || "error");
return;
}
const pol = r.policy || {};
extraInput.value = String(pol.extra_roots || "");
allowAnyCb.checked = !!pol.allow_any_path;
allowHighToolsCb.checked = !!r.public_tools_allow_high;
status.textContent = (r.from_db ? t("workspacePaths.fromDb") + " · " : "") + JSON.stringify(pol);
} catch (e) {
status.textContent = String(e && e.message ? e.message : e);
}
};
tenantSel.addEventListener("change", async () => {
await loadUsers();
await loadPolicy();
});
userSel.addEventListener("change", () => {
loadPolicy();
});
await loadUsers();
await loadPolicy();
const loadBtn = el("button", {
class: "btn",
text: t("workspacePaths.load"),
onclick: () => loadPolicy(),
});
const saveBtn = el("button", {
class: "btn btn--primary",
text: t("workspacePaths.save"),
disabled: canWrite ? undefined : "disabled",
onclick: async () => {
if (!canWrite) return;
const tid = getEffectiveTid();
const uid = getEffectiveUid();
if (!tid || !uid) return;
status.textContent = "…";
try {
const payload = {
tenant_id: tid,
user_id: uid,
extra_roots: extraInput.value,
allow_any_path: !!allowAnyCb.checked,
};
if (canTogglePublicHigh) {
payload.public_tools_allow_high = !!allowHighToolsCb.checked;
}
const r = await apiPost("/admin/api/users/workspace-path-policy", payload);
if (!r.ok) {
status.textContent = String(r.error || "error");
return;
}
await loadPolicy();
} catch (e) {
status.textContent = String(e && e.message ? e.message : e);
}
},
});
const userRowChildren = selfService ? [selfUserLabel] : [userSel];
const cardParts = [
el("div", { class: "card__title", text: t("title.workspacePaths") }),
el("div", { class: "muted", text: t("workspacePaths.help") }),
];
if (selfService) {
cardParts.push(el("div", { class: "muted u-mt-6", text: t("workspacePaths.selfOnly") }));
}
cardParts.push(
el("div", { class: "u-h-10" }),
el("div", { class: "row" }, [
el("label", { text: t("workspacePaths.tenant") }),
tenantSel,
]),
el("div", { class: "row" }, [
el("label", { text: t("workspacePaths.user") }),
...userRowChildren,
]),
el("div", { class: "row" }, [el("label", { text: t("workspacePaths.extraRoots") })]),
extraInput,
el("label", { class: "row", style: "align-items:center;gap:8px;margin-top:8px;" }, [
allowAnyCb,
el("span", { text: t("workspacePaths.allowAny") }),
]),
el("div", { class: "muted u-muted-block", text: t("workspacePaths.allowAnyHint") }),
el("label", { class: "row", style: "align-items:center;gap:8px;margin-top:10px;" }, [
allowHighToolsCb,
el("span", { text: t("workspacePaths.allowHighTools") }),
]),
el("div", { class: "muted u-muted-block", text: t("workspacePaths.allowHighToolsHint") }),
el("div", { class: "row", style: "margin-top:10px;gap:8px;" }, [loadBtn, saveBtn]),
el("div", { class: "muted u-mt-8" }, [el("span", { text: t("workspacePaths.status") + ": " }), status]),
);
return el("div", { class: "card" }, cardParts);
}
export { renderWorkspacePaths };