Files
pikasTech-HWLAB/web/hwlab-cloud-web/app-conversation.ts
T
2026-06-02 12:25:16 +08:00

1522 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
});
state.traceScrollPinnedToBottom.set(traceUiKey, isScrolledToBottom(list));
}
function restoreTraceScrollPositions(root = el.conversationList, options = {}) {
for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) {
const traceUiKey = list.dataset.traceUiKey;
if (options.deferred === true && isTraceScrollUserActive(traceUiKey)) continue;
const position = state.traceScrollPositions.get(traceUiKey);
if (!position) {
if (shouldDefaultTracePinToBottom(list)) scrollTraceToBottom(list);
continue;
}
writeTraceScrollTop(list, scrollTopForPosition(list, position));
list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth));
rememberTraceScrollPosition(traceUiKey, list);
}
}
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 isScrolledToBottom(element) {
return scrollBottomGap(element) <= SCROLL_BOTTOM_PIN_PX;
}
function scrollBottomTop(element) {
return Math.max(0, element.scrollHeight - element.clientHeight);
}
function scrollTopForPosition(element, position) {
const maxTop = scrollBottomTop(element);
if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop;
return Math.min(position.top, maxTop);
}
function shouldDefaultTracePinToBottom(list) {
return list?.dataset?.traceStatus === "running" || isScrolledToBottom(list);
}
function shouldFollowTraceBottom(list) {
const traceUiKey = list?.dataset?.traceUiKey;
if (!traceUiKey || !list) return false;
const pinned = state.traceScrollPinnedToBottom.get(traceUiKey);
return pinned === true || (pinned !== false && shouldDefaultTracePinToBottom(list));
}
function scrollTraceToBottom(list, options = {}) {
const traceUiKey = list?.dataset?.traceUiKey;
if (!traceUiKey || !list) return;
const run = () => {
if (!list.isConnected || !shouldFollowTraceBottom(list)) return;
writeTraceScrollTop(list, scrollBottomTop(list));
rememberTraceScrollPosition(traceUiKey, list);
};
state.traceScrollPinnedToBottom.set(traceUiKey, true);
if (options.deferred === true) {
window.requestAnimationFrame(run);
return;
}
run();
}
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) {
const shouldFollowBottom = shouldFollowTraceBottom(currentList);
captureTraceBodyScrollPositions(currentList);
currentList.dataset.traceMode = nextList.dataset.traceMode ?? "";
currentList.dataset.traceStatus = nextList.dataset.traceStatus ?? "";
patchTraceEventList(currentList, [...nextList.children]);
restoreTraceBodyScrollPositions(currentList);
if (shouldFollowBottom) {
scrollTraceToBottom(currentList, { deferred: true });
} else {
rememberTraceScrollPosition(currentList.dataset.traceUiKey, 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))}`;
const composer = currentComposerState({ status });
el.commandInput.disabled = composer.disabled;
el.commandSend.disabled = composer.disabled;
el.commandSend.textContent = composer.submitMode === "steer" ? "引导" : "发送";
el.commandSend.title = composer.submitMode === "steer"
? `发送到运行中的 Code Agent turn${composer.targetTraceId ?? "unknown"}`
: "发送新的 Code Agent turn";
el.commandInput.dataset.submitMode = composer.submitMode;
el.commandSend.dataset.submitMode = composer.submitMode;
if (result?.conversationId) {
el.agentChatStatus.title = `${result.provider ?? "provider"} / ${result.model ?? "model"} / ${result.backend ?? "backend"}`;
} else {
el.agentChatStatus.removeAttribute("title");
}
}
function currentComposerState(overrides = {}) {
return computeCodeAgentComposerState({
...overrides,
chatPending: state.chatPending,
currentRequest: state.currentRequest,
messages: state.chatMessages,
conversationId: state.conversationId,
sessionId: state.sessionId,
threadId: state.threadId,
activeTraceId: state.activeTraceId,
sessionStatus: state.sessionStatus,
workspaceId: state.workspaceId,
workspaceRevision: state.workspaceRevision
});
}
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("providerTrace.failureKind", summary.providerTraceFailureKind, 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;
if (state.activeTraceId === traceId) state.activeTraceId = 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;
list.dataset.traceStatus = toneClass(message.status);
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 ?? ""}`;
}