// app.js 管理协议调试器列表筛选、详情编辑器和实时事件交互。 import { getLocale, setLocale, t, translateDocument } from "./i18n.js"; import { bindEvents, renderPauseState } from "./app_events.js"; import { escapeHTML, formatBytes, formatDuration, formatHex, formatState, renderDecodeError, renderTruncated } from "./view_helpers.js"; const monacoReady = loadMonaco(); const editorSlots = { request: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" }, response: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" }, }; const state = { status: null, exchanges: [], conversations: [], selectedId: null, selected: null, search: "", requestId: "", conversationId: "", endpoint: "all", bidiMessageKinds: new Set(), showOptions: false, sortOrder: "desc", paused: false, pendingRefresh: false, connection: { connected: false, key: "status.connecting", values: {} }, tabs: { request: "body", response: "body", }, }; const elements = { statusDot: document.querySelector("#status-dot"), statusText: document.querySelector("#status-text"), serviceAddress: document.querySelector("#service-address"), upstreamURL: document.querySelector("#upstream-url"), connectionLabel: document.querySelector("#connection-label"), trafficSummary: document.querySelector("#traffic-summary"), searchInput: document.querySelector("#search-input"), requestIdInput: document.querySelector("#request-id-input"), conversationSelect: document.querySelector("#conversation-select"), endpointFilter: document.querySelector("#endpoint-filter"), bidiMessageFilter: document.querySelector("#bidi-message-filter"), bidiMessageOptions: document.querySelector("#bidi-message-options"), showOptionsCheckbox: document.querySelector("#show-options-checkbox"), sortOrder: document.querySelector("#sort-order"), requestCount: document.querySelector("#request-count"), requestList: document.querySelector("#request-list"), emptyState: document.querySelector("#empty-state"), selectionSummary: document.querySelector("#selection-summary"), requestContent: document.querySelector("#request-content"), responseContent: document.querySelector("#response-content"), pauseButton: document.querySelector("#pause-button"), clearButton: document.querySelector("#clear-button"), localeSelect: document.querySelector("#locale-select"), workspace: document.querySelector("#workspace"), splitter: document.querySelector("#horizontal-splitter"), }; async function fetchJSON(url, options) { const response = await fetch(url, options); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } if (response.status === 204) return null; return response.json(); } async function loadStatus() { state.status = await fetchJSON("api/status"); elements.statusDot.classList.toggle("online", Boolean(state.status.running)); renderRuntimeStatus(); elements.serviceAddress.textContent = `http://${state.status.serviceAddr}`; elements.upstreamURL.textContent = state.status.upstreamURL; elements.showOptionsCheckbox.checked = state.showOptions; } async function refreshList() { const query = state.conversationId ? `?conversation_id=${encodeURIComponent(state.conversationId)}` : ""; [state.exchanges, state.conversations] = await Promise.all([ fetchJSON(`api/exchanges${query}`), fetchJSON("api/conversations"), ]); renderConversationOptions(); renderBidiMessageFilter(); renderList(); renderTrafficSummary(); if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) { await refreshDetail(state.selectedId); } else if (state.selectedId) { state.selectedId = null; state.selected = null; renderDetail(); } } function renderConversationOptions() { const selected = state.conversationId; const options = [``]; for (const conversation of state.conversations) { if (!conversation.conversationId) continue; const label = `${conversation.conversationId} (${conversation.exchangeCount})`; options.push(``); } elements.conversationSelect.innerHTML = options.join(""); elements.conversationSelect.value = selected; } async function refreshDetail(id) { if (!id) return; try { const detail = await fetchJSON(`api/exchanges/${encodeURIComponent(id)}`); if (state.selectedId !== id) return; state.selected = detail; renderDetail(); } catch (error) { if (state.selectedId === id) { state.selected = null; renderDetailError(error); } } } function scheduleRefresh() { if (state.paused) { state.pendingRefresh = true; return; } if (state.pendingRefresh) return; state.pendingRefresh = true; window.setTimeout(async () => { state.pendingRefresh = false; try { await refreshList(); } catch (error) { setConnectionState(false, "connection.refreshFailed", { message: error.message }); } }, 90); } function connectEvents() { const events = new EventSource("api/events"); events.addEventListener("open", () => setConnectionState(true, "connection.live")); events.addEventListener("update", scheduleRefresh); events.addEventListener("error", () => setConnectionState(false, "connection.retrying")); } function setConnectionState(connected, key, values = {}) { state.connection = { connected, key, values }; renderConnectionState(); } function renderRuntimeStatus() { if (!state.status) { elements.statusText.textContent = t("status.connecting"); return; } elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped"); } function renderConnectionState() { const { connected, key, values } = state.connection; elements.connectionLabel.textContent = t(key, values); elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running)); } function filteredExchanges() { const query = state.search.trim().toLowerCase(); const requestId = state.requestId.trim().toLowerCase(); const direction = state.sortOrder === "asc" ? 1 : -1; return state.exchanges .filter((item) => { if (!state.showOptions && String(item.method || "").toUpperCase() === "OPTIONS") return false; if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false; if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false; if (state.endpoint === "bidiappend" && state.bidiMessageKinds.size > 0 && !state.bidiMessageKinds.has(item.requestKind || "")) return false; if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false; if (!query) return true; return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(query)); }) .sort((left, right) => { const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime(); if (startedAtDelta !== 0) return startedAtDelta * direction; return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction; }); } function renderBidiMessageFilter() { const visible = state.endpoint === "bidiappend"; elements.bidiMessageFilter.hidden = !visible; if (!visible) elements.bidiMessageFilter.open = false; const availableKinds = state.exchanges .filter((item) => item.path.toLowerCase().includes("bidiappend") && item.requestKind) .map((item) => item.requestKind); const kinds = [...new Set([...availableKinds, ...state.bidiMessageKinds])].sort((left, right) => left.localeCompare(right)); elements.bidiMessageFilter.querySelector("summary").textContent = state.bidiMessageKinds.size ? t("filters.selectedMessageTypes", { count: state.bidiMessageKinds.size }) : t("filters.allMessageTypes"); elements.bidiMessageOptions.innerHTML = [ ``, ...kinds.map((kind) => ``), ].join(""); } function renderList() { const exchanges = filteredExchanges(); elements.requestCount.textContent = t("count.requests", { count: exchanges.length }); elements.emptyState.classList.toggle("hidden", exchanges.length > 0); const groups = new Map(); for (const item of exchanges) { const conversationID = item.conversationId || ""; if (!groups.has(conversationID)) groups.set(conversationID, []); groups.get(conversationID).push(item); } elements.requestList.innerHTML = [...groups.entries()] .map(([conversationID, items]) => { const label = conversationID || t("groups.unassigned"); const header = `
${escapeHTML(label)}${items.length}${escapeHTML(item.id)}${escapeHTML(item.url)}${escapeHTML(item.requestId || "-")}${escapeHTML(t("selection.prompt"))}`;
elements.requestContent.classList.remove("editor-active");
elements.responseContent.classList.remove("editor-active");
elements.requestContent.innerHTML = `${escapeHTML(item.url)}`;
renderPayload("request", item.request, state.tabs.request);
renderPayload("response", item.response, state.tabs.response);
}
function renderDetailError(error) {
disposeEditor("request");
disposeEditor("response");
elements.requestContent.classList.remove("editor-active");
elements.responseContent.classList.remove("editor-active");
elements.requestContent.innerHTML = `| ${escapeHTML(header.name)} | ${escapeHTML(header.value)} |
|---|
${escapeHTML(value)}