window.PluginUI = (() => {
async function request(path, options = {}) {
const response = await fetch(path, options);
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json()
: await response.text();
if (!response.ok) {
const detail = typeof payload === "object" ? payload.detail || payload.message : payload;
throw new Error(detail || `请求失败:${response.status}`);
}
return payload;
}
async function requestBlob(path, options = {}) {
const response = await fetch(path, options);
if (!response.ok) {
let detail = "";
try {
const payload = await response.json();
detail = payload.detail || payload.message || "";
} catch {
detail = await response.text();
}
throw new Error(detail || `请求失败:${response.status}`);
}
return response.blob();
}
function showMessage(id, text, type = "success") {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.className = `plugin-message show ${type}`;
}
function clearMessage(id) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = "";
el.className = "plugin-message";
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value == null ? "" : String(value);
}
function setJson(id, value) {
setText(id, typeof value === "string" ? value : JSON.stringify(value, null, 2));
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function fileSize(bytes) {
if (!Number.isFinite(bytes)) return "-";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
async function copyText(text) {
await navigator.clipboard.writeText(text);
}
// ============================================================
// Run Log 组件 — 从后端 run/log API 轮询真实事件
// ============================================================
/** 时间格式化辅助 */
function _formatTs(ts) {
try {
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return ts || "";
}
}
/** 事件级别对应的 CSS class */
function _levelClass(level) {
if (level === "error") return "run-log-entry--error";
if (level === "warning") return "run-log-entry--warn";
return "";
}
/** 事件级别对应的中文标签 */
function _levelLabel(level) {
if (level === "error") return "错误";
if (level === "warning") return "警告";
if (level === "debug") return "调试";
return "信息";
}
/**
* 创建 run 日志容器,返回日志 DOM 元素。
* 调用方应将其插入到期望的位置。
*
* @param {Object} [options]
* @param {string} [options.title] - 面板标题,默认"运行日志"
* @returns {HTMLElement} 日志容器 DOM
*/
function createRunLog(options = {}) {
const title = options.title || "运行日志";
const panel = document.createElement("div");
panel.className = "plugin-panel run-log-panel";
const titleEl = document.createElement("h3");
titleEl.className = "plugin-panel-title";
titleEl.textContent = title;
panel.appendChild(titleEl);
// 运行状态指示器
const statusBar = document.createElement("div");
statusBar.className = "run-log-status";
statusBar.innerHTML = `等待运行...`;
panel.appendChild(statusBar);
// 日志列表
const logList = document.createElement("div");
logList.className = "run-log-list";
panel.appendChild(logList);
// 存储内部状态(不在 DOM 中暴露)
panel._runLogState = {
seenSeq: new Set(),
pollingTimer: null,
apiBase: "",
runId: "",
statusBar,
logList,
};
return panel;
}
/**
* 渲染单条事件到日志列表。
*
* @param {HTMLElement} logPanel - createRunLog 返回的面板
* @param {Object} event - 事件对象 {seq, ts, stage, level, message, detail, artifact_id}
*/
function _renderEvent(logPanel, event) {
const state = logPanel._runLogState;
if (!state) return;
// 去重
if (state.seenSeq.has(event.seq)) return;
state.seenSeq.add(event.seq);
const entry = document.createElement("div");
entry.className = `run-log-entry ${_levelClass(event.level)}`;
// 事件头部:时间 + 阶段 + 级别
const header = document.createElement("div");
header.className = "run-log-entry-header";
const ts = document.createElement("span");
ts.className = "run-log-entry-ts";
ts.textContent = _formatTs(event.ts);
const stage = document.createElement("span");
stage.className = "run-log-entry-stage";
stage.textContent = event.stage || "";
const level = document.createElement("span");
level.className = `run-log-entry-level run-log-level--${event.level || "info"}`;
level.textContent = _levelLabel(event.level);
header.appendChild(ts);
header.appendChild(stage);
header.appendChild(level);
entry.appendChild(header);
// 消息
const msg = document.createElement("div");
msg.className = "run-log-entry-message";
msg.textContent = event.message || "";
entry.appendChild(msg);
// 详情(折叠显示)
if (event.detail) {
const detail = document.createElement("details");
detail.className = "run-log-entry-detail";
const summary = document.createElement("summary");
summary.textContent = "详情";
detail.appendChild(summary);
const pre = document.createElement("pre");
pre.textContent = event.detail;
detail.appendChild(pre);
entry.appendChild(detail);
}
// artifact 链接
if (event.artifact_id) {
const artifactLink = document.createElement("div");
artifactLink.className = "run-log-entry-artifact";
artifactLink.innerHTML = `产物: ${event.artifact_id}`;
entry.appendChild(artifactLink);
}
state.logList.appendChild(entry);
// 自动滚动到底部
state.logList.scrollTop = state.logList.scrollHeight;
}
/**
* 开始轮询后端 run events API,自动追加事件到日志面板。
*
* @param {string} pluginName - 插件名(如 "audio"),用于拼真后端路径 /api/plugins/{pluginName}/runs/{runId}
* @param {string} runId - 运行 ID
* @param {HTMLElement} logPanel - createRunLog 返回的面板
* @param {Object} [options]
* @param {number} [options.interval] - 轮询间隔(毫秒),默认 1500
* @param {Function} [options.onRunComplete] - run 完成回调,参数 (run)
* @param {Function} [options.onError] - 轮询失败回调,参数 (error)
*/
function pollRunEvents(pluginName, runId, logPanel, options = {}) {
const state = logPanel._runLogState;
if (!state) return;
// 清理旧轮询
if (state.pollingTimer) {
clearInterval(state.pollingTimer);
state.pollingTimer = null;
}
state.pluginName = pluginName;
state.runId = runId;
const interval = options.interval || 1500;
// 更新状态指示器
state.statusBar.querySelector(".run-log-status-text").textContent = "运行中...";
state.statusBar.querySelector(".run-log-status-dot").className =
"run-log-status-dot run-log-status-dot--running";
// 真后端 run 日志路径由 app/api/plugin_runs.py 提供,前缀 /api/plugins/{pluginName}/runs/{runId}
const runBase = `/api/plugins/${pluginName}/runs/${runId}`;
const poll = async () => {
try {
// 获取 run 状态
const runResp = await request(runBase);
const runStatus = runResp.status;
// 获取事件列表
const eventsResp = await request(`${runBase}/events`);
const events = eventsResp.events || [];
for (const event of events) {
_renderEvent(logPanel, event);
}
// 检查是否完成
if (runStatus === "succeeded" || runStatus === "failed") {
clearInterval(state.pollingTimer);
state.pollingTimer = null;
const dot = state.statusBar.querySelector(".run-log-status-dot");
const text = state.statusBar.querySelector(".run-log-status-text");
if (runStatus === "succeeded") {
dot.className = "run-log-status-dot run-log-status-dot--done";
text.textContent = "运行完成";
} else {
dot.className = "run-log-status-dot run-log-status-dot--error";
text.textContent = runResp.error
? `运行失败: ${runResp.error}`
: "运行失败";
}
if (options.onRunComplete) {
options.onRunComplete(runResp);
}
}
} catch (err) {
// 轮询失败不清空已有日志,仅在状态栏显示错误
state.statusBar.querySelector(".run-log-status-dot").className =
"run-log-status-dot run-log-status-dot--error";
state.statusBar.querySelector(".run-log-status-text").textContent =
`轮询错误: ${err.message}`;
if (options.onError) {
options.onError(err);
}
}
};
// 立即执行一次
poll();
// 定时轮询
state.pollingTimer = setInterval(poll, interval);
}
/**
* 停止当前日志面板的轮询。
*
* @param {HTMLElement} logPanel - createRunLog 返回的面板
*/
function stopPolling(logPanel) {
const state = logPanel._runLogState;
if (state && state.pollingTimer) {
clearInterval(state.pollingTimer);
state.pollingTimer = null;
}
}
/**
* 渲染 artifact 下载链接。
*
* @param {Object} artifact - artifact 元数据 {artifact_id, filename, media_type, size}
* @param {HTMLElement} container - 容器元素
* @param {string} pluginName - 插件名(如 "audio")
* @param {string} runId - 运行 ID
* @returns {HTMLElement} artifact 项 DOM
*/
function renderArtifactLink(artifact, container, pluginName, runId) {
const item = document.createElement("div");
item.className = "plugin-item";
const info = document.createElement("div");
info.innerHTML = `
${escapeHtml(artifact.filename)}