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