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)}
${fileSize(artifact.size)} · ${escapeHtml(artifact.media_type)}
`; item.appendChild(info); const btn = document.createElement("button"); btn.className = "plugin-button secondary"; btn.textContent = "下载"; btn.addEventListener("click", async () => { try { const blob = await requestBlob( `/api/plugins/${pluginName}/runs/${runId}/artifacts/${artifact.artifact_id}/download` ); downloadBlob(blob, artifact.filename); } catch (err) { alert(`下载失败: ${err.message}`); } }); item.appendChild(btn); container.appendChild(item); return item; } /** HTML 转义辅助 */ function escapeHtml(str) { if (!str) return ""; const el = document.createElement("span"); el.textContent = str; return el.innerHTML; } return { request, requestBlob, showMessage, clearMessage, setText, setJson, downloadBlob, fileSize, copyText, createRunLog, pollRunEvents, stopPolling, renderArtifactLink, escapeHtml, }; })();