1449 lines
59 KiB
TypeScript
1449 lines
59 KiB
TypeScript
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 ?? ""}`;
|
||
}
|