diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 94b52514..44b12cb7 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -3813,6 +3813,7 @@ def build_admin_router() -> APIRouter: @router.get("/admin/api/admin-audit") def api_admin_audit( limit: int = Query(default=200), + offset: int = Query(default=0), action: str | None = Query(default=None), actor_user_id: str | None = Query(default=None), status: str | None = Query(default=None), @@ -3821,17 +3822,34 @@ def build_admin_router() -> APIRouter: store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") - rows = store.list_admin_audit_logs(tenant_id=str(ctx.get("tenant_id") or ""), limit=limit) a = str(action or "").strip() actor = str(actor_user_id or "").strip() st = str(status or "").strip() - if a: - rows = [r for r in rows if str(r.get("action") or "") == a] - if actor: - rows = [r for r in rows if str(r.get("actor_user_id") or "") == actor] - if st: - rows = [r for r in rows if str(r.get("status") or "") == st] - return {"ok": True, "items": rows} + lim = max(1, min(int(limit), 500)) + off = max(0, int(offset)) + tenant_id = str(ctx.get("tenant_id") or "") + rows = store.list_admin_audit_logs( + tenant_id=tenant_id, + action=a or None, + actor_user_id=actor or None, + status=st or None, + limit=lim, + offset=off, + ) + total = store.count_admin_audit_logs( + tenant_id=tenant_id, + action=a or None, + actor_user_id=actor or None, + status=st or None, + ) + return { + "ok": True, + "items": rows, + "logs": rows, + "total": int(total), + "limit": int(lim), + "offset": int(off), + } from oclaw.interfaces.admin.chat_api import include_chat_routes from oclaw.interfaces.admin.models_api import include_model_mgmt_routes diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 15a66fb1..175654fd 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -306,6 +306,9 @@ const I18N = { "adminAudit.action": "动作", "adminAudit.actor": "操作者", "adminAudit.status": "状态", + "adminAudit.total": "共 {total} 条", + "adminAudit.jumpPlaceholder": "页码", + "adminAudit.jump": "跳转", "models.sectionActive": "当前模型配置", "models.sectionBindings": "智能体与模型绑定", "models.sectionNew": "新建模型配置", @@ -735,6 +738,9 @@ const I18N = { "adminAudit.action": "Action", "adminAudit.actor": "Actor", "adminAudit.status": "Status", + "adminAudit.total": "Total {total}", + "adminAudit.jumpPlaceholder": "Page", + "adminAudit.jump": "Go", "models.sectionActive": "Active profile", "models.sectionBindings": "Agent ↔ profile bindings", "models.sectionNew": "New profile", @@ -4162,19 +4168,26 @@ async function renderModels() { el("div", { class: "row" }, [btnSave]), el("div", { class: "row" }, [btnDelete]), ], { id: "models-api" }), - renderSectionCard("Experts (runtime/workspaces)", "English name required; Chinese optional. SOUL.md or ROLE_SYSTEM.md is required.", [ - 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: "row" }, [el("label", { text: "SOUL.md" }), expertSoul]), - el("div", { class: "row" }, [el("label", { text: "ROLE_SYSTEM.md" }), expertRoleSystem]), - el("div", { class: "row" }, [btnExpertSave]), + renderSectionCard("Experts", "Runtime registry and workspace prompt files are split into two sections below.", [ + el("div", { class: "card", style: "margin:8px 0;padding:10px;" }, [ + 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", style: "margin:8px 0;padding:10px;" }, [ + 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", id: "models-eval" }, [evalDetails]), @@ -6308,17 +6321,43 @@ async function renderAdminAudit() { 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", "300"); + 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) => { @@ -6333,13 +6372,70 @@ async function renderAdminAudit() { 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(); - const btn = el("button", { class: "btn btn--primary", text: t("audit.query"), onclick: 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") }), diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index 07ed59eb..306c29a9 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -69,6 +69,7 @@ const I18N = { "lang.switch": "English", "chat.imageViewerClose": "关闭", "chat.imageViewerHint": "点击查看大图,空白处或 Esc 关闭", + "chat.imageViewerDownload": "下载图片", "chat.specialistLabel": "专家", "chat.modeLabel": "模式", "chat.modeComprehensive": "综合", @@ -236,6 +237,7 @@ const I18N = { "lang.switch": "中文", "chat.imageViewerClose": "Close", "chat.imageViewerHint": "Click image to enlarge; click outside or Esc to close", + "chat.imageViewerDownload": "Download image", "chat.specialistLabel": "Specialist", "chat.modeLabel": "Mode", "chat.modeComprehensive": "Comprehensive", @@ -1091,6 +1093,75 @@ function openChatImageLightbox(src, alt) { "aria-label": t("chat.imageViewerHint"), }); const inner = el("div", { class: "chat-img-lightbox__inner" }); + let scale = 1.0; + const clamp = (v) => Math.max(0.2, Math.min(5.0, Number(v || 1))); + const applyScale = () => { + scale = clamp(scale); + viewport.style.transform = `scale(${scale})`; + zoomText.textContent = `${Math.round(scale * 100)}%`; + }; + const fileStem = (() => { + const raw = String(alt || "").trim() || "image"; + const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").replace(/\s+/g, "_").slice(0, 64); + return safe || "image"; + })(); + const ext = (() => { + const s = String(src || ""); + if (/^data:image\/png/i.test(s)) return ".png"; + if (/^data:image\/webp/i.test(s)) return ".webp"; + if (/^data:image\/gif/i.test(s)) return ".gif"; + if (/^data:image\/bmp/i.test(s)) return ".bmp"; + if (/^data:image\/jpeg/i.test(s) || /^data:image\/jpg/i.test(s)) return ".jpg"; + return ".png"; + })(); + const toolbar = el("div", { class: "chat-mermaid-lightbox__toolbar" }); + const btnMinus = el("button", { + type: "button", + class: "chat-mermaid-lightbox__btn", + text: "−", + title: currentLang === "zh" ? "缩小" : "Zoom out", + onclick: (e) => { + e.stopPropagation(); + scale = clamp(scale - 0.1); + applyScale(); + }, + }); + const btnReset = el("button", { + type: "button", + class: "chat-mermaid-lightbox__btn", + text: "100%", + title: currentLang === "zh" ? "重置缩放" : "Reset zoom", + onclick: (e) => { + e.stopPropagation(); + scale = 1.0; + applyScale(); + wrap.scrollLeft = 0; + wrap.scrollTop = 0; + }, + }); + const btnPlus = el("button", { + type: "button", + class: "chat-mermaid-lightbox__btn", + text: "+", + title: currentLang === "zh" ? "放大" : "Zoom in", + onclick: (e) => { + e.stopPropagation(); + scale = clamp(scale + 0.1); + applyScale(); + }, + }); + const zoomText = el("span", { class: "chat-mermaid-lightbox__zoom", text: "100%" }); + const saveBtn = el("a", { + class: "chat-mermaid-lightbox__btn chat-img-lightbox__btn--icon", + href: String(src), + download: `${fileStem}${ext}`, + text: "⤓", + title: t("chat.imageViewerDownload"), + "aria-label": t("chat.imageViewerDownload"), + onclick: (e) => e.stopPropagation(), + }); + const wrap = el("div", { class: "chat-img-lightbox__viewportWrap" }); + const viewport = el("div", { class: "chat-img-lightbox__viewport" }); const big = el("img", { class: "chat-img-lightbox__img", src: String(src), @@ -1107,13 +1178,65 @@ function openChatImageLightbox(src, alt) { closeChatImageLightbox(); }, }); + // Keep native image context menu so users can right-click save. + big.addEventListener("contextmenu", (e) => { + e.stopPropagation(); + }); + toolbar.appendChild(btnMinus); + toolbar.appendChild(btnReset); + toolbar.appendChild(btnPlus); + toolbar.appendChild(zoomText); + toolbar.appendChild(saveBtn); + viewport.appendChild(big); + wrap.appendChild(viewport); + let dragging = false; + let dragStartX = 0; + let dragStartY = 0; + let dragScrollLeft = 0; + let dragScrollTop = 0; + const onDragStart = (e) => { + if (e && e.target && e.target.closest && e.target.closest(".chat-mermaid-lightbox__toolbar")) return; + dragging = true; + wrap.classList.add("chat-img-lightbox__viewportWrap--dragging"); + dragStartX = Number(e.clientX || 0); + dragStartY = Number(e.clientY || 0); + dragScrollLeft = wrap.scrollLeft; + dragScrollTop = wrap.scrollTop; + }; + const onDragMove = (e) => { + if (!dragging) return; + const x = Number(e.clientX || 0); + const y = Number(e.clientY || 0); + wrap.scrollLeft = dragScrollLeft - (x - dragStartX); + wrap.scrollTop = dragScrollTop - (y - dragStartY); + }; + const onDragEnd = () => { + dragging = false; + wrap.classList.remove("chat-img-lightbox__viewportWrap--dragging"); + }; + wrap.addEventListener("mousedown", (e) => onDragStart(e)); + window.addEventListener("mousemove", (e) => onDragMove(e)); + window.addEventListener("mouseup", () => onDragEnd()); + wrap.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + const dy = Number(e.deltaY || 0); + const step = dy > 0 ? -0.08 : 0.08; + scale = clamp(scale + step); + applyScale(); + }, + { passive: false }, + ); + inner.appendChild(toolbar); inner.appendChild(closeBtn); - inner.appendChild(big); + inner.appendChild(wrap); backdrop.appendChild(inner); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) closeChatImageLightbox(); }); document.body.appendChild(backdrop); + applyScale(); } function openChatMermaidLightbox(svg) { @@ -4404,6 +4527,16 @@ ${autoLimit ? `