1208 lines
48 KiB
TypeScript
1208 lines
48 KiB
TypeScript
function installWorkbenchTestHooks() {
|
||
if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return;
|
||
window.__hwlabWorkbenchTestHooks = {
|
||
seedCompletedAgentMessage,
|
||
seedTraceMessage,
|
||
appendTraceEvents,
|
||
reconcileTraceResultFixture,
|
||
latestAgentMessageText,
|
||
traceScrollMetrics,
|
||
traceDomIdentity,
|
||
traceBodyDomIdentity,
|
||
traceRowTexts,
|
||
setTraceBodyScrollTop,
|
||
replaceTraceAssistantStream,
|
||
setTraceScrollTop,
|
||
setConversationScrollTop,
|
||
resetProgrammaticScrollWriteCount
|
||
};
|
||
}
|
||
|
||
function isLocalWorkbenchTestHost() {
|
||
return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname);
|
||
}
|
||
|
||
function seedCompletedAgentMessage(options = {}) {
|
||
state.chatMessages = [{
|
||
id: options.messageId ?? "msg_markdown_contract",
|
||
role: "agent",
|
||
title: options.title ?? "Code Agent 回复",
|
||
text: String(options.text ?? ""),
|
||
status: options.status ?? "completed",
|
||
traceId: options.traceId ?? "trc_markdown_contract",
|
||
conversationId: options.conversationId ?? "cnv_markdown_contract",
|
||
sessionId: options.sessionId ?? "ses_markdown_contract"
|
||
}];
|
||
renderConversation();
|
||
}
|
||
|
||
function seedTraceMessage(options = {}) {
|
||
const traceId = options.traceId ?? "trc_scroll_contract";
|
||
const messageId = options.messageId ?? "msg_scroll_contract";
|
||
const count = Math.max(1, Number(options.count ?? 80));
|
||
const events = Array.isArray(options.events)
|
||
? options.events.map((event, index) => normalizeTestTraceEvent(traceId, event, index + 1))
|
||
: Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1));
|
||
state.chatMessages = [{
|
||
id: messageId,
|
||
role: "agent",
|
||
title: "Code Agent 处理中",
|
||
text: "Trace scroll contract fixture",
|
||
status: "running",
|
||
traceId,
|
||
runnerTrace: {
|
||
traceId,
|
||
events,
|
||
assistantStreams: Array.isArray(options.assistantStreams) ? options.assistantStreams : [],
|
||
eventCount: Number.isInteger(options.eventCount) ? options.eventCount : events.length,
|
||
eventsCompacted: options.eventsCompacted === true,
|
||
eventWindow: options.eventWindow ?? null,
|
||
fullTraceLoaded: options.fullTraceLoaded === true,
|
||
lastEvent: options.lastEvent ?? events.at(-1),
|
||
waitingFor: "turn/completed"
|
||
}
|
||
}];
|
||
renderConversation();
|
||
}
|
||
|
||
function normalizeTestTraceEvent(traceId, event, seq) {
|
||
return {
|
||
traceId,
|
||
seq,
|
||
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
|
||
...event,
|
||
traceId: event?.traceId ?? traceId,
|
||
seq: event?.seq ?? seq,
|
||
createdAt: event?.createdAt ?? new Date(1779690000000 + seq * 1000).toISOString()
|
||
};
|
||
}
|
||
|
||
function appendTraceEvents(count = 1) {
|
||
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
|
||
if (!message) return;
|
||
const trace = message.runnerTrace;
|
||
const start = Array.isArray(trace.events) ? trace.events.length : 0;
|
||
const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1));
|
||
trace.events = [...trace.events, ...additions];
|
||
trace.eventCount = trace.events.length;
|
||
trace.lastEvent = trace.events.at(-1);
|
||
patchMessageTracePanel(message);
|
||
}
|
||
|
||
function replaceTraceAssistantStream(stream = {}) {
|
||
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
|
||
if (!message) return null;
|
||
const trace = message.runnerTrace;
|
||
trace.assistantStreams = [{
|
||
itemId: "assistant-message",
|
||
chunkCount: Math.max(1, Number(stream.chunkCount ?? 1)),
|
||
createdAt: stream.createdAt ?? new Date(1779699000000).toISOString(),
|
||
updatedAt: stream.updatedAt ?? new Date(1779699001000 + Math.max(1, Number(stream.chunkCount ?? 1)) * 1000).toISOString(),
|
||
waitingFor: stream.waitingFor ?? "turn/completed",
|
||
text: String(stream.text ?? "assistant stream fixture")
|
||
}];
|
||
patchMessageTracePanel(message);
|
||
return traceBodyDomIdentity();
|
||
}
|
||
|
||
async function reconcileTraceResultFixture(result) {
|
||
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId || item.traceId);
|
||
if (!message) return null;
|
||
applyCodeAgentResultToMessage(message.id, {
|
||
...(result && typeof result === "object" ? result : {}),
|
||
traceId: message.traceId,
|
||
conversationId: message.conversationId ?? "cnv_fixture",
|
||
sessionId: message.sessionId ?? "ses_fixture",
|
||
status: "completed",
|
||
provider: "codex-stdio",
|
||
model: "gpt-test",
|
||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||
workspace: "/workspace/hwlab",
|
||
sandbox: "workspace-write",
|
||
capabilityLevel: "long-lived-codex-stdio-session",
|
||
runner: {
|
||
kind: "codex-app-server-stdio-runner",
|
||
codexStdio: true,
|
||
writeCapable: true,
|
||
durableSession: true,
|
||
workspace: "/workspace/hwlab",
|
||
sandbox: "workspace-write",
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||
capabilityLevel: "long-lived-codex-stdio-session"
|
||
},
|
||
session: {
|
||
sessionId: message.sessionId ?? "ses_fixture",
|
||
status: "idle",
|
||
idleTimeoutMs: 1800000,
|
||
lastTraceId: message.traceId,
|
||
longLivedSession: true,
|
||
durableSession: true,
|
||
codexStdio: true,
|
||
writeCapable: true
|
||
},
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||
sessionReuse: { reused: true, sessionId: message.sessionId ?? "ses_fixture" },
|
||
longLivedSessionGate: { status: "pass", pass: true },
|
||
toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }],
|
||
skills: [],
|
||
reply: {
|
||
content: "fixture reconciled result",
|
||
...(result?.reply && typeof result.reply === "object" ? result.reply : {})
|
||
},
|
||
providerTrace: {
|
||
protocol: "codex-app-server-jsonrpc-stdio",
|
||
command: "codex app-server --listen stdio://",
|
||
terminalStatus: "completed",
|
||
...(result?.providerTrace && typeof result.providerTrace === "object" ? result.providerTrace : {})
|
||
},
|
||
runnerTrace: message.runnerTrace,
|
||
updatedAt: new Date().toISOString()
|
||
}, {
|
||
baseMessage: message,
|
||
retryInput: message.retryInput,
|
||
retryOf: message.retryOf
|
||
});
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
return latestAgentMessageText();
|
||
}
|
||
|
||
function latestAgentMessageText() {
|
||
const message = [...state.chatMessages].reverse().find((item) => item.role === "agent");
|
||
return message ? {
|
||
title: message.title,
|
||
text: message.text,
|
||
status: message.status,
|
||
traceId: message.traceId,
|
||
providerTrace: message.providerTrace ?? null,
|
||
sourceKind: message.sourceKind ?? null,
|
||
capabilityLevel: message.capabilityLevel ?? null,
|
||
error: message.error ?? null
|
||
} : null;
|
||
}
|
||
|
||
function traceRowTexts() {
|
||
return [...el.conversationList.querySelectorAll(".message-trace-events[data-trace-ui-key] .message-trace-row")]
|
||
.map((row, index) => ({
|
||
index,
|
||
rowId: row.dataset.traceRowId ?? null,
|
||
text: row.textContent ?? ""
|
||
}));
|
||
}
|
||
|
||
function testTraceEvent(traceId, seq) {
|
||
return {
|
||
traceId,
|
||
label: "trace:scroll-contract",
|
||
type: "trace",
|
||
status: "observed",
|
||
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
|
||
message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`,
|
||
waitingFor: "turn/completed"
|
||
};
|
||
}
|
||
|
||
function traceScrollMetrics() {
|
||
const conversation = el.conversationList;
|
||
const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]");
|
||
return {
|
||
conversationTop: conversation.scrollTop,
|
||
conversationBottomGap: scrollBottomGap(conversation),
|
||
traceTop: list?.scrollTop ?? null,
|
||
traceBottomGap: list ? scrollBottomGap(list) : null,
|
||
traceScrollHeight: list?.scrollHeight ?? null,
|
||
traceClientHeight: list?.clientHeight ?? null,
|
||
traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0
|
||
};
|
||
}
|
||
|
||
function traceDomIdentity() {
|
||
const conversation = el.conversationList;
|
||
const panel = conversation.querySelector(".message-trace[data-trace-ui-key]");
|
||
const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]");
|
||
const firstRow = list?.querySelector(".message-trace-row") ?? null;
|
||
if (panel && !panel.__hwlabTraceIdentity) panel.__hwlabTraceIdentity = `panel-${Date.now()}-${Math.random()}`;
|
||
if (list && !list.__hwlabTraceIdentity) list.__hwlabTraceIdentity = `list-${Date.now()}-${Math.random()}`;
|
||
if (firstRow && !firstRow.__hwlabTraceIdentity) firstRow.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`;
|
||
return {
|
||
panel: panel?.__hwlabTraceIdentity ?? null,
|
||
list: list?.__hwlabTraceIdentity ?? null,
|
||
firstRow: firstRow?.__hwlabTraceIdentity ?? null,
|
||
rowCount: list?.querySelectorAll(".message-trace-row").length ?? 0,
|
||
programmaticScrollWrites: state.traceProgrammaticScrollWrites
|
||
};
|
||
}
|
||
|
||
function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child .message-trace-body") {
|
||
const body = el.conversationList.querySelector(selector);
|
||
const row = body?.closest(".message-trace-row") ?? null;
|
||
if (body && !body.__hwlabTraceIdentity) body.__hwlabTraceIdentity = `body-${Date.now()}-${Math.random()}`;
|
||
if (row && !row.__hwlabTraceIdentity) row.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`;
|
||
return {
|
||
selector,
|
||
body: body?.__hwlabTraceIdentity ?? null,
|
||
row: row?.__hwlabTraceIdentity ?? null,
|
||
rowId: row?.dataset.traceRowId ?? null,
|
||
top: body?.scrollTop ?? null,
|
||
left: body?.scrollLeft ?? null,
|
||
scrollHeight: body?.scrollHeight ?? null,
|
||
clientHeight: body?.clientHeight ?? null,
|
||
text: body?.textContent ?? null
|
||
};
|
||
}
|
||
|
||
function resetProgrammaticScrollWriteCount() {
|
||
state.traceProgrammaticScrollWrites = 0;
|
||
return state.traceProgrammaticScrollWrites;
|
||
}
|
||
|
||
function setTraceScrollTop(top, { user = true } = {}) {
|
||
const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]");
|
||
if (!list) return traceScrollMetrics();
|
||
if (user) markTraceScrollIntent(list.dataset.traceUiKey);
|
||
list.scrollTop = top;
|
||
rememberTraceScrollPosition(list.dataset.traceUiKey, list);
|
||
return traceScrollMetrics();
|
||
}
|
||
|
||
function setTraceBodyScrollTop(selector, top, { user = true } = {}) {
|
||
const body = el.conversationList.querySelector(selector);
|
||
if (!body) return traceBodyDomIdentity(selector);
|
||
if (user) markTraceBodyScrollIntent(body);
|
||
body.scrollTop = top;
|
||
rememberTraceBodyScrollPosition(body);
|
||
return traceBodyDomIdentity(selector);
|
||
}
|
||
|
||
function setConversationScrollTop(top, { user = true } = {}) {
|
||
if (user) markConversationScrollIntent();
|
||
el.conversationList.scrollTop = top;
|
||
captureConversationScrollPosition();
|
||
return traceScrollMetrics();
|
||
}
|
||
|
||
export function traceDisplayRows(trace, events) {
|
||
const rows = [];
|
||
const commandGroups = traceCommandExecutionGroups(events);
|
||
const requestEvent = tracePrimaryRequestEvent(events);
|
||
const hasTerminalAssistant = events.some((event) => isTerminalAssistantTraceEvent(event));
|
||
const renderedCommands = new Set();
|
||
let requestRendered = false;
|
||
let setupRendered = false;
|
||
let completionRendered = false;
|
||
let assistantIndex = 0;
|
||
for (const event of events) {
|
||
if (!event) continue;
|
||
if (isToolOutputChunkTraceEvent(event)) continue;
|
||
if (isCommandExecutionTraceEvent(event)) {
|
||
const groupKey = traceCommandGroupKey(event);
|
||
const group = commandGroups.get(groupKey) ?? { key: groupKey, started: event, completed: event, outputs: [] };
|
||
const renderHere = event.label === "item/commandExecution:started" || group.started === event;
|
||
if (renderHere && !renderedCommands.has(groupKey)) {
|
||
const row = traceCommandSummaryRow(trace, group);
|
||
if (row) rows.push(row);
|
||
renderedCommands.add(groupKey);
|
||
}
|
||
continue;
|
||
}
|
||
if (isAssistantMessageTraceEvent(event)) {
|
||
const row = traceAssistantSummaryRow(trace, event, assistantIndex);
|
||
assistantIndex += 1;
|
||
if (row) rows.push(row);
|
||
continue;
|
||
}
|
||
if (isRequestTraceEvent(event)) {
|
||
if (!requestRendered) {
|
||
const row = traceRequestSummaryRow(trace, event, requestEvent ?? event);
|
||
if (row) rows.push(row);
|
||
requestRendered = true;
|
||
}
|
||
continue;
|
||
}
|
||
if (isSetupTraceEvent(event)) {
|
||
if (!setupRendered) {
|
||
const row = traceSetupSummaryRow(trace, events, event);
|
||
if (row) rows.push(row);
|
||
setupRendered = true;
|
||
}
|
||
continue;
|
||
}
|
||
if (isCompletionTraceEvent(event)) {
|
||
if (!completionRendered && !hasTerminalAssistant) {
|
||
const row = traceCompletionSummaryRow(trace, event);
|
||
if (row) rows.push(row);
|
||
completionRendered = true;
|
||
}
|
||
continue;
|
||
}
|
||
if (isNoisyTraceEvent(event)) continue;
|
||
const row = traceDisplayRow(trace, event);
|
||
if (row) rows.push(row);
|
||
}
|
||
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean);
|
||
}
|
||
|
||
function compactTraceTextTail(text, maxLength) {
|
||
const clean = cleanTraceText(text);
|
||
if (clean.length <= maxLength) return clean;
|
||
return `...${clean.slice(-maxLength)}`;
|
||
}
|
||
|
||
function traceCommandExecutionGroups(events) {
|
||
const groups = new Map();
|
||
let activeKey = null;
|
||
const ensureGroup = (key, event) => {
|
||
const groupKey = key || traceCommandGroupKey(event);
|
||
if (!groups.has(groupKey)) groups.set(groupKey, { key: groupKey, started: null, completed: null, outputs: [] });
|
||
return groups.get(groupKey);
|
||
};
|
||
for (const event of events) {
|
||
if (isCommandExecutionTraceEvent(event)) {
|
||
const key = traceCommandGroupKey(event, activeKey);
|
||
const group = ensureGroup(key, event);
|
||
if (event.label === "item/commandExecution:started") {
|
||
group.started = group.started ?? event;
|
||
activeKey = key;
|
||
} else {
|
||
group.completed = event;
|
||
group.started = group.started ?? event;
|
||
if (activeKey === key) activeKey = null;
|
||
}
|
||
continue;
|
||
}
|
||
if (isToolOutputChunkTraceEvent(event)) {
|
||
const key = traceCommandGroupKey(event, activeKey);
|
||
ensureGroup(key, event).outputs.push(event);
|
||
}
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
function traceCommandGroupKey(event, fallback = null) {
|
||
return traceNonEmptyString(event?.itemId ?? event?.callId ?? event?.commandId ?? event?.id) || fallback || `cmd:${event?.seq ?? event?.createdAt ?? Math.random()}`;
|
||
}
|
||
|
||
function traceCommandSummaryRow(trace, group) {
|
||
const start = group.started ?? group.completed ?? group.outputs?.[0];
|
||
const completed = group.completed ?? null;
|
||
if (!start) return null;
|
||
const command = displayTraceCommand(completed?.command ?? start.command ?? "");
|
||
const clock = traceClock(start.createdAt);
|
||
const title = traceCommandTitle(command);
|
||
const ok = !completed ? false : traceStatusToken(completed) === "ok" && (!Number.isInteger(completed.exitCode) || completed.exitCode === 0);
|
||
return {
|
||
rowId: `tool-summary:${group.key}`,
|
||
seq: start.seq ?? null,
|
||
tone: completed ? (ok ? "ok" : "blocked") : "warn",
|
||
header: `${clock} 🔧 工具调用:${title}`,
|
||
body: traceCommandSummaryBody(group, command)
|
||
};
|
||
}
|
||
|
||
function traceCommandSummaryBody(group, command) {
|
||
const completed = group.completed ?? null;
|
||
const result = traceCommandResultSummary(group, command);
|
||
return [
|
||
command ? `命令:${command}` : null,
|
||
completed ? `结果:${result}` : "结果:等待命令完成"
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function traceCommandResultSummary(group, command) {
|
||
const completed = group.completed ?? {};
|
||
const output = traceCommandCombinedOutput(group);
|
||
const fields = traceOutputFields(output);
|
||
const success = fields.hostSuccess ?? fields.success ?? commandSuccessFromEvent(completed);
|
||
const duration = traceCommandDurationSuffix(group);
|
||
if (fields.content && /read-after-launch-flash/u.test(command)) {
|
||
return `\n${traceStartupLogSummary(fields.content)}${duration}`;
|
||
}
|
||
if (fields.hostJobId) {
|
||
return [`hostJobId=${fields.hostJobId}`, `success=${success}`].join(", ") + duration;
|
||
}
|
||
if (fields.logTail && /Flash Load finished/iu.test(fields.logTail)) {
|
||
const ticks = fields.content ? traceTickRange(fields.content) : null;
|
||
const capture = ticks ? ` 捕获的部分串口:tick ${ticks}` : "";
|
||
return `success=${success}, Flash Load finished.${capture}${duration}`;
|
||
}
|
||
if (fields.logTail) {
|
||
return `success=${success}, logTail: "${traceLogTailSummary(fields.logTail)}"${duration}`;
|
||
}
|
||
if (fields.content) {
|
||
const ticks = traceTickRange(fields.content);
|
||
const content = ticks ? `捕获的串口:tick ${ticks}` : compactTraceOneLine(fields.content, 180);
|
||
return `success=${success}, ${content}${duration}`;
|
||
}
|
||
const compact = compactTraceOneLine(output || completed.errorMessage || completed.message || "", 220);
|
||
return `${success !== null ? `success=${success}` : traceStatusToken(completed)}${compact ? `, ${compact}` : ""}${duration}`;
|
||
}
|
||
|
||
function traceCommandCombinedOutput(group) {
|
||
return [
|
||
...(Array.isArray(group.outputs) ? group.outputs.map((event) => rawTraceOutputText(event)) : []),
|
||
group.completed?.stdoutSummary,
|
||
group.completed?.outputSummary,
|
||
group.completed?.stderrSummary
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function commandSuccessFromEvent(event) {
|
||
if (!event) return null;
|
||
if (Number.isInteger(event.exitCode)) return event.exitCode === 0;
|
||
const status = String(event.status ?? "");
|
||
if (["completed", "succeeded", "success"].includes(status)) return true;
|
||
if (["failed", "error", "timeout", "canceled"].includes(status)) return false;
|
||
return null;
|
||
}
|
||
|
||
function traceCommandDurationSuffix(group) {
|
||
const completed = group.completed ?? null;
|
||
if (!completed) return "";
|
||
let seconds = null;
|
||
if (typeof completed.durationMs === "number") {
|
||
seconds = completed.durationMs / 1000;
|
||
} else if (group.started?.createdAt && completed.createdAt) {
|
||
const start = Date.parse(group.started.createdAt);
|
||
const end = Date.parse(completed.createdAt);
|
||
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) seconds = (end - start) / 1000;
|
||
}
|
||
return seconds === null ? "" : ` (耗时 ${seconds.toFixed(1)}s)`;
|
||
}
|
||
|
||
function traceCommandTitle(command) {
|
||
if (/build\s+start/iu.test(command)) return "启动编译";
|
||
if (/build\s+status/iu.test(command)) return "等待编译并获取状态";
|
||
if (/download\s+start/iu.test(command)) return "启动下载(含串口捕获)";
|
||
if (/download\s+status/iu.test(command)) return "等待下载并获取状态";
|
||
if (/read-after-launch-flash/iu.test(command)) return "读取完整启动日志";
|
||
return "命令执行";
|
||
}
|
||
|
||
function displayTraceCommand(command) {
|
||
let text = cleanTraceOutputText(command);
|
||
text = text.replace(/^\/?\s*bin\s*\/\s*bash\s+-lc\s+(["'])([\s\S]*)\1$/u, "$2");
|
||
text = text.replace(/^bash\s+-lc\s+(["'])([\s\S]*)\1$/u, "$2");
|
||
return normalizeTraceCommandSpacing(text);
|
||
}
|
||
|
||
function normalizeTraceCommandSpacing(text) {
|
||
return String(text ?? "")
|
||
.replace(/:\s+\/\s+/gu, ":/")
|
||
.replace(/\s+\/\s+/gu, "/")
|
||
.replace(/\s{2,}/gu, " ")
|
||
.trim();
|
||
}
|
||
|
||
function traceOutputFields(text) {
|
||
return {
|
||
hostAction: traceOutputStringField(text, "hostAction"),
|
||
hostJobId: traceOutputStringField(text, "hostJobId"),
|
||
hostSuccess: traceOutputBooleanField(text, "hostSuccess"),
|
||
success: traceOutputBooleanField(text, "success"),
|
||
logTail: traceOutputStringField(text, "logTail"),
|
||
content: traceOutputStringField(text, "content")
|
||
};
|
||
}
|
||
|
||
function traceOutputStringField(text, name) {
|
||
const pattern = new RegExp(`"${name}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`, "gu");
|
||
let value = "";
|
||
for (const match of String(text ?? "").matchAll(pattern)) {
|
||
try {
|
||
const parsed = JSON.parse(`"${match[1]}"`);
|
||
if (parsed) value = parsed;
|
||
} catch {
|
||
if (match[1]) value = match[1];
|
||
}
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function traceOutputBooleanField(text, name) {
|
||
const pattern = new RegExp(`"${name}"\\s*:\\s*(true|false)`, "giu");
|
||
let value = null;
|
||
for (const match of String(text ?? "").matchAll(pattern)) {
|
||
value = match[1].toLowerCase() === "true";
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function traceLogTailSummary(value) {
|
||
const text = cleanSerialText(value);
|
||
const build = text.match(/([^\n]*\b\d+ Error\(s\), \d+ Warning\(s\)\.[^\n]*)(?:\n([^\n]*Build Time Elapsed:[^\n]*))?/iu);
|
||
if (build) return compactTraceOneLine(`... ${[build[1], build[2]].filter(Boolean).join(" ")}`, 220);
|
||
const flash = text.match(/Flash Load finished[^\n]*/iu);
|
||
if (flash) return flash[0];
|
||
return compactTraceOneLine(text, 220);
|
||
}
|
||
|
||
function traceStartupLogSummary(value) {
|
||
const lines = cleanSerialText(value)
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
const selected = [];
|
||
for (const line of lines) {
|
||
if (/hello device-pod!/iu.test(line)) selected.push("hello device-pod!");
|
||
else if (/STM32F103 USART1 baseline ready/iu.test(line)) selected.push("[01_baseline] STM32F103 USART1 baseline ready");
|
||
else if (/baud=/iu.test(line)) selected.push(line.replace(/\s+\/\s+/gu, "/"));
|
||
else if (/project=/iu.test(line)) selected.push(line.replace(/\s+\/\s+/gu, "/"));
|
||
else if (/dap=.*idcode=/iu.test(line)) selected.push(line);
|
||
}
|
||
const ticks = traceTickRange(value);
|
||
if (ticks) {
|
||
const [first, last] = ticks.split("-");
|
||
selected.push(last ? `tick=${first}, ..., tick=${last}` : `tick=${first}`);
|
||
}
|
||
return selected.slice(0, 8).join("\n");
|
||
}
|
||
|
||
function traceTickRange(value) {
|
||
const ticks = [...String(value ?? "").matchAll(/tick=(\d+)/giu)].map((match) => Number(match[1])).filter(Number.isFinite);
|
||
if (ticks.length === 0) return null;
|
||
const first = ticks[0];
|
||
const last = ticks.at(-1);
|
||
return first === last ? String(first) : `${first}-${last}`;
|
||
}
|
||
|
||
function cleanSerialText(value) {
|
||
return String(value ?? "")
|
||
.replace(/\u0000/gu, "")
|
||
.replace(/\r\n|\r/gu, "\n")
|
||
.replace(/\n{3,}/gu, "\n\n")
|
||
.trim();
|
||
}
|
||
|
||
function compactTraceOneLine(value, limit = 220) {
|
||
const text = cleanSerialText(value).replace(/\s+/gu, " ").trim();
|
||
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`;
|
||
}
|
||
|
||
function tracePrimaryRequestEvent(events) {
|
||
return events.find((event) => isRequestTraceEvent(event) && tracePromptText(event)) ?? events.find(isRequestTraceEvent) ?? null;
|
||
}
|
||
|
||
function traceRequestSummaryRow(trace, event, promptEvent = event) {
|
||
const clock = traceClock(event.createdAt);
|
||
const prompt = tracePromptText(promptEvent);
|
||
return {
|
||
rowId: `trace-request:${event.seq ?? "accepted"}`,
|
||
seq: event.seq ?? null,
|
||
tone: "warn",
|
||
header: `${clock} 请求接受${prompt ? `,提示词:"${compactTraceOneLine(prompt, 120)}"` : ""}`,
|
||
body: null
|
||
};
|
||
}
|
||
|
||
function traceSetupSummaryRow(trace, events, event) {
|
||
const setupEvents = events.filter(isSetupTraceEvent);
|
||
const sessionReused = setupEvents.some((item) => /session:reused/iu.test(String(item?.label ?? "")));
|
||
const promptSent = setupEvents.some((item) => /prompt:sent|sent prompt/iu.test(String(item?.label ?? "")));
|
||
const turnStarted = setupEvents.some((item) => /turn[:/]start|turn:started|turn\/start/iu.test(String(item?.label ?? "")));
|
||
const parts = [
|
||
sessionReused ? "会话复用" : "会话就绪",
|
||
promptSent ? "提示词已发送" : null,
|
||
turnStarted ? "轮次开始" : null
|
||
].filter(Boolean);
|
||
return {
|
||
rowId: "trace-setup:session-turn",
|
||
seq: event.seq ?? null,
|
||
tone: "source",
|
||
header: `${traceClock(event.createdAt)} ${parts.join(",") || "会话准备完成"}`,
|
||
body: null
|
||
};
|
||
}
|
||
|
||
function traceCompletionSummaryRow(trace, event) {
|
||
return {
|
||
rowId: `trace-completion:${event.seq ?? "turn"}`,
|
||
seq: event.seq ?? null,
|
||
tone: traceEventTone(event),
|
||
header: `${traceClock(event.createdAt)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`,
|
||
body: null
|
||
};
|
||
}
|
||
|
||
function traceAssistantSummaryRow(trace, event, index = 0) {
|
||
const terminal = isTerminalAssistantTraceEvent(event);
|
||
const text = traceAssistantTextForEvent(trace, event, index);
|
||
if (!text && !terminal && traceHasLaterTerminalAssistant(trace, event)) return null;
|
||
const elapsed = formatTraceDuration(traceRelativeMs(trace, event));
|
||
return {
|
||
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`,
|
||
seq: event.seq ?? null,
|
||
tone: traceEventTone(event),
|
||
header: terminal
|
||
? `${traceClock(event.createdAt)} 🤖 助手最后一条消息,轮次完成(总耗时 ${elapsed})`
|
||
: `${traceClock(event.createdAt)} 🤖 助手消息${index === 0 ? "(第一条)" : ""}`,
|
||
body: text ? compactTraceAssistantMarkdown(text, terminal ? 5000 : 2200) : null,
|
||
bodyFormat: "markdown"
|
||
};
|
||
}
|
||
|
||
function traceAssistantTextForEvent(trace, event, index = 0) {
|
||
const direct = normalizeTraceAssistantMarkdown(event.message ?? event.outputSummary ?? event.chunk ?? event.text ?? "");
|
||
if (direct) return traceAssistantTextDuplicatesTerminal(trace, event, direct) ? "" : direct;
|
||
const streams = traceAssistantStreams(trace);
|
||
if (streams.length === 0) return "";
|
||
const itemId = traceNonEmptyString(event.itemId ?? event.messageId ?? event.id);
|
||
if (itemId) {
|
||
const match = streams.find((stream) => stream.itemId === itemId || stream.messageId === itemId || stream.id === itemId);
|
||
if (match?.text) {
|
||
const text = normalizeTraceAssistantMarkdown(match.text);
|
||
return traceAssistantTextDuplicatesTerminal(trace, event, text) ? "" : text;
|
||
}
|
||
}
|
||
if (isTerminalAssistantTraceEvent(event)) return traceAssistantTerminalText(trace, event);
|
||
return traceAssistantCumulativeSegment(trace, event, index);
|
||
}
|
||
|
||
function traceAssistantStreams(trace) {
|
||
return Array.isArray(trace?.assistantStreams)
|
||
? trace.assistantStreams.filter((stream) => stream && typeof stream === "object" && normalizeTraceAssistantMarkdown(stream.text ?? ""))
|
||
: [];
|
||
}
|
||
|
||
function traceAssistantEvents(trace) {
|
||
return Array.isArray(trace?.events) ? trace.events.filter(isAssistantMessageTraceEvent) : [];
|
||
}
|
||
|
||
function traceAssistantTerminalText(trace, event = null) {
|
||
const streams = traceAssistantStreams(trace);
|
||
const itemId = traceNonEmptyString(event?.itemId ?? event?.messageId ?? event?.id);
|
||
if (itemId) {
|
||
const matched = streams.find((stream) => stream.itemId === itemId || stream.messageId === itemId || stream.id === itemId);
|
||
if (matched?.text) return normalizeTraceAssistantMarkdown(matched.text);
|
||
}
|
||
return normalizeTraceAssistantMarkdown(streams.at(-1)?.text ?? "");
|
||
}
|
||
|
||
function traceAssistantTextDuplicatesTerminal(trace, event, text) {
|
||
if (isTerminalAssistantTraceEvent(event) || !traceHasLaterTerminalAssistant(trace, event)) return false;
|
||
const terminalText = traceAssistantTerminalText(trace);
|
||
return Boolean(terminalText && traceAssistantComparableText(text) === traceAssistantComparableText(terminalText));
|
||
}
|
||
|
||
function traceHasLaterTerminalAssistant(trace, event) {
|
||
const events = traceAssistantEvents(trace);
|
||
const currentIndex = events.findIndex((item) => item === event || (item?.seq !== undefined && item.seq === event?.seq));
|
||
if (currentIndex < 0) return events.some(isTerminalAssistantTraceEvent);
|
||
return events.slice(currentIndex + 1).some(isTerminalAssistantTraceEvent);
|
||
}
|
||
|
||
function traceAssistantCumulativeSegment(trace, event, index = 0) {
|
||
const assistantEvents = traceAssistantEvents(trace).filter((item) => !isTerminalAssistantTraceEvent(item));
|
||
const eventIndex = assistantEvents.findIndex((item) => item === event || (item?.seq !== undefined && item.seq === event?.seq));
|
||
const segmentIndex = eventIndex >= 0 ? eventIndex : index;
|
||
if (segmentIndex < 0) return "";
|
||
const stream = traceAssistantCumulativeStream(trace);
|
||
if (!stream?.text) return "";
|
||
const cumulative = normalizeTraceAssistantMarkdown(stream.text);
|
||
const terminalText = traceAssistantTerminalText(trace);
|
||
const nonTerminalText = traceAssistantRemoveTerminalSuffix(cumulative, terminalText);
|
||
if (!nonTerminalText || traceAssistantComparableText(nonTerminalText) === traceAssistantComparableText(terminalText)) return "";
|
||
const segments = traceAssistantSplitCumulativeText(nonTerminalText, assistantEvents.length);
|
||
return normalizeTraceAssistantMarkdown(segments[segmentIndex] ?? "");
|
||
}
|
||
|
||
function traceAssistantCumulativeStream(trace) {
|
||
const streams = traceAssistantStreams(trace);
|
||
if (streams.length === 0) return null;
|
||
const terminalIds = new Set(traceAssistantEvents(trace)
|
||
.filter(isTerminalAssistantTraceEvent)
|
||
.flatMap((event) => [event.itemId, event.messageId, event.id].map(traceNonEmptyString).filter(Boolean)));
|
||
const candidates = streams.filter((stream) => {
|
||
const streamId = traceNonEmptyString(stream.itemId ?? stream.messageId ?? stream.id);
|
||
return !streamId || !terminalIds.has(streamId);
|
||
});
|
||
return [...(candidates.length > 0 ? candidates : streams)]
|
||
.sort((left, right) => String(right.text ?? "").length - String(left.text ?? "").length)[0] ?? null;
|
||
}
|
||
|
||
function traceAssistantRemoveTerminalSuffix(text, terminalText) {
|
||
const clean = normalizeTraceAssistantMarkdown(text);
|
||
const terminal = normalizeTraceAssistantMarkdown(terminalText);
|
||
if (!clean || !terminal) return clean;
|
||
if (clean.endsWith(terminal)) return clean.slice(0, -terminal.length).trim();
|
||
const index = traceAssistantCompactLastIndex(clean, terminal);
|
||
if (index > 0) return clean.slice(0, index).trim();
|
||
return clean;
|
||
}
|
||
|
||
function traceAssistantCompactLastIndex(text, needle) {
|
||
const haystack = traceAssistantComparableWithMap(text);
|
||
const target = traceAssistantComparableText(needle);
|
||
if (!target) return -1;
|
||
const compactIndex = haystack.text.lastIndexOf(target);
|
||
return compactIndex >= 0 ? haystack.map[compactIndex] ?? -1 : -1;
|
||
}
|
||
|
||
function traceAssistantComparableWithMap(value) {
|
||
const map = [];
|
||
let text = "";
|
||
const raw = String(value ?? "");
|
||
for (let index = 0; index < raw.length; index += 1) {
|
||
const char = raw[index];
|
||
if (/\s/u.test(char)) continue;
|
||
text += char.toLowerCase();
|
||
map.push(index);
|
||
}
|
||
return { text, map };
|
||
}
|
||
|
||
function traceAssistantComparableText(value) {
|
||
return traceAssistantComparableWithMap(normalizeTraceAssistantMarkdown(value)).text;
|
||
}
|
||
|
||
function traceAssistantSplitCumulativeText(text, count) {
|
||
const targetCount = Math.max(1, Number(count) || 1);
|
||
const clean = normalizeTraceAssistantMarkdown(text);
|
||
if (!clean) return [];
|
||
if (targetCount === 1) return [clean];
|
||
const colonPieces = [];
|
||
let remaining = clean;
|
||
while (remaining && colonPieces.length < targetCount) {
|
||
const match = /^([\s\S]*?[::])(?=\s*[^\s])/u.exec(remaining);
|
||
if (!match?.[1] || match[1].trim().length < 8) break;
|
||
colonPieces.push(match[1].trim());
|
||
remaining = remaining.slice(match[1].length).trim();
|
||
}
|
||
const pieces = colonPieces.length > 0 ? colonPieces : traceAssistantSentencePieces(clean);
|
||
if (colonPieces.length > 0 && remaining) pieces.push(remaining);
|
||
if (pieces.length <= targetCount) return [...pieces, ...Array.from({ length: targetCount - pieces.length }, () => "")];
|
||
return traceAssistantPackPieces(pieces, targetCount);
|
||
}
|
||
|
||
function traceAssistantSentencePieces(text) {
|
||
return String(text ?? "").match(/[^。!?.!?]+[。!?.!?]?/gu)?.map((item) => item.trim()).filter(Boolean) ?? [];
|
||
}
|
||
|
||
function traceAssistantPackPieces(pieces, count) {
|
||
const packed = Array.from({ length: count }, () => "");
|
||
pieces.forEach((piece, index) => {
|
||
const slot = Math.min(count - 1, Math.floor(index * count / pieces.length));
|
||
packed[slot] = [packed[slot], piece].filter(Boolean).join("");
|
||
});
|
||
return packed;
|
||
}
|
||
|
||
function compactTraceAssistantMarkdown(text, maxLength) {
|
||
const clean = normalizeTraceAssistantMarkdown(text);
|
||
if (clean.length <= maxLength) return clean;
|
||
return `${clean.slice(0, Math.max(0, maxLength - 12)).trimEnd()}\n\n...内容已截断`;
|
||
}
|
||
|
||
function normalizeTraceAssistantMarkdown(value) {
|
||
let text = String(value ?? "")
|
||
.replace(/\u0000/gu, "")
|
||
.replace(/\r\n|\r/gu, "\n")
|
||
.replace(/[ \t]{2,}/gu, " ")
|
||
.replace(/\n{3,}/gu, "\n\n")
|
||
.trim();
|
||
if (!text) return "";
|
||
text = restoreTraceAssistantEnglishSpacing(text);
|
||
text = restoreTraceAssistantMarkdownBreaks(text);
|
||
return text;
|
||
}
|
||
|
||
function restoreTraceAssistantEnglishSpacing(value) {
|
||
return String(value ?? "")
|
||
.replace(/\bLetmecheckwhat/gu, "Let me check what ")
|
||
.replace(/cloudAPI/giu, "Cloud API")
|
||
.replace(/CloudAPI/gu, "Cloud API")
|
||
.replace(/gatewaysessions/giu, "gateway sessions")
|
||
.replace(/gatewaysession/giu, "gateway session")
|
||
.replace(/gatewaysidecar/giu, "gateway sidecar")
|
||
.replace(/Windows端/gu, "Windows 端")
|
||
.replace(/gateway服务/giu, "gateway 服务")
|
||
.replace(/gateway恢复/giu, "gateway 恢复")
|
||
.replace(/device-podprofilesareavailable/giu, "device-pod profiles are available")
|
||
.replace(/profilesareavailable/giu, "profiles are available")
|
||
.replace(/\b([A-Za-z]+)areavailable\b/gu, "$1 are available")
|
||
.replace(/(\d+)Error\(s\),\s*(\d+)Warning\(s\)/gu, "$1 Error(s), $2 Warning(s)")
|
||
.replace(/所有(\d+)个/gu, "所有 $1 个")
|
||
.replace(/(\d+)个device-pod/giu, "$1 个 device-pod")
|
||
.replace(/个device-pod/giu, "个 device-pod")
|
||
.replace(/(\*\*\d+)个/gu, "$1 个")
|
||
.replace(/([\u4e00-\u9fff])`/gu, "$1 `")
|
||
.replace(/`([^`]+)`([\u4e00-\u9fff])/gu, "`$1` $2")
|
||
.replace(/([A-Za-z0-9_-])`/gu, "$1 `")
|
||
.replace(/([\u4e00-\u9fff])\*\*/gu, "$1 **")
|
||
.replace(/\*\*([\u4e00-\u9fff])/gu, "** $1")
|
||
.replace(/([a-z])([\u4e00-\u9fff])/gu, "$1 $2")
|
||
.replace(/([\u4e00-\u9fff])([A-Za-z][A-Za-z0-9_-]*)/gu, "$1 $2");
|
||
}
|
||
|
||
function restoreTraceAssistantMarkdownBreaks(value) {
|
||
return String(value ?? "")
|
||
.replace(/([::。.!?])\s*(\|[^\n]+\|)/gu, "$1\n\n$2")
|
||
.replace(/\|\s*\|(?=(?:---|:---|[^|\n]+\s*\|))/gu, "|\n|")
|
||
.replace(/\s+-\s+(?=(?:\*\*|`|[\u4e00-\u9fffA-Za-z0-9]))/gu, "\n- ")
|
||
.replace(/([^\n])\s+(想对哪个pod进行操作?)/gu, "$1\n\n$2")
|
||
.replace(/([^\n])\s+(编译干净通过,无错误无警告。)/gu, "$1\n\n$2");
|
||
}
|
||
|
||
function traceDisplayRow(trace, event, options = {}) {
|
||
if (!options.includeNoise && isNoisyTraceEvent(event)) return null;
|
||
if (isCommandExecutionTraceEvent(event)) return traceCommandExecutionRow(trace, event);
|
||
const clock = traceClock(event.createdAt);
|
||
const total = formatTraceDuration(traceRelativeMs(trace, event));
|
||
const status = traceStatusToken(event);
|
||
const label = readableTraceLabel(event);
|
||
const meta = [
|
||
status,
|
||
event.errorCode ? `error=${event.errorCode}` : null,
|
||
typeof event.timeoutMs === "number" ? `timeout=${formatTraceDuration(event.timeoutMs)}` : null,
|
||
typeof event.idleMs === "number" ? `idle=${formatTraceDuration(event.idleMs)}` : null,
|
||
event.outputSummary ? `out=${compactTraceSize(event.outputSummary)}` : null
|
||
].filter(Boolean).join(" ");
|
||
const body = traceDisplayBody(event);
|
||
return {
|
||
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`,
|
||
seq: event.seq ?? null,
|
||
tone: traceEventTone(event),
|
||
header: `${clock} total=${total}${meta ? ` ${meta}` : ""}${label ? ` ${label}` : ""}`,
|
||
body
|
||
};
|
||
}
|
||
|
||
function traceCommandExecutionRow(trace, event) {
|
||
const clock = traceClock(event.createdAt);
|
||
const total = formatTraceDuration(traceRelativeMs(trace, event));
|
||
const status = traceStatusToken(event);
|
||
const command = cleanTraceOutputText(event.command ?? "");
|
||
const meta = [
|
||
status,
|
||
"tool cmd",
|
||
command ? `:: ${compactTraceTextTail(command, 220)}` : null,
|
||
Number.isInteger(event.exitCode) ? `exit=${event.exitCode}` : null,
|
||
typeof event.durationMs === "number" ? `s=${(event.durationMs / 1000).toFixed(1)}` : null,
|
||
typeof event.outputBytes === "number" ? `out=${formatTraceBytes(event.outputBytes)}` : null,
|
||
event.itemId ? `item=${event.itemId}` : null
|
||
].filter(Boolean).join(" ");
|
||
return {
|
||
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "command"}:${event.createdAt ?? "unknown"}`}`,
|
||
seq: event.seq ?? null,
|
||
tone: traceEventTone(event),
|
||
header: `${clock} total=${total} ${meta}`,
|
||
body: traceCommandExecutionBody(event)
|
||
};
|
||
}
|
||
|
||
function traceCommandExecutionBody(event) {
|
||
const lines = [
|
||
event.command ? `command=${compactTraceTextTail(cleanTraceOutputText(event.command), 900)}` : null,
|
||
event.stdoutSummary ? `stdout:\n${compactTraceTextTail(cleanTraceOutputText(event.stdoutSummary), 1200)}` : null,
|
||
event.stderrSummary ? `stderr:\n${compactTraceTextTail(cleanTraceOutputText(event.stderrSummary), 800)}` : null,
|
||
event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null
|
||
].filter(Boolean);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function isCommandExecutionTraceEvent(event) {
|
||
return event?.type === "tool_call" && event?.toolName === "commandExecution" &&
|
||
(event?.label === "item/commandExecution:started" || event?.label === "item/commandExecution:completed");
|
||
}
|
||
|
||
function isAssistantMessageTraceEvent(event) {
|
||
const label = String(event?.label ?? "");
|
||
return event?.type === "assistant_message" || label.startsWith("assistant:");
|
||
}
|
||
|
||
function isTerminalAssistantTraceEvent(event) {
|
||
const label = String(event?.label ?? "");
|
||
return event?.terminal === true || label === "assistant:completed";
|
||
}
|
||
|
||
function isRequestTraceEvent(event) {
|
||
const label = String(event?.label ?? "");
|
||
return label === "request:accepted-short-connection" ||
|
||
label === "request:accepted" ||
|
||
label === "request:received";
|
||
}
|
||
|
||
function isSetupTraceEvent(event) {
|
||
const label = String(event?.label ?? "");
|
||
return /^session:|^stdio:|^prompt:|^thread:|^turn:started|^turn:start|turn\/start|codex turn|tool:codex-app-server/iu.test(label);
|
||
}
|
||
|
||
function isCompletionTraceEvent(event) {
|
||
const label = String(event?.label ?? "");
|
||
return label.startsWith("turn:completed") || label.startsWith("result:completed");
|
||
}
|
||
|
||
function tracePromptText(event) {
|
||
return cleanTraceText(event?.promptSummary ?? event?.prompt ?? "");
|
||
}
|
||
|
||
function traceNonEmptyString(value) {
|
||
const text = String(value ?? "").trim();
|
||
return text ? text : null;
|
||
}
|
||
|
||
function isNoisyTraceEvent(event) {
|
||
const label = String(event?.label ?? "");
|
||
if (isToolOutputChunkTraceEvent(event)) return false;
|
||
if (/token_count|outputDelta:chunk/iu.test(label)) return true;
|
||
if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true;
|
||
return false;
|
||
}
|
||
|
||
function isToolOutputChunkTraceEvent(event) {
|
||
const label = String(event?.label ?? "");
|
||
return label === "item/commandExecution/outputDelta" ||
|
||
(event?.type === "tool_call" && event?.status === "output_chunk" && !String(event?.toolName ?? "").startsWith("codex-app-server"));
|
||
}
|
||
|
||
function readableTraceLabel(event) {
|
||
const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`);
|
||
if (label === "request:accepted-short-connection") return "submit short-connection";
|
||
if (label === "request:accepted") return "request accepted";
|
||
if (label.startsWith("tool:codex-app-server.thread/start+turn/start")) return "codex turn start";
|
||
if (label.startsWith("tool:codex-app-server.thread/resume+turn/start")) return "codex turn resume";
|
||
if (label === "item/commandExecution:started") return "cmd started";
|
||
if (label === "item/commandExecution:completed") return "cmd completed";
|
||
if (label.startsWith("item/commandExecution/outputDelta")) return "cmd output";
|
||
if (label === "turn:waiting:first_assistant_token") return "waiting first assistant token";
|
||
if (label.startsWith("turn:completed")) return "turn completed";
|
||
if (label.startsWith("result:completed")) return "result ready";
|
||
if (label.startsWith("result:failed")) return "result failed";
|
||
if (label.startsWith("timeout:")) return "timeout";
|
||
if (label.startsWith("cancel:")) return "cancel";
|
||
return label
|
||
.replace(/^tool:/u, "")
|
||
.replace(/^assistant:/u, "assistant ")
|
||
.replace(/:completed:completed$/u, " completed")
|
||
.replace(/:completed$/u, " completed")
|
||
.replace(/:started$/u, " started");
|
||
}
|
||
|
||
function traceDisplayBody(event) {
|
||
const lines = [
|
||
event.promptSummary ? cleanTraceText(event.promptSummary) : null,
|
||
event.outputSummary ? cleanTraceOutputText(event.outputSummary) : null,
|
||
event.message ? cleanTraceText(event.message) : null,
|
||
event.chunk && !isNoisyTraceEvent(event) ? cleanTraceOutputText(event.chunk) : null,
|
||
event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null
|
||
].filter(Boolean);
|
||
return lines.join("\n").slice(0, 1400);
|
||
}
|
||
|
||
function rawTraceOutputText(event) {
|
||
return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? "");
|
||
}
|
||
|
||
function parseGatewayJsonRpcOutputs(text) {
|
||
const payloads = parseJsonObjects(text);
|
||
return payloads
|
||
.filter((payload) => payload && payload.jsonrpc === "2.0" && payload.result && typeof payload.result === "object")
|
||
.map((payload) => {
|
||
const result = payload.result;
|
||
const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {};
|
||
if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null;
|
||
return { payload, result, dispatch };
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function parseGatewayJsonRpcOutput(text) {
|
||
return parseGatewayJsonRpcOutputs(text)[0] ?? null;
|
||
}
|
||
|
||
function parseJsonObjects(text) {
|
||
const source = String(text ?? "");
|
||
const values = [];
|
||
let searchFrom = 0;
|
||
while (searchFrom < source.length) {
|
||
const start = source.indexOf("{", searchFrom);
|
||
if (start < 0) break;
|
||
const end = jsonObjectEnd(source, start);
|
||
if (end < 0) break;
|
||
try {
|
||
values.push(JSON.parse(source.slice(start, end + 1)));
|
||
searchFrom = end + 1;
|
||
} catch {
|
||
searchFrom = start + 1;
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function parseFirstJsonObject(text) {
|
||
const source = String(text ?? "");
|
||
const start = source.indexOf("{");
|
||
if (start < 0) return null;
|
||
const end = jsonObjectEnd(source, start);
|
||
if (end < 0) return null;
|
||
try {
|
||
return JSON.parse(source.slice(start, end + 1));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function jsonObjectEnd(source, start) {
|
||
let depth = 0;
|
||
let inString = false;
|
||
let escaped = false;
|
||
for (let index = start; index < source.length; index += 1) {
|
||
const char = source[index];
|
||
if (inString) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (char === "\\") {
|
||
escaped = true;
|
||
} else if (char === "\"") {
|
||
inString = false;
|
||
}
|
||
continue;
|
||
}
|
||
if (char === "\"") {
|
||
inString = true;
|
||
continue;
|
||
}
|
||
if (char === "{") depth += 1;
|
||
if (char === "}") depth -= 1;
|
||
if (depth === 0) {
|
||
return index;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) {
|
||
const stdout = cleanTraceOutputText(dispatch.stdout);
|
||
const stderr = cleanTraceOutputText(dispatch.stderr);
|
||
const error = dispatch.error?.message ?? dispatch.error ?? dispatch.message ?? result.message ?? null;
|
||
return [
|
||
`request=${payload.id ?? "unknown"}`,
|
||
`gateway=${result.gatewaySessionId ?? "unknown"} resource=${result.resourceId ?? "unknown"} capability=${result.capabilityId ?? "unknown"}`,
|
||
`dispatch=${dispatch.dispatchStatus ?? result.status ?? "unknown"} shellExecuted=${dispatch.shellExecuted === true ? "true" : "false"} timedOut=${dispatch.timedOut === true ? "true" : "false"}`,
|
||
error ? `error=${compactTraceTextTail(cleanTraceOutputText(error), 600)}` : null,
|
||
dispatch.command ? `command=${compactTraceTextTail(cleanTraceOutputText(dispatch.command), 420)}` : null,
|
||
result.auditId ? `audit=${result.auditId}` : null,
|
||
result.evidenceId ? `evidence=${result.evidenceId}` : null,
|
||
dispatch.stdoutTruncated === true ? "stdoutTruncated=true" : null,
|
||
stdout ? `stdout:\n${compactTraceTextTail(stdout, 1200)}` : null,
|
||
stderr ? `stderr:\n${compactTraceTextTail(stderr, 800)}` : null,
|
||
!stdout && !stderr && raw ? compactTraceTextTail(cleanTraceOutputText(raw), 900) : null
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function traceStatusToken(event) {
|
||
const status = String(event?.status ?? "");
|
||
if (status === "completed" || status === "succeeded") return "ok";
|
||
if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event?.errorCode) return "fail";
|
||
if (["started", "running", "accepted"].includes(status)) return "run";
|
||
return status || "event";
|
||
}
|
||
|
||
function traceEventTone(event) {
|
||
const status = traceStatusToken(event);
|
||
if (status === "ok") return "ok";
|
||
if (status === "fail") return "blocked";
|
||
if (status === "run") return "warn";
|
||
return "source";
|
||
}
|
||
|
||
function traceClock(value) {
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19);
|
||
}
|
||
|
||
function traceRelativeMs(trace, event) {
|
||
if (typeof event?.elapsedMs === "number") return event.elapsedMs;
|
||
const start = Date.parse(trace?.startedAt ?? trace?.createdAt ?? "");
|
||
const current = Date.parse(event?.createdAt ?? "");
|
||
return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start);
|
||
}
|
||
|
||
function formatTraceDuration(ms) {
|
||
const total = Math.max(0, Math.floor(Number(ms) || 0));
|
||
const seconds = Math.floor(total / 1000);
|
||
const hh = String(Math.floor(seconds / 3600)).padStart(2, "0");
|
||
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0");
|
||
const ss = String(seconds % 60).padStart(2, "0");
|
||
return `${hh}:${mm}:${ss}`;
|
||
}
|
||
|
||
function compactTraceSize(value) {
|
||
const length = String(value ?? "").length;
|
||
if (length >= 1000) return `${(length / 1000).toFixed(length >= 10000 ? 0 : 1)}k`;
|
||
return String(length);
|
||
}
|
||
|
||
function formatTraceBytes(value) {
|
||
const bytes = Math.max(0, Number(value) || 0);
|
||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MiB`;
|
||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KiB`;
|
||
return `${bytes}B`;
|
||
}
|
||
|
||
function cleanTraceText(value) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return "";
|
||
return text
|
||
.replace(/\s*\/\s*/gu, " / ")
|
||
.replace(/[ \t]{2,}/gu, " ")
|
||
.replace(/\n{3,}/gu, "\n\n");
|
||
}
|
||
|
||
function cleanTraceOutputText(value) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return "";
|
||
return text
|
||
.replace(/[ \t]{2,}/gu, " ")
|
||
.replace(/\n{3,}/gu, "\n\n");
|
||
}
|
||
|
||
function traceActionButton(label, onClick, title) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "message-trace-action";
|
||
button.textContent = label;
|
||
button.title = title;
|
||
button.addEventListener("click", onClick);
|
||
return button;
|
||
}
|
||
|
||
function messageTraceJson(message, trace, events) {
|
||
return JSON.stringify({
|
||
traceId: trace?.traceId ?? message.traceId ?? null,
|
||
messageId: message.id ?? null,
|
||
eventCount: Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length,
|
||
eventsCompacted: trace?.eventsCompacted === true,
|
||
eventWindow: trace?.eventWindow ?? null,
|
||
lastEvent: trace?.lastEvent ?? events.at(-1) ?? null,
|
||
assistantStreams: Array.isArray(trace?.assistantStreams) ? trace.assistantStreams : [],
|
||
events
|
||
}, null, 2);
|
||
}
|
||
|
||
function downloadTraceJson(message, trace, events) {
|
||
const traceId = String(trace?.traceId ?? message.traceId ?? "trace")
|
||
.replace(/[^A-Za-z0-9_.-]+/gu, "-")
|
||
.replace(/^-|-$/gu, "")
|
||
.slice(0, 96) || "trace";
|
||
const blob = new Blob([messageTraceJson(message, trace, events)], { type: "application/json" });
|
||
const href = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = href;
|
||
anchor.download = `${traceId}.json`;
|
||
document.body.append(anchor);
|
||
anchor.click();
|
||
anchor.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(href), 0);
|
||
}
|
||
|
||
function runnerTraceHeadline(message, trace) {
|
||
const count = Number.isInteger(trace?.eventCount) ? trace.eventCount : Array.isArray(trace?.events) ? trace.events.length : 0;
|
||
const last = trace?.lastEvent?.label ?? trace?.eventLabels?.at?.(-1) ?? "等待事件";
|
||
const elapsed = typeof trace?.elapsedMs === "number" ? ` / ${trace.elapsedMs}ms` : "";
|
||
const waiting = trace?.waitingFor ? ` / 等待 ${trace.waitingFor}` : "";
|
||
const thread = trace?.threadId ?? message.threadId ? ` / thread=${trace?.threadId ?? message.threadId}` : "";
|
||
return `运行 trace ${trace?.traceId ?? message.traceId ?? "pending"} / events=${count} / last=${last}${elapsed}${waiting}${thread}`;
|
||
}
|