function renderConversation() { const renderVersion = ++state.conversationRenderVersion; captureConversationScrollPosition(); captureTraceScrollPositions(); captureTraceBodyScrollPositions(); const introMessages = [ { role: "system", title: "界面模式", text: "这里是用户工作台:可以整理任务、查看 Device Pod summary 和纯文本事件流。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。", status: "source" }, codeAgentStatusMessage(state.codeAgentAvailability) ]; replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard)); restoreConversationScrollPosition(); restoreTraceScrollPositions(); restoreTraceBodyScrollPositions(); scheduleCodeAgentSessionPersist(); window.requestAnimationFrame(() => { if (renderVersion !== state.conversationRenderVersion) return; restoreConversationScrollPosition({ deferred: true }); restoreTraceScrollPositions(el.conversationList, { deferred: true }); restoreTraceBodyScrollPositions(el.conversationList, { deferred: true }); }); } function initConversationScrollMemory() { for (const eventName of ["wheel", "touchstart", "pointerdown"]) { el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true }); } el.conversationList.addEventListener("keydown", (event) => { if (isScrollIntentKey(event.key)) markConversationScrollIntent(); }); el.conversationList.addEventListener("scroll", () => { captureConversationScrollPosition(); }, { passive: true }); } function markConversationScrollIntent() { state.conversationScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS; } function isScrollIntentKey(key) { return ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(key); } function captureConversationScrollPosition() { state.conversationScrollPosition = { top: el.conversationList.scrollTop, left: el.conversationList.scrollLeft, bottomGap: scrollBottomGap(el.conversationList) }; } function restoreConversationScrollPosition(options = {}) { if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return; const position = state.conversationScrollPosition; if (!position) return; writeConversationScrollTop(scrollTopForPosition(el.conversationList, position)); el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth)); } function captureTraceScrollPositions(root = el.conversationList) { for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { rememberTraceScrollPosition(list.dataset.traceUiKey, list); } } function rememberTraceScrollPosition(traceUiKey, list) { if (!traceUiKey || !list) return; state.traceScrollPositions.set(traceUiKey, { top: list.scrollTop, left: list.scrollLeft, bottomGap: scrollBottomGap(list) }); } function restoreTraceScrollPositions(root = el.conversationList, options = {}) { for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue; const position = state.traceScrollPositions.get(list.dataset.traceUiKey); if (!position) continue; writeTraceScrollTop(list, scrollTopForPosition(list, position)); list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth)); } } function captureTraceBodyScrollPositions(root = el.conversationList) { for (const body of root.querySelectorAll(".message-trace-body")) { rememberTraceBodyScrollPosition(body); } } function traceBodyScrollKey(body) { const list = body?.closest?.(".message-trace-events[data-trace-ui-key]"); const row = body?.closest?.(".message-trace-row[data-trace-row-id]"); const traceUiKey = list?.dataset?.traceUiKey; const rowId = row?.dataset?.traceRowId; return traceUiKey && rowId ? `${traceUiKey}:${rowId}` : null; } function rememberTraceBodyScrollPosition(body) { const key = traceBodyScrollKey(body); if (!key || !body) return; state.traceBodyScrollPositions.set(key, elementScrollPosition(body)); } function restoreTraceBodyScrollPositions(root = el.conversationList, options = {}) { for (const body of root.querySelectorAll(".message-trace-body")) { const key = traceBodyScrollKey(body); if (!key) continue; if (options.deferred === true && isTraceBodyScrollUserActive(key)) continue; const position = state.traceBodyScrollPositions.get(key); if (!position) continue; restoreElementScrollPosition(body, position); } } function markTraceBodyScrollIntent(body) { const key = traceBodyScrollKey(body); if (!key) return; state.traceBodyScrollUserActiveUntil.set(key, Date.now() + SCROLL_USER_ACTIVITY_MS); } function isTraceBodyScrollUserActive(key) { if (!key) return false; return Date.now() < Number(state.traceBodyScrollUserActiveUntil.get(key) ?? 0); } function elementScrollPosition(element) { return { top: element.scrollTop, left: element.scrollLeft, bottomGap: scrollBottomGap(element) }; } function restoreElementScrollPosition(element, position) { if (!element || !position) return; element.scrollTop = scrollTopForPosition(element, position); element.scrollLeft = Math.min(position.left, Math.max(0, element.scrollWidth - element.clientWidth)); } function scrollBottomGap(element) { return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop); } function scrollTopForPosition(element, position) { const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop; return Math.min(position.top, maxTop); } function markTraceScrollIntent(traceUiKey) { if (!traceUiKey) return; state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS); } function isTraceScrollUserActive(traceUiKey) { if (!traceUiKey) return false; return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0); } function writeConversationScrollTop(top) { if (el.conversationList.scrollTop === top) return; state.traceProgrammaticScrollWrites += 1; el.conversationList.scrollTop = top; } function writeTraceScrollTop(list, top) { if (!list || list.scrollTop === top) return; state.traceProgrammaticScrollWrites += 1; list.scrollTop = top; } function patchMessageTracePanel(message) { const traceUiKey = messageTraceUiKey(message); if (!traceUiKey) return false; const panel = el.conversationList.querySelector(`.message-trace[data-trace-ui-key="${cssEscape(traceUiKey)}"]`); if (!panel) return false; const replacement = messageTracePanel(message); if (!replacement) { panel.remove(); return true; } patchTracePanelElement(panel, replacement); return true; } function patchTracePanelElement(panel, replacement) { panel.open = replacement.open; const currentSummary = panel.querySelector("summary"); const nextSummary = replacement.querySelector("summary"); if (currentSummary && nextSummary) { currentSummary.className = nextSummary.className; currentSummary.textContent = nextSummary.textContent; } const currentToolbar = panel.querySelector(".message-trace-toolbar"); const nextToolbar = replacement.querySelector(".message-trace-toolbar"); if (currentToolbar && nextToolbar) { currentToolbar.replaceChildren(...nextToolbar.childNodes); } else if (!currentToolbar && nextToolbar) { const list = panel.querySelector(".message-trace-events"); panel.insertBefore(nextToolbar, list); } else if (currentToolbar && !nextToolbar) { currentToolbar.remove(); } const currentList = panel.querySelector(".message-trace-events[data-trace-ui-key]"); const nextList = replacement.querySelector(".message-trace-events[data-trace-ui-key]"); if (currentList && nextList) { captureTraceBodyScrollPositions(currentList); currentList.dataset.traceMode = nextList.dataset.traceMode ?? ""; patchTraceEventList(currentList, [...nextList.children]); restoreTraceBodyScrollPositions(currentList); } } function cssEscape(value) { if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value); return String(value).replace(/["\\]/gu, "\\$&"); } function renderGateTable() { const rows = filteredGateRows(); const loading = state.gateDiagnostics.loading; const payload = state.gateDiagnostics.payload; const statusTone = gatePayloadTone(); const loadedAt = state.gateDiagnostics.loadedAt; el.gateSourceStatus.textContent = loading ? "读取 live 后端中" : payload ? `live 后端 ${rows.length}/${state.gateDiagnostics.rows.length} 行` : state.gateDiagnostics.error ? "live 后端阻塞" : "等待 live 后端"; el.gateSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`; el.gateSourceStatus.title = payload ? `route=${payload.route}; observedAt=${payload.observedAt ?? loadedAt ?? "unknown"}` : state.gateDiagnostics.error ?? "等待 /v1/diagnostics/gate"; if (loading && state.gateDiagnostics.rows.length === 0) { const tr = document.createElement("tr"); tr.append(tableCell("正在读取 live 后端聚合。", "wrap", 8)); replaceChildren(el.gateReviewBody, tr); return; } if (rows.length === 0) { const tr = document.createElement("tr"); tr.append(tableCell("没有匹配当前过滤条件的复核行。", "wrap", 8)); replaceChildren(el.gateReviewBody, tr); return; } replaceChildren(el.gateReviewBody, ...rows.map(gateTableRow)); } function filteredGateRows() { const filter = el.gateStatusFilter.value; const query = el.gateSearch.value.trim().toLowerCase(); return state.gateDiagnostics.rows.filter((row) => { if (filter !== "all" && row.status !== filter) return false; if (!query) return true; return [ row.category, row.check, row.status, row.owner, row.detail, ...(row.evidence ?? []), row.updatedAt, row.next ].join(" ").toLowerCase().includes(query); }); } function gateTableRow(row) { const tr = document.createElement("tr"); tr.dataset.status = row.status; tr.dataset.statusKey = row.statusKey; tr.append(tableCell(row.category, "wrap")); tr.append(tableCell(row.check, "wrap")); const status = document.createElement("td"); status.append(badge(row.status, row.statusKey)); tr.append(status); tr.append(tableCell(row.owner, "mono wrap")); tr.append(tableCell(row.detail, "wrap")); tr.append(tableCell((row.evidence ?? []).join(" / "), "mono wrap")); tr.append(tableCell(formatTimestamp(row.updatedAt), "mono wrap")); tr.append(tableCell(row.next, "wrap")); return tr; } function normalizeGateRow(row) { const statusKey = normalizeGateStatusKey(row.statusKey ?? row.status); return { category: oneLine(row.category || "内部复核"), check: oneLine(row.check || "未命名检查项"), status: gateStatusLabel(row.status, statusKey), statusKey, owner: oneLine(row.owner || "未标明"), detail: oneLine(row.detail || "live 后端未返回关键细节"), evidence: normalizeEvidenceList(row.evidence), updatedAt: row.updatedAt || new Date().toISOString(), next: oneLine(row.next || "等待维护者补充下一步。"), sourceKind: "LIVE-BACKEND" }; } function gateLiveBlockerRow(response) { const now = new Date().toISOString(); return normalizeGateRow({ category: "健康检查", check: "内部复核 live 后端聚合", status: "阻塞", statusKey: response.timeout ? "failed" : "blocked", owner: "hwlab-cloud-api /v1/diagnostics/gate", detail: response.error || "live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。", evidence: [ "GET /v1/diagnostics/gate", response.status ? `HTTP ${response.status}` : null, response.timeout ? `timeoutMs=${response.timeoutMs}` : null ].filter(Boolean), updatedAt: now, next: "先恢复后端聚合接口,再刷新本表。" }); } function gatePayloadTone() { if (state.gateDiagnostics.loading) return "pending"; if (!state.gateDiagnostics.payload) return state.gateDiagnostics.error ? "blocked" : "source"; const rows = state.gateDiagnostics.rows; if (rows.some((row) => row.statusKey === "failed")) return "failed"; if (rows.some((row) => row.statusKey === "blocked")) return "blocked"; if (rows.some((row) => row.statusKey === "pending")) return "pending"; return "pass"; } function renderAgentChatStatus(status, result = null) { const labels = { idle: "等待输入", running: "处理中", completed: "DEV-LIVE 回复", source: "SOURCE 回复", failed: "后端失败", timeout: "等待超时", canceled: "已取消", error: "请求错误", blocked: "服务受阻" }; el.agentChatStatus.textContent = agentStatusLabel(status, result, labels); el.agentChatStatus.className = `state-tag tone-${toneClass(agentStatusTone(status, result))}`; el.commandInput.disabled = status === "running"; el.commandSend.disabled = status === "running"; el.commandSend.textContent = status === "running" ? "发送中" : "发送"; if (result?.conversationId) { el.agentChatStatus.title = `${result.provider ?? "provider"} / ${result.model ?? "model"} / ${result.backend ?? "backend"}`; } else { el.agentChatStatus.removeAttribute("title"); } } function renderCodeAgentSummary() { const summary = currentCodeAgentStatusSummary(); el.codeAgentSummary.className = `code-agent-summary tone-border-${toneClass(summary.tone)}`; el.codeAgentSummary.dataset.codeAgentStatusKind = summary.kind; el.codeAgentSummaryIcon.textContent = summary.icon; el.codeAgentSummaryIcon.className = `code-agent-summary-icon tone-${toneClass(summary.tone)}`; el.codeAgentSummaryLabel.textContent = summary.label; el.codeAgentSummaryLabel.className = `code-agent-summary-label tone-${toneClass(summary.tone)}`; el.codeAgentSummaryCapability.textContent = summary.capabilityLevel; el.codeAgentSummaryTrace.textContent = `trace ${summary.lastTraceId}`; replaceChildren(el.codeAgentSummaryDetail, ...codeAgentSummaryRows(summary)); } function currentCodeAgentStatusSummary() { return classifyCodeAgentStatusSummary({ availability: state.codeAgentAvailability, latestMessage: latestChatResult(), live: state.liveSurface }); } function codeAgentSummaryRows(summary) { return [ codeAgentSummaryRow("codeAgent.status", summary.codeAgentStatus, summary.tone), codeAgentSummaryRow("provider/mode/backend", summary.providerModeBackend, "source"), codeAgentSummaryRow("capabilityLevel", summary.capabilityLevel, summary.tone), codeAgentSummaryRow("session", summary.sessionSummary, sessionSummaryTone(summary)), codeAgentSummaryRow("sessionId/status", summary.sessionIdStatus, sessionSummaryTone(summary)), codeAgentSummaryRow("会话提示", summary.sessionLifecycleHint, sessionSummaryTone(summary)), codeAgentSummaryRow("workspace", summary.workspace, "source"), codeAgentSummaryRow("sandbox", summary.sandbox, "source"), codeAgentSummaryRow("runnerKind", summary.runnerKind, summary.kind === "long-lived-session" ? "ok" : "source"), codeAgentSummaryRow("protocol", summary.protocol, summary.runtimePathTone), codeAgentSummaryRow("implementationType", summary.implementationType, summary.runtimePathTone), codeAgentSummaryRow("providerTrace.command", summary.providerTraceCommand, summary.runtimePathTone), codeAgentSummaryRow("providerTrace.terminalStatus", summary.providerTraceTerminalStatus, summary.runtimePathTone), codeAgentSummaryRow("运行路径语义", summary.runtimePathLabel, summary.runtimePathTone), codeAgentSummaryRow("toolCalls", summary.toolCalls, summary.toolCalls === "none" ? "source" : summary.tone), codeAgentSummaryRow("skills", summary.skills, summary.skills === "none" ? "source" : summary.tone), codeAgentSummaryRow("conversation facts", conversationFactsSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"), codeAgentSummaryRow("fact traces", conversationFactTracesSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"), codeAgentSummaryRow("runnerTrace", summary.runnerTrace, "source"), codeAgentSummaryRow("readiness blockers", summary.blockersLabel, summary.readinessBlockers.length > 0 ? "blocked" : "source"), codeAgentSummaryRow("last traceId", summary.lastTraceId, "source"), codeAgentSummaryRow("当前部署 revision", summary.deploymentRevision, "source") ]; } function codeAgentSummaryRow(label, value, tone) { const row = document.createElement("div"); row.className = "code-agent-summary-row"; row.append(textSpan(label, "code-agent-summary-key"), textSpan(value, `code-agent-summary-value tone-${toneClass(tone)}`)); return row; } function sessionSummaryTone(summary) { if (summary.kind === "long-lived-session") return "ok"; if (summary.kind === "blocked" || summary.kind === "skill-cli-api-blocked") return "blocked"; if (["read-only-session-tools", "stateless-one-shot", "fallback-text-chat-only", "degraded"].includes(summary.kind)) return "warn"; return "source"; } function agentStatusLabel(status, result, labels) { if (status !== "failed") return labels[status] ?? statusLabel(status); const presentation = agentFailurePresentation(result?.error, { result }); return ( { timeout: "等待超时", provider: "Provider 不可用", runner_busy: "Runner 忙碌", session_blocked: "Session 受阻", canceled: "已取消", runner_blocked: "Runner 受阻", api_error: "API 错误", needs_config: "需要配置", capability_unavailable: "能力未开放", security_blocked: "安全阻断", fallback: "仍是 fallback", retryable: "可重试" }[presentation.category] ?? labels.failed ); } function agentStatusTone(status, result) { if (status === "completed") return "dev-live"; if (["failed", "blocked", "timeout", "canceled", "error"].includes(status)) return "blocked"; if (status === "running") return "pending"; return "source"; } function sourceTitle(result) { return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; } function sourceFixtureTitle(result) { return `Code Agent SOURCE 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; } function textFallbackTitle(result) { return `Code Agent 文本 fallback 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`; } function classifyCodeAgentCompletion(result, { blockedError = null } = {}) { if (result?.status === "canceled") { return { status: "canceled", replied: false, sourceKind: "BLOCKED", title: "Code Agent 已取消" }; } if (result?.status === "timeout") { return { status: "timeout", replied: false, sourceKind: "BLOCKED", title: "Code Agent 无新事件超时" }; } if (result?.status === "error") { return { status: "failed", replied: false, sourceKind: "BLOCKED", title: "Code Agent 返回错误" }; } if (isStructuredBlockedChatResult(result)) { return { status: "failed", replied: false, sourceKind: "BLOCKED", title: agentFailurePresentation(structuredBlockedErrorFromResult(result), { result }).title }; } if (blockedError) { return { status: "failed", replied: false, sourceKind: "BLOCKED", title: agentFailurePresentation(blockedError, { result }).title }; } if (isRealCompletedChatResult(result)) { return { status: "completed", replied: true, sourceKind: "DEV-LIVE", title: sourceTitle(result) }; } if (isSourceFixtureChatResult(result)) { return { status: "source", replied: true, sourceKind: "SOURCE", title: sourceFixtureTitle(result) }; } if (isTextFallbackChatResult(result)) { return { status: "source", replied: true, sourceKind: "TEXT-FALLBACK", title: textFallbackTitle(result) }; } return { status: "failed", replied: false, sourceKind: "BLOCKED", title: result?.status === "completed" && !result?.blocker ? "Code Agent 完成证据不足" : agentFailurePresentation(result?.error, { result }).title }; } function isStructuredBlockedChatResult(result) { const error = result?.error && typeof result.error === "object" ? result.error : {}; return Boolean( result && typeof result === "object" && ( result.blocker?.code || error.blocker?.code || result.capabilityLevel === "hwlab-api-control-blocked" ) ); } function structuredBlockedErrorFromResult(result) { if (!isStructuredBlockedChatResult(result)) return null; const error = result?.error && typeof result.error === "object" ? result.error : {}; const blocker = error.blocker ?? result?.blocker ?? {}; const code = normalizeErrorCode(error.code ?? blocker.code ?? "code_agent_blocked"); const selectedBlocker = blocker; return { ...error, code, layer: error.layer ?? selectedBlocker.layer, category: error.category ?? selectedBlocker.category, blocker: selectedBlocker, retryable: error.retryable ?? selectedBlocker.retryable, userMessage: error.userMessage ?? selectedBlocker.userMessage, message: error.message ?? selectedBlocker.summary ?? selectedBlocker.zh ?? selectedBlocker.message ?? code, traceId: error.traceId ?? result?.traceId, route: error.route ?? selectedBlocker.route, toolName: error.toolName ?? selectedBlocker.toolName }; } function failureMessage(result) { const presentation = agentFailurePresentation(structuredBlockedErrorFromResult(result) ?? result?.error, { result }); if (presentation.category !== "unknown") { return presentation.text; } if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") { const availability = result.error?.providerStatus || result.availability?.status !== "blocked" ? codeAgentAvailabilityFromResult(result) : result.availability ?? codeAgentAvailabilityFromResult(result); return codeAgentBlockedSummary(availability); } const missing = [ ...(result.error?.missingCommands ?? []), ...(result.error?.missingEnv ?? []) ].filter(Boolean); const suffix = missing.length ? ` 缺口:${missing.join("、")}。` : ""; return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}。${suffix}请稍后重试或联系维护者补齐后端 provider。`; } function agentErrorFromHttpResponse(response, traceId) { const payloadError = response.data?.error; const code = response.timeout ? "client_timeout" : normalizeErrorCode( (payloadError && typeof payloadError === "object" ? payloadError.code : null) ?? (typeof payloadError === "string" ? payloadError : null) ?? response.data?.code ?? "request_failed" ); const error = new Error(response.timeout ? `Code Agent 超过 ${response.timeoutMs}ms 无新事件;已保留输入,可稍后重试。` : payloadError?.userMessage || response.error || "Code Agent 请求失败"); error.traceId = response.data?.traceId || traceId; error.code = code; error.layer = payloadError && typeof payloadError === "object" ? payloadError.layer : undefined; error.blocker = payloadError && typeof payloadError === "object" ? payloadError.blocker : undefined; error.retryable = payloadError && typeof payloadError === "object" ? payloadError.retryable : response.timeout ? true : undefined; error.userMessage = payloadError && typeof payloadError === "object" ? payloadError.userMessage : undefined; error.missingConfig = payloadError && typeof payloadError === "object" ? payloadError.missingConfig : undefined; error.route = payloadError && typeof payloadError === "object" ? payloadError.route : undefined; error.toolName = payloadError && typeof payloadError === "object" ? payloadError.toolName : undefined; error.timeoutMs = response.timeoutMs ?? (payloadError && typeof payloadError === "object" ? payloadError.timeoutMs : undefined); error.hardTimeoutMs = payloadError && typeof payloadError === "object" ? payloadError.hardTimeoutMs : undefined; error.idleMs = response.idleMs ?? (payloadError && typeof payloadError === "object" ? payloadError.idleMs : undefined); error.lastActivityAt = response.lastActivityAt ?? (payloadError && typeof payloadError === "object" ? payloadError.lastActivityAt : undefined); error.waitingFor = response.waitingFor ?? (payloadError && typeof payloadError === "object" ? payloadError.waitingFor : undefined); error.providerStatus = payloadError && typeof payloadError === "object" ? payloadError.providerStatus : response.status; error.category = payloadError && typeof payloadError === "object" ? payloadError.category : undefined; return error; } function agentFailurePresentation(error, { result = null, traceId = null } = {}) { const code = normalizeErrorCode(error?.code); const message = String(error?.message ?? result?.error?.message ?? "").trim(); const structuredBlocker = error?.blocker ?? result?.error?.blocker ?? result?.blocker ?? null; const userMessage = String(error?.userMessage ?? result?.error?.userMessage ?? structuredBlocker?.userMessage ?? "").trim(); const observedTraceId = result?.traceId || error?.traceId || traceId || null; const traceSuffix = observedTraceId ? ` trace=${observedTraceId}` : ""; const timeoutMs = error?.timeoutMs ?? result?.error?.timeoutMs ?? timeoutMsFromText(message); const hardTimeoutMs = error?.hardTimeoutMs ?? result?.error?.hardTimeoutMs; const idleMs = error?.idleMs ?? result?.error?.idleMs; const lastActivityAt = error?.lastActivityAt ?? result?.error?.lastActivityAt; const waitingFor = error?.waitingFor ?? result?.error?.waitingFor; const providerStatus = error?.providerStatus ?? result?.error?.providerStatus; if (code === "codex_stdio_hard_timeout") { return { category: "timeout", title: "Code Agent 达到硬性上限", text: `Code Agent 达到硬性总时长上限${hardTimeoutMs ? ` ${hardTimeoutMs}ms` : ""};输入已保留,可稍后重试或缩小问题范围。${traceSuffix}` }; } if (isTimeoutFailure(code, message)) { const detail = [ typeof idleMs === "number" ? `idleMs=${idleMs}` : null, lastActivityAt ? `lastActivityAt=${lastActivityAt}` : null, waitingFor ? `waitingFor=${waitingFor}` : null ].filter(Boolean).join(";"); return { category: "timeout", title: "Code Agent 无新事件超时", text: `Code Agent ${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}无新事件;输入已保留,可稍后重试或缩小问题范围。${detail ? ` ${detail}。` : ""}${traceSuffix}` }; } if (code === "codex_stdio_canceled" || code === "session_canceled") { return { category: "canceled", title: "Code Agent 已取消", text: `本次 Codex stdio 请求已取消;输入、sessionId 和 traceId 已保留,可重试上一条消息。${traceSuffix}` }; } if (userMessage) { const category = error?.category ?? result?.error?.category ?? structuredBlocker?.category ?? "runner_blocked"; return { category, title: titleForBlockerCategory(category, code), text: `${userMessage}${traceSuffix}` }; } if (code === "session_busy") { return { category: "runner_busy", title: "会话忙/请等待", text: `Codex stdio runner 正在处理上一轮请求;输入已保留,请等待当前请求完成或取消后重试。${traceSuffix}` }; } if (code === "session_expired") { return { category: "session_blocked", title: "会话已过期", text: `当前 session 已过期;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` }; } if (["session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled", "session_timeout", "session_error"].includes(code)) { return { category: "session_blocked", title: "Code Agent Session 受阻", text: `当前 session 已过期、失败、中断或与 conversation 绑定不一致;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` }; } if ([ "runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed" ].includes(code)) { return { category: "runner_blocked", title: "Code Agent Runner 受阻", text: `Codex stdio runner 返回 ${code};本次不会 fallback 冒充 Codex session。${message ? `原因:${message}。` : ""}${traceSuffix}` }; } if (["invalid_params", "parse_error", "request_failed", "cloud_api_proxy_failed", "upstream_unavailable"].includes(code)) { return { category: "api_error", title: "Code Agent API 错误", text: `Code Agent API 请求失败;输入已保留,可稍后重试。${message ? `原因:${message}。` : ""}${traceSuffix}` }; } if (code === "provider_unavailable" || providerStatus) { return { category: "provider", title: "Code Agent Provider 不可用", text: providerStatus ? `Code Agent provider 返回 HTTP ${providerStatus};本次未生成回复,输入已保留,可稍后重试。${traceSuffix}` : `Code Agent provider 暂不可用;本次未生成回复,输入已保留,可稍后重试。${traceSuffix}` }; } return { category: "unknown", title: "Code Agent 返回失败", text: `Code Agent 调用失败:${message || "未知错误"}。请稍后重试或联系维护者补齐后端 provider。${traceSuffix}` }; } function titleForBlockerCategory(category, code) { const value = String(category ?? "").trim(); if (value === "needs_config") return "Code Agent 需要配置"; if (value === "security_blocked" || code === "security_blocked") return "Code Agent 安全阻断"; if (value === "fallback" || code === "text_chat_only_fallback") return "Code Agent 仍是 fallback"; if (value === "timeout") return "Code Agent 无新事件超时"; if (value === "canceled" || code === "codex_stdio_canceled") return "Code Agent 已取消"; if (value === "runner_busy") return "Code Agent Runner 忙碌"; if (value === "session_blocked") return "Code Agent Session 受阻"; if (value === "runner_blocked") return "Code Agent Runner 受阻"; if (value === "api_error") return "Code Agent API 错误"; if (value === "capability_unavailable") return "Code Agent 能力未开放"; if (value === "retryable") return "Code Agent 可重试"; return "Code Agent 服务受阻"; } function normalizeErrorCode(code) { const normalized = String(code ?? "").trim(); return normalized || "code_agent_failed"; } function isTimeoutFailure(code, message) { return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout", "codex_stdio_timeout", "codex_stdio_idle_timeout", "codex_stdio_hard_timeout"].includes(code) || /\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu.test(message); } function isCodeAgentTimeoutError(error, fallbackMessage = "") { const code = normalizeErrorCode(error?.code); const message = [error?.message, error?.userMessage, fallbackMessage] .filter(Boolean) .join(" "); return isTimeoutFailure(code, message); } function timeoutMsFromText(message) { const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu); if (!match) return null; const value = Number.parseInt(match[1], 10); return Number.isInteger(value) && value > 0 ? value : null; } function messageCard(message) { const article = document.createElement("article"); article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`; article.append(textSpan(roleLabel(message.role), "message-role")); article.append(textSpan(message.title, "message-title")); article.append(messageCopyNode(message)); const pendingContext = messagePendingContextPanel(message); if (pendingContext) article.append(pendingContext); const sessionContext = messageSessionContinuityPanel(message); if (sessionContext) article.append(sessionContext); const runtimePath = messageRuntimePathPanel(message); if (runtimePath) article.append(runtimePath); const tracePanel = messageTracePanel(message); if (tracePanel) article.append(tracePanel); const actions = messageActionsPanel(message); if (actions) article.append(actions); return article; } function messageCopyNode(message) { if (!shouldRenderMessageMarkdown(message)) { return textSpan(message.text, "message-copy"); } const node = document.createElement("div"); node.className = "message-copy message-copy-markdown"; const markdown = String(message.text ?? ""); try { node.innerHTML = renderMessageMarkdown(markdown); } catch (error) { node.textContent = markdown || `Markdown 渲染失败:${error.message}`; } hardenRenderedMarkdown(node); return node; } function shouldRenderMessageMarkdown(message) { return message?.role === "agent" && MESSAGE_MARKDOWN_STATUSES.includes(String(message.status ?? "").toLowerCase()); } function messageActionsPanel(message) { if (message.role !== "agent") return null; const actions = []; if (message.status === "running") { actions.push(actionButton("取消当前请求", "cancel", () => cancelAgentMessage(message.id), "取消当前 in-flight Codex stdio 请求")); if (message.retryInput) { actions.push(actionButton("重试上一条", "retry", () => restartRunningAgentMessage(message.id), "先取消当前 trace,再用同一输入重新发送")); } } if (canRetryTerminalAgentMessage(message)) { actions.push(actionButton("重试上一条", "retry", () => retryAgentMessage(message.id), "保留 conversation/trace 记录并重新发送上一条输入")); } if (message.traceId) { actions.push(actionButton("回放 trace", "trace", () => replayAgentTrace(message.id), "从 runnerTrace store 重新读取真实事件")); } if (actions.length === 0 && !message.traceReplayStatus) return null; const panel = document.createElement("div"); panel.className = "message-actions"; panel.append(...actions); if (message.traceReplayStatus) { panel.append(textSpan(message.traceReplayStatus, "message-action-status")); } return panel; } function actionButton(label, action, onClick, title) { const button = document.createElement("button"); button.type = "button"; button.className = `message-action message-action-${action}`; button.textContent = label; button.title = title; button.addEventListener("click", onClick); return button; } function canRetryTerminalAgentMessage(message) { return Boolean( message?.retryInput && ["failed", "timeout", "error", "canceled"].includes(String(message.status ?? "").toLowerCase()) ); } async function restartRunningAgentMessage(messageId) { const message = state.chatMessages.find((item) => item.id === messageId); if (!message?.retryInput || message.status !== "running") return; await cancelAgentMessage(messageId); const canceled = state.chatMessages.find((item) => item.id === messageId); if (canceled?.status !== "canceled" || state.chatPending) { setAgentActionStatus(messageId, "取消未确认,当前 trace 仍在等待后端;已保留输入,可等待真实超时或后端结果后再重试。"); return; } await submitAgentMessage(message.retryInput, { conversationId: canceled.conversationId || state.conversationId || undefined, sessionId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status) ? undefined : canceled.sessionId, threadId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status) ? undefined : canceled.threadId ?? threadIdFrom(canceled), retryOf: canceled.traceId }); } async function retryAgentMessage(messageId) { const message = state.chatMessages.find((item) => item.id === messageId); if (!message?.retryInput) return; if (state.chatPending) { setAgentActionStatus(messageId, "当前仍有请求处理中;请先取消当前请求,或等待真实超时/后端结果后再重试。"); return; } await submitAgentMessage(message.retryInput, { conversationId: message.conversationId || state.conversationId || undefined, sessionId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status) ? undefined : message.sessionId, threadId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status) ? undefined : message.threadId ?? threadIdFrom(message), retryOf: message.traceId }); } function setAgentActionStatus(messageId, text) { const index = state.chatMessages.findIndex((message) => message.id === messageId); if (index < 0) return; state.chatMessages[index] = { ...state.chatMessages[index], traceReplayStatus: text, updatedAt: new Date().toISOString() }; renderConversation(); } async function cancelAgentMessage(messageId) { const index = state.chatMessages.findIndex((message) => message.id === messageId); if (index < 0) return; const message = state.chatMessages[index]; const traceId = message.traceId; const sessionId = message.sessionId || message.runnerTrace?.sessionId || state.currentRequest?.sessionId || state.sessionId; const threadId = message.threadId || message.runnerTrace?.threadId || state.currentRequest?.threadId || state.threadId; const response = await cancelAgentRequest({ traceId, conversationId: message.conversationId || state.conversationId, sessionId, threadId }); const payload = response.data ?? {}; const runnerTrace = payload.runnerTrace ?? latestTraceSnapshot(traceId); if (response.ok && payload.canceled === true) { state.canceledTraces.add(traceId); stopRunningTraceStream(traceId); state.chatPending = false; state.currentRequest = null; state.sessionId = payload.sessionId || sessionId || state.sessionId; state.sessionStatus = payload.session?.status ?? runnerTrace?.sessionStatus ?? "canceled"; state.chatMessages[index] = { ...message, title: "Code Agent 已取消", text: payload.userMessage || "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。", status: "canceled", sessionId: payload.sessionId || sessionId || message.sessionId, threadId: payload.threadId || threadId || message.threadId, sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "codex_stdio_canceled" }), session: payload.session ?? message.session, runnerTrace, traceReplayStatus: lastTraceEventLabel(runnerTrace), updatedAt: new Date().toISOString(), error: payload.error ?? { code: "codex_stdio_canceled", category: "canceled", retryable: true, message: "user canceled current Code Agent request" } }; if (message.retryInput) el.commandInput.value = message.retryInput; } else { state.chatMessages[index] = { ...message, title: "Code Agent 取消受阻", text: payload.error?.userMessage || response.error || "当前请求没有可取消的 in-flight Codex stdio session;输入和 trace 已保留。", status: "failed", threadId, sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "cancel_blocked" }), runnerTrace, traceReplayStatus: lastTraceEventLabel(runnerTrace), updatedAt: new Date().toISOString(), error: payload.error ?? { code: "cancel_blocked", category: "cancel_blocked", retryable: true, message: response.error || "cancel blocked" } }; if (message.retryInput) el.commandInput.value = message.retryInput; } renderAgentChatStatus(state.chatMessages[index].status, state.chatMessages[index]); renderCodeAgentSummary(); renderConversation(); renderDrafts(); renderDevicePodPanel(state.liveSurface); } function stopRunningTraceStream(traceId) { const id = nonEmptyString(traceId); if (!id) return; const close = state.traceStreams.get(id); if (typeof close === "function") close(); state.traceStreams.delete(id); } async function cancelAgentRequest({ traceId, conversationId, sessionId, threadId }) { return fetchJson("/v1/agent/chat/cancel", { method: "POST", headers: { "Content-Type": "application/json", "X-Trace-Id": traceId, "Prefer": "respond-async" }, timeoutMs: CODE_AGENT_CANCEL_TIMEOUT_MS, timeoutName: "Code Agent cancel", body: JSON.stringify({ traceId, conversationId, sessionId, threadId }) }); } async function replayAgentTrace(messageId) { const index = state.chatMessages.findIndex((message) => message.id === messageId); if (index < 0) return; const message = state.chatMessages[index]; if (!message.traceId) return; const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), timeoutName: "Code Agent trace replay" }); if (response.ok) { const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, message.runnerTrace); state.chatMessages[index] = { ...message, runnerTrace, threadId: runnerTrace.threadId ?? message.threadId, traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, updatedAt: response.data?.updatedAt ?? new Date().toISOString() }; if (shouldReconcileCodeAgentResult(state.chatMessages[index], runnerTrace)) { reconcileCodeAgentResult(message.id, { quiet: true }); } } else { state.chatMessages[index] = { ...message, traceReplayStatus: response.error || "trace 回放失败", updatedAt: new Date().toISOString() }; } renderCodeAgentSummary(); renderConversation(); renderDevicePodPanel(state.liveSurface); } function maybeReplayFullTraceForMessage(message) { if (!message || !message.traceId || message.runnerTrace?.eventsCompacted !== true) return; if (state.fullTraceReplayInFlight.has(message.traceId)) return; replayFullTrace(message.id, { quiet: true }); } async function replayFullTrace(messageId, options = {}) { const index = state.chatMessages.findIndex((message) => message.id === messageId); if (index < 0) return false; const message = state.chatMessages[index]; if (!message.traceId) return false; if (state.fullTraceReplayInFlight.has(message.traceId)) return false; state.fullTraceReplayInFlight.add(message.traceId); try { const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), timeoutName: "Code Agent full trace replay" }); if (!response.ok) return false; const currentIndex = state.chatMessages.findIndex((item) => item.id === messageId); if (currentIndex < 0) return false; const current = state.chatMessages[currentIndex]; const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, current.runnerTrace); state.chatMessages[currentIndex] = { ...current, runnerTrace, threadId: runnerTrace.threadId ?? current.threadId, traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, updatedAt: response.data?.updatedAt ?? new Date().toISOString() }; if (shouldReconcileCodeAgentResult(state.chatMessages[currentIndex], runnerTrace)) { reconcileCodeAgentResult(messageId, { quiet: true }); } if (options.quiet !== true) { renderCodeAgentSummary(); renderConversation(); renderDevicePodPanel(state.liveSurface); } else { renderConversation(); } return true; } finally { state.fullTraceReplayInFlight.delete(message.traceId); } } function lastTraceEventLabel(runnerTrace) { const event = runnerTrace?.lastEvent ?? (Array.isArray(runnerTrace?.events) ? runnerTrace.events.at(-1) : null); if (!event) return "lastEvent=none"; return `lastEvent=${event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`}`; } function messageSessionContinuityPanel(message) { if (message.role !== "agent" || message.status === "running") return null; const continuity = message.sessionContinuity ?? completedSessionContinuity(message, pendingSessionContinuity({ conversationId: message.conversationId, sessionId: message.sessionId, threadId: message.threadId, retryOf: message.retryOf })); if (!continuity) return null; const fields = [ ["状态", continuity.label], ["conversation", continuity.returned?.conversationId ?? message.conversationId], ["session", continuity.returned?.sessionId ?? message.sessionId ?? "未确认"], ["thread", continuity.returned?.threadId ?? message.threadId ?? "未确认"], ["lifecycle", message.sessionLifecycleStatus ?? message.sessionSummary?.status ?? message.session?.lifecycleStatus], ["提示", message.sessionSummary?.userMessage ?? message.sessionLifecycle?.userMessage], ["requestedSession", continuity.requested?.sessionId], ["requestedThread", continuity.requested?.threadId], ["retryOf", continuity.requested?.retryOf ?? message.retryOf], ["missing", continuity.missing?.join("、")], ["changed", continuity.changed?.join("、")] ]; const list = document.createElement("div"); list.className = "message-session-grid"; for (const [label, value] of fields) { if (value === undefined || value === null || value === "") continue; const item = document.createElement("div"); item.className = "message-session-row"; item.append(textSpan(label, "message-session-key"), textSpan(value, "message-session-value mono")); if (label === "conversation" || label === "session" || label === "thread") { item.append(copyButton(value, label)); } list.append(item); } return messageCompactDetails({ className: "message-session-context", tone: continuity.tone, badgeLabel: continuity.label, badgeTone: continuity.tone, summary: continuity.summary, dialogTitle: "会话明细", body: list }); } function messagePendingContextPanel(message) { if (message.status !== "running") return null; const continuity = message.sessionContinuity ?? pendingSessionContinuity({ conversationId: message.conversationId, sessionId: message.sessionId, threadId: message.threadId, retryOf: message.retryOf }); const fields = [ ["状态", continuity.label], ["traceId", message.traceId], ["conversation", message.conversationId], ["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"], ["thread", message.threadId ?? message.runnerTrace?.threadId ?? "等待后端分配"], ["sessionStatus", message.runnerTrace?.sessionStatus], ["lifecycle", message.runnerTrace?.sessionLifecycleStatus], ["activityTimeout", `${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 无新事件`] ]; const section = document.createElement("section"); section.className = "message-pending-context tone-border-pending"; const header = document.createElement("div"); header.className = "message-pending-head"; header.append(badge("处理中", "pending"), textSpan(continuity.summary, "message-pending-summary")); const list = document.createElement("div"); list.className = "message-pending-grid"; for (const [label, value] of fields) { if (value === undefined || value === null || value === "") continue; const item = document.createElement("div"); item.className = "message-pending-row"; item.append(textSpan(label, "message-pending-key"), textSpan(value, "message-pending-value mono")); if (label === "traceId" || label === "conversation" || label === "session") { item.append(copyButton(value, label)); } list.append(item); } section.append(header, list); return section; } function messageRuntimePathPanel(message) { if (message.role !== "agent") return null; const runtimePath = codeAgentRuntimePathFromMessage(message); if (!runtimePath) return null; const rows = document.createElement("div"); rows.className = "message-runtime-grid"; for (const row of runtimePath.rows) { const item = document.createElement("div"); item.className = `message-runtime-row${row.missing ? " message-runtime-row-missing" : ""}`; item.append(textSpan(row.label, "message-runtime-key"), textSpan(row.value, "message-runtime-value mono")); if (!row.missing && (row.label === "providerTrace.command" || row.label === "providerTrace.terminalStatus" || row.label === "protocol")) { item.append(copyButton(row.value, row.label)); } rows.append(item); } if (runtimePath.missingFields.length > 0) { const item = document.createElement("div"); item.className = "message-runtime-row message-runtime-row-missing message-runtime-row-wide"; item.append( textSpan("缺失字段", "message-runtime-key"), textSpan(runtimePath.missingFields.join(", "), "message-runtime-value mono") ); rows.append(item); } const details = messageCompactDetails({ className: "message-runtime-path", tone: runtimePath.tone, badgeLabel: runtimePath.label, badgeTone: runtimePath.tone, summary: runtimePath.summary, dialogTitle: "运行路径明细", body: rows }); details.dataset.runtimePathKind = runtimePath.kind; return details; } function messageCompactDetails({ className, tone, badgeLabel, badgeTone, summary, dialogTitle, body }) { const section = document.createElement("section"); section.className = `${className} message-compact-details tone-border-${toneClass(tone)}`; const trigger = document.createElement("button"); trigger.type = "button"; trigger.className = "message-compact-summary"; trigger.setAttribute("aria-haspopup", "dialog"); trigger.append( badge(badgeLabel, badgeTone), textSpan(summary, "message-compact-summary-text") ); trigger.addEventListener("click", () => { openWorkbenchDialog({ title: dialogTitle, body, restoreFocus: trigger }); }); section.append(trigger); return section; } function boundedJson(value, maxLength = 6000) { let text = ""; try { text = JSON.stringify(value, null, 2); } catch { text = String(value ?? ""); } return text.length > maxLength ? `${text.slice(0, maxLength)}\n...字段已截断` : text; } function messageTracePanel(message) { const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; if (!trace && !message.traceId) return null; const details = document.createElement("details"); details.className = "message-trace"; const traceUiKey = messageTraceUiKey(message); if (traceUiKey) details.dataset.traceUiKey = traceUiKey; const storedOpen = traceUiKey ? state.traceDetailsOpen.get(traceUiKey) : undefined; details.open = typeof storedOpen === "boolean" ? storedOpen : defaultTraceDetailsOpen(message); if (traceUiKey) { details.addEventListener("toggle", () => { state.traceDetailsOpen.set(traceUiKey, details.open); }); } const summary = document.createElement("summary"); summary.className = "message-meta"; summary.textContent = runnerTraceHeadline(message, trace); const list = document.createElement("ol"); list.className = "message-trace-events"; if (traceUiKey) { list.dataset.traceUiKey = traceUiKey; for (const eventName of ["wheel", "touchstart", "pointerdown"]) { list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true }); } list.addEventListener("keydown", (event) => { if (isScrollIntentKey(event.key)) markTraceScrollIntent(traceUiKey); }); list.addEventListener("scroll", () => rememberTraceScrollPosition(traceUiKey, list), { passive: true }); } const events = Array.isArray(trace?.events) ? trace.events : []; const rows = traceDisplayRows(trace, events); if (events.length === 0) { const item = document.createElement("li"); item.textContent = `trace=${message.traceId ?? "pending"};等待后端事件。`; list.append(item); } else { const toolbar = messageTraceToolbar(message, trace, events, rows); list.dataset.traceMode = "all"; renderTraceEventList(list, rows); details.append(summary, toolbar, list); return details; } details.append(summary, list); return details; } function messageTraceUiKey(message) { const traceId = nonEmptyString(message.traceId); const messageId = nonEmptyString(message.id ?? message.messageId); if (!traceId && !messageId) return null; return `${messageId ?? "message"}:${traceId ?? "trace"}`; } function defaultTraceDetailsOpen(message) { return ["running", "completed", "source", "failed", "timeout", "canceled", "error"].includes(message.status); } function messageTraceToolbar(message, trace, events, rows) { const toolbar = document.createElement("div"); toolbar.className = "message-trace-toolbar"; const rawTotal = Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length; const count = textSpan(messageTraceCountText(trace, rawTotal, events.length, rows.length), "message-trace-count"); toolbar.append(count); toolbar.append(traceActionButton("复制 JSON", () => copyTextToClipboard(messageTraceJson(message, trace, events)), "复制完整 trace JSON")); toolbar.append(traceActionButton("下载 trace", () => downloadTraceJson(message, trace, events), "下载完整 trace JSON 文件")); return toolbar; } function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) { if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) { return `完整 trace 回放中 / 当前可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; } return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; } function renderTraceEventList(list, rows) { patchTraceEventList(list, rows.map((row, index) => traceEventRowNode(row, index))); } function patchTraceEventList(list, nextItems) { const currentById = new Map(); for (const current of [...list.children]) { const rowId = current.dataset.traceRowId; if (rowId && !currentById.has(rowId)) currentById.set(rowId, current); } const retained = new Set(); for (let index = 0; index < nextItems.length; index += 1) { const next = nextItems[index]; const rowId = next.dataset.traceRowId; const current = rowId ? currentById.get(rowId) : null; const item = current && !retained.has(current) ? current : next; if (item === current) patchTraceRowElement(current, next); const before = list.children[index] ?? null; if (before !== item) list.insertBefore(item, before); retained.add(item); } for (const current of [...list.children]) { if (!retained.has(current)) current.remove(); } } function patchTraceRowElement(current, next) { current.className = next.className; current.dataset.traceRowKey = next.dataset.traceRowKey ?? ""; current.dataset.traceRowId = next.dataset.traceRowId ?? ""; const currentHeader = current.querySelector(".message-trace-line"); const nextHeader = next.querySelector(".message-trace-line"); if (currentHeader && nextHeader) { currentHeader.className = nextHeader.className; if (currentHeader.textContent !== nextHeader.textContent) currentHeader.textContent = nextHeader.textContent; } else if (!currentHeader && nextHeader) { current.prepend(nextHeader); } else if (currentHeader && !nextHeader) { currentHeader.remove(); } const currentBody = current.querySelector(".message-trace-body"); const nextBody = next.querySelector(".message-trace-body"); if (currentBody && nextBody) { patchTraceBodyElement(currentBody, nextBody); } else if (!currentBody && nextBody) { current.append(nextBody); } else if (currentBody && !nextBody) { currentBody.remove(); } } function patchTraceBodyElement(currentBody, nextBody) { const position = elementScrollPosition(currentBody); currentBody.className = nextBody.className; currentBody.dataset.traceBodyId = nextBody.dataset.traceBodyId ?? ""; currentBody.dataset.traceBodyFormat = nextBody.dataset.traceBodyFormat ?? "text"; if (currentBody.dataset.traceBodyText !== nextBody.dataset.traceBodyText) { currentBody.dataset.traceBodyText = nextBody.dataset.traceBodyText ?? ""; renderTraceBodyContent(currentBody, currentBody.dataset.traceBodyText, currentBody.dataset.traceBodyFormat); } restoreElementScrollPosition(currentBody, position); rememberTraceBodyScrollPosition(currentBody); } function traceEventRowNode(row, index = 0) { const item = document.createElement("li"); item.className = `message-trace-row tone-border-${toneClass(row.tone)}`; const rowId = traceRowId(row, index); item.dataset.traceRowId = rowId; item.dataset.traceRowKey = traceRowKey(row); const header = document.createElement("div"); header.className = "message-trace-line"; header.textContent = row.header; item.append(header); if (row.body) { const body = document.createElement(row.bodyFormat === "markdown" ? "div" : "pre"); body.className = row.bodyFormat === "markdown" ? "message-trace-body message-trace-markdown message-copy-markdown" : "message-trace-body"; body.dataset.traceBodyId = rowId; body.dataset.traceBodyFormat = row.bodyFormat === "markdown" ? "markdown" : "text"; body.dataset.traceBodyText = row.body; installTraceBodyScrollListeners(body); renderTraceBodyContent(body, row.body, body.dataset.traceBodyFormat); item.append(body); } return item; } function renderTraceBodyContent(body, text, format = "text") { if (format === "markdown") { try { body.innerHTML = renderMessageMarkdown(text); hardenRenderedMarkdown(body); } catch (error) { body.textContent = text || `Markdown 渲染失败:${error.message}`; } return; } body.textContent = text; } function installTraceBodyScrollListeners(body) { for (const eventName of ["wheel", "touchstart", "pointerdown"]) { body.addEventListener(eventName, () => markTraceBodyScrollIntent(body), { passive: true }); } body.addEventListener("keydown", (event) => { if (isScrollIntentKey(event.key)) markTraceBodyScrollIntent(body); }); body.addEventListener("scroll", () => rememberTraceBodyScrollPosition(body), { passive: true }); } function traceRowId(row, index = 0) { if (row?.rowId) return String(row.rowId); if (row?.seq !== null && row?.seq !== undefined) return `event:${row.seq}`; return `row:${index}:${traceRowKey(row)}`; } function traceRowKey(row) { return `${row.header}\n${row.body ?? ""}\n${row.bodyFormat ?? "text"}\n${row.tone ?? ""}`; }