feat: improve AgentRun trace continuity
Pipelines as Code CI / hwlab-nc01-v03-ci-poll-9f876ae42a3cd218986017e288372578a758ebbc Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll-9f876ae42a3cd218986017e288372578a758ebbc Success
This commit is contained in:
@@ -1409,11 +1409,12 @@ function toolCapabilityAllowed(toolCapabilities = null, toolId) {
|
||||
|
||||
async function resolveReusableAgentRun({ params = {}, options = {}, env = process.env, managerUrl, fetchImpl, timeoutMs, backendProfile, traceId, traceStore }) {
|
||||
const hwlabSessionId = hwlabSessionIdForParams(params, traceId);
|
||||
if (!safeSessionId(hwlabSessionId) || hwlabSessionId === agentRunSessionId(traceId) || typeof options.accessController?.getAgentSession !== "function") {
|
||||
if (!safeSessionId(hwlabSessionId) || hwlabSessionId === agentRunSessionId(traceId)) {
|
||||
return null;
|
||||
}
|
||||
const session = await options.accessController.getAgentSession(hwlabSessionId);
|
||||
if (!canReuseAgentRunSessionForOwner(session, params, options)) {
|
||||
const hasSessionAccessController = typeof options.accessController?.getAgentSession === "function";
|
||||
const session = hasSessionAccessController ? await options.accessController.getAgentSession(hwlabSessionId) : null;
|
||||
if (hasSessionAccessController && !canReuseAgentRunSessionForOwner(session, params, options)) {
|
||||
traceStore.append(traceId, agentRunTraceEvent({
|
||||
type: "backend",
|
||||
status: "running",
|
||||
@@ -1425,7 +1426,57 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces
|
||||
}, backendProfile));
|
||||
return null;
|
||||
}
|
||||
const mapping = session?.session?.agentRun;
|
||||
let mapping = session?.session?.agentRun;
|
||||
if (!isReusableAgentRunMapping(mapping, backendProfile)) {
|
||||
const agentRunScopedSessionId = scopedAgentRunSessionIdForParams(params, traceId);
|
||||
try {
|
||||
const durableSession = agentRunSessionRecordFromEnsure(await agentRunDispatchJson({
|
||||
fetchImpl,
|
||||
managerUrl,
|
||||
path: `/api/v1/sessions/${encodeURIComponent(agentRunScopedSessionId)}`,
|
||||
method: "GET",
|
||||
timeoutMs,
|
||||
env,
|
||||
traceStore,
|
||||
traceId,
|
||||
backendProfile,
|
||||
stage: "session-run-reuse-discovery",
|
||||
terminalOnFailure: false
|
||||
}));
|
||||
const lastRunId = safeOpaqueId(durableSession?.lastRunId);
|
||||
const durableProfile = firstNonEmpty(durableSession?.backendProfile, durableSession?.metadata?.agentRunSessionProfile);
|
||||
if (lastRunId && (!durableProfile || durableProfile === backendProfile)) {
|
||||
mapping = {
|
||||
adapter: ADAPTER_ID,
|
||||
managerUrl,
|
||||
backendProfile,
|
||||
providerId: resolveAgentRunProviderId(env),
|
||||
runId: lastRunId,
|
||||
commandId: safeOpaqueId(durableSession?.lastCommandId) || null,
|
||||
sessionId: agentRunScopedSessionId,
|
||||
conversationId: safeConversationId(durableSession?.conversationId) || safeConversationId(params.conversationId) || null,
|
||||
threadId: safeOpaqueId(durableSession?.threadId) || null,
|
||||
durableDispatch: true,
|
||||
reuseEligible: true,
|
||||
runnerJobCount: 1,
|
||||
valuesPrinted: false
|
||||
};
|
||||
traceStore.append(traceId, agentRunTraceEvent({
|
||||
type: "backend",
|
||||
status: "running",
|
||||
label: "agentrun:run:reuse-discovered-from-session",
|
||||
message: `AgentRun durable session ${agentRunScopedSessionId} identified run ${lastRunId}; local HWLAB session projection was not required for runner reuse.`,
|
||||
sessionId: hwlabSessionId,
|
||||
agentRunSessionId: agentRunScopedSessionId,
|
||||
runId: lastRunId,
|
||||
waitingFor: "agentrun-run-reuse-check",
|
||||
valuesPrinted: false
|
||||
}, backendProfile));
|
||||
}
|
||||
} catch {
|
||||
mapping = null;
|
||||
}
|
||||
}
|
||||
if (!isReusableAgentRunMapping(mapping, backendProfile)) {
|
||||
traceStore.append(traceId, agentRunTraceEvent({
|
||||
type: "backend",
|
||||
|
||||
@@ -58,6 +58,10 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
||||
renderedSourceEvents.add(sourceEventKey);
|
||||
}
|
||||
if (!event) continue;
|
||||
if (isSemanticRuntimePreparationEvent(event)) {
|
||||
rows.push(traceSemanticRuntimePreparationRow(event, displayOptions));
|
||||
continue;
|
||||
}
|
||||
if (isSemanticFailureRetryEvent(event)) {
|
||||
rows.push(traceSemanticFailureRetryRow(event, displayOptions));
|
||||
continue;
|
||||
@@ -116,6 +120,30 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
||||
.map((event) => traceDisplayRow(event, displayOptions));
|
||||
}
|
||||
|
||||
function isSemanticRuntimePreparationEvent(event: TraceEvent): boolean {
|
||||
const code = nonEmptyString(event.failureCode ?? event.code);
|
||||
return code === "git-mirror-fetch-in-progress" || code === "git-mirror-fetch-completed";
|
||||
}
|
||||
|
||||
function traceSemanticRuntimePreparationRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||||
const code = nonEmptyString(event.failureCode ?? event.code);
|
||||
const completed = code === "git-mirror-fetch-completed";
|
||||
const attempt = numberOrNull(event.retryAttempt ?? event.attempt);
|
||||
const maxAttempts = numberOrNull(event.retryMaxAttempts ?? event.maxAttempts);
|
||||
const progress = attempt !== null && maxAttempts !== null ? `第 ${attempt}/${maxAttempts} 次` : "本次";
|
||||
const summary = nonEmptyString(event.summary ?? event.message) ?? (completed ? "Runner 源码获取完成" : "正在获取 Runner 源码");
|
||||
return {
|
||||
rowId: `event:runtime-preparation:${eventKey ?? code ?? "source-fetch"}`,
|
||||
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
|
||||
sequenceAuthority: options.sequenceAuthority,
|
||||
tone: completed ? "ok" : "source",
|
||||
header: `${traceEventClock(event, options)} 运行环境准备 · ${completed ? "源码获取完成" : "源码获取中"}`.trim(),
|
||||
body: `${summary}\n${completed ? "正在启动 Runner" : `${progress}源码获取进行中`}`,
|
||||
bodyFormat: "text"
|
||||
};
|
||||
}
|
||||
|
||||
function isSemanticFailureRetryEvent(event: TraceEvent): boolean {
|
||||
if (!nonEmptyString(event.failureDomain)) return false;
|
||||
const phase = normalizedRetryPhase(event.retryPhase);
|
||||
@@ -309,7 +337,8 @@ function traceToolCallRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOp
|
||||
const command = cleanShellCommand(event.command);
|
||||
const toolState = traceToolState(event);
|
||||
const toolName = traceToolName(event, command);
|
||||
const body = isFileChangeTraceEvent(event) ? traceFileChangeBody(event) : traceToolCallBody(event, command);
|
||||
const fileChange = isFileChangeTraceEvent(event);
|
||||
const body = fileChange ? traceFileChangeBody(event) : traceToolCallBody(event, command);
|
||||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||||
return {
|
||||
rowId: `tool:${event.itemId ?? eventKey ?? `${event.label ?? event.type ?? "tool"}:${event.createdAt ?? "unknown"}`}`,
|
||||
@@ -320,8 +349,8 @@ function traceToolCallRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOp
|
||||
statusLabel: traceToolStatusLabel(toolState),
|
||||
header: traceEventClock(event, options),
|
||||
body,
|
||||
preview: isFileChangeTraceEvent(event) ? traceFileChangePreview(event) : traceToolPreview(command, toolName),
|
||||
bodyFormat: "text"
|
||||
preview: fileChange ? traceFileChangePreview(event) : traceToolPreview(command, toolName),
|
||||
bodyFormat: fileChange && body ? "markdown" : "text"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -330,22 +359,12 @@ function isFileChangeTraceEvent(event: TraceEvent): boolean {
|
||||
}
|
||||
|
||||
function traceFileChangePreview(event: TraceEvent): string {
|
||||
const paths = (Array.isArray(event.changes) ? event.changes : [])
|
||||
.filter(isRecord)
|
||||
.map((change) => nonEmptyString(change.path))
|
||||
.filter((path): path is string => Boolean(path));
|
||||
return compactTraceOneLine(paths.length > 0 ? `fileChange ${paths.join(", ")}` : "fileChange", 140);
|
||||
const summary = traceDiffSummary(traceDiffFiles(event));
|
||||
return compactTraceOneLine(summary ?? "fileChange", 140);
|
||||
}
|
||||
|
||||
function traceFileChangeBody(event: TraceEvent): string | null {
|
||||
const changes = (Array.isArray(event.changes) ? event.changes : []).filter(isRecord);
|
||||
if (changes.length === 0) return null;
|
||||
return changes.map((change) => {
|
||||
const path = nonEmptyString(change.path) ?? "(path missing)";
|
||||
const kind = nonEmptyString(change.kind) ?? "update";
|
||||
const diff = nonEmptyString(change.diff) ?? "";
|
||||
return [`path: ${path}`, `kind: ${kind}`, "diff:", diff].filter(Boolean).join("\n");
|
||||
}).join("\n\n");
|
||||
return traceDiffMarkdown(traceDiffFiles(event));
|
||||
}
|
||||
|
||||
function isDiffTraceEvent(event: TraceEvent): boolean {
|
||||
@@ -354,6 +373,8 @@ function isDiffTraceEvent(event: TraceEvent): boolean {
|
||||
|
||||
function traceDiffRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||||
const files = traceDiffFiles(event);
|
||||
const diff = nonEmptyString(event.diff ?? event.message);
|
||||
return {
|
||||
rowId: `tool:diff:${eventKey ?? event.createdAt ?? "updated"}`,
|
||||
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
|
||||
@@ -362,12 +383,139 @@ function traceDiffRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOption
|
||||
toolState: "success",
|
||||
statusLabel: "文件 diff 已更新",
|
||||
header: traceEventClock(event, options),
|
||||
body: nonEmptyString(event.diff ?? event.message),
|
||||
preview: "turn/diff/updated",
|
||||
bodyFormat: "text"
|
||||
body: traceDiffMarkdown(files) ?? diff,
|
||||
preview: traceDiffSummary(files) ?? "turn/diff/updated",
|
||||
bodyFormat: files.length > 0 ? "markdown" : "text"
|
||||
};
|
||||
}
|
||||
|
||||
interface TraceDiffFile {
|
||||
path: string | null;
|
||||
kind: string | null;
|
||||
diff: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
function traceDiffFiles(event: TraceEvent): TraceDiffFile[] {
|
||||
const changes = (Array.isArray(event.changes) ? event.changes : []).filter(isRecord);
|
||||
if (changes.length > 0) {
|
||||
return changes.map((change) => traceDiffFile(
|
||||
traceDiffPath(nonEmptyString(change.path)),
|
||||
traceDiffKind(change.kind),
|
||||
nonEmptyString(change.diff) ?? ""
|
||||
));
|
||||
}
|
||||
const diff = nonEmptyString(event.diff ?? event.message);
|
||||
return diff ? traceUnifiedDiffFiles(diff) : [];
|
||||
}
|
||||
|
||||
function traceUnifiedDiffFiles(diff: string): TraceDiffFile[] {
|
||||
const sections: Array<{ path: string | null; lines: string[] }> = [];
|
||||
let current: { path: string | null; lines: string[] } = { path: null, lines: [] };
|
||||
const flush = () => {
|
||||
if (current.lines.length === 0) return;
|
||||
sections.push(current);
|
||||
};
|
||||
for (const line of diff.replace(/\r\n|\r/gu, "\n").split("\n")) {
|
||||
if (line.startsWith("diff --git ") && current.lines.length > 0) {
|
||||
flush();
|
||||
current = { path: null, lines: [] };
|
||||
}
|
||||
current.lines.push(line);
|
||||
if (line.startsWith("+++ ")) current.path = traceDiffHeaderPath(line.slice(4)) ?? current.path;
|
||||
if (line.startsWith("rename to ")) current.path = line.slice("rename to ".length).trim() || current.path;
|
||||
}
|
||||
flush();
|
||||
return sections.map((section) => traceDiffFile(section.path, null, section.lines.join("\n")));
|
||||
}
|
||||
|
||||
function traceDiffHeaderPath(value: string): string | null {
|
||||
const path = value.trim().replace(/^"|"$/gu, "");
|
||||
if (!path || path === "/dev/null") return null;
|
||||
return traceDiffPath(path.startsWith("b/") ? path.slice(2) : path);
|
||||
}
|
||||
|
||||
function traceDiffFile(path: string | null, kind: string | null, diff: string): TraceDiffFile {
|
||||
const normalizedDiff = traceNormalizedFileDiff(path, kind, diff);
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of normalizedDiff.replace(/\r\n|\r/gu, "\n").split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
|
||||
if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
|
||||
}
|
||||
return { path, kind, diff: normalizedDiff, additions, deletions };
|
||||
}
|
||||
|
||||
function traceDiffKind(value: unknown): string | null {
|
||||
if (isRecord(value)) return nonEmptyString(value.type ?? value.kind);
|
||||
return nonEmptyString(value);
|
||||
}
|
||||
|
||||
function traceDiffPath(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const marker = "/agentrun-workspace/";
|
||||
const markerIndex = value.indexOf(marker);
|
||||
return markerIndex >= 0 ? value.slice(markerIndex + marker.length) : value;
|
||||
}
|
||||
|
||||
function traceNormalizedFileDiff(path: string | null, kind: string | null, diff: string): string {
|
||||
const normalized = diff.replace(/\r\n|\r/gu, "\n");
|
||||
if (!normalized || /^(?:diff --git |@@ |--- |\+\+\+ )/mu.test(normalized)) return normalized;
|
||||
const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n");
|
||||
if (kind === "add" || kind === "added" || kind === "create") {
|
||||
return [`--- /dev/null`, `+++ b/${path ?? "file"}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`)].join("\n");
|
||||
}
|
||||
if (kind === "delete" || kind === "deleted" || kind === "remove") {
|
||||
return [`--- a/${path ?? "file"}`, `+++ /dev/null`, `@@ -1,${lines.length} +0,0 @@`, ...lines.map((line) => `-${line}`)].join("\n");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function traceDiffSummary(files: TraceDiffFile[]): string | null {
|
||||
if (files.length === 0) return null;
|
||||
const additions = files.reduce((total, file) => total + file.additions, 0);
|
||||
const deletions = files.reduce((total, file) => total + file.deletions, 0);
|
||||
const paths = files.map((file) => file.path).filter((path): path is string => Boolean(path));
|
||||
const stats = `+${additions} -${deletions}`;
|
||||
if (files.length === 1) return `${paths[0] ?? "文件变更"} · ${stats}`;
|
||||
const visiblePaths = paths.slice(0, 2).join(", ");
|
||||
const remaining = Math.max(0, paths.length - 2);
|
||||
const pathSummary = visiblePaths ? ` · ${visiblePaths}${remaining > 0 ? ` 等 ${paths.length} 个文件` : ""}` : "";
|
||||
return `${files.length} 个文件 · ${stats}${pathSummary}`;
|
||||
}
|
||||
|
||||
function traceDiffMarkdown(files: TraceDiffFile[]): string | null {
|
||||
if (files.length === 0) return null;
|
||||
return files.map((file, index) => {
|
||||
const title = traceMarkdownInlineCode(file.path ?? `文件变更 ${index + 1}`);
|
||||
const kind = traceDiffKindLabel(file.kind);
|
||||
const stats = `${kind} · +${file.additions} -${file.deletions}`;
|
||||
const diff = file.diff.trimEnd();
|
||||
return [`#### ${title}`, stats, diff ? traceMarkdownCodeFence("diff", diff) : "无可显示 diff"].join("\n\n");
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
function traceDiffKindLabel(value: string | null): string {
|
||||
if (value === "add" || value === "added" || value === "create") return "新增";
|
||||
if (value === "delete" || value === "deleted" || value === "remove") return "删除";
|
||||
if (value === "rename" || value === "renamed") return "重命名";
|
||||
return "修改";
|
||||
}
|
||||
|
||||
function traceMarkdownInlineCode(value: string): string {
|
||||
const text = value.replace(/\r\n|\r|\n/gu, " ").trim();
|
||||
const longestRun = Math.max(0, ...([...text.matchAll(/`+/gu)].map((match) => match[0].length)));
|
||||
const fence = "`".repeat(longestRun + 1);
|
||||
return `${fence}${text}${fence}`;
|
||||
}
|
||||
|
||||
function traceMarkdownCodeFence(language: string, body: string): string {
|
||||
const longestRun = Math.max(0, ...([...body.matchAll(/`+/gu)].map((match) => match[0].length)));
|
||||
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
||||
return `${fence}${language}\n${body}\n${fence}`;
|
||||
}
|
||||
|
||||
function traceToolPreview(command: string | null, fallbackToolName: string): string {
|
||||
return compactTraceOneLine(command ?? fallbackToolName, 140);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first; PJ2026-010401080313 Workbench实时权威 draft-2026-07-14-p0-pure-kafka-authority. Render a bounded, stable Trace tail from the Kafka SSE projection. -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import type { RunnerTrace } from "@/types";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
|
||||
@@ -20,15 +20,19 @@ const props = defineProps<{
|
||||
|
||||
const TRACE_RENDER_ROW_WINDOW = 120;
|
||||
const TRACE_RENDER_EVENT_WINDOW = TRACE_RENDER_ROW_WINDOW * 2;
|
||||
const TRACE_LOAD_OLDER_THRESHOLD_PX = 24;
|
||||
const listRef = ref<HTMLOListElement | null>(null);
|
||||
const expanded = ref(false);
|
||||
const visibleRowLimit = ref(TRACE_RENDER_ROW_WINDOW);
|
||||
const visibleEventLimit = ref(TRACE_RENDER_EVENT_WINDOW);
|
||||
const loadingOlder = ref(false);
|
||||
const expandedToolRowIds = ref(new Set<string>());
|
||||
const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(listRef, { threshold: 24 });
|
||||
|
||||
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
|
||||
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
|
||||
const renderEvents = computed(() => events.value.length > TRACE_RENDER_EVENT_WINDOW
|
||||
? events.value.slice(-TRACE_RENDER_EVENT_WINDOW)
|
||||
const renderEvents = computed(() => events.value.length > visibleEventLimit.value
|
||||
? events.value.slice(-visibleEventLimit.value)
|
||||
: events.value);
|
||||
const rawReadableRows = computed(() => expanded.value ? traceDisplayRows(traceRecord(props.trace), renderEvents.value as Record<string, unknown>[], {
|
||||
formatClock: formatDisplayClock,
|
||||
@@ -36,9 +40,10 @@ const rawReadableRows = computed(() => expanded.value ? traceDisplayRows(traceRe
|
||||
outerFinalResponseText: props.outerFinalResponseText
|
||||
}) : []);
|
||||
const readableRows = computed(() => rawReadableRows.value);
|
||||
const hiddenRowCount = computed(() => Math.max(0, readableRows.value.length - TRACE_RENDER_ROW_WINDOW));
|
||||
const renderedRows = computed(() => hiddenRowCount.value > 0 ? readableRows.value.slice(-TRACE_RENDER_ROW_WINDOW) : readableRows.value);
|
||||
const hiddenRowCount = computed(() => Math.max(0, readableRows.value.length - visibleRowLimit.value));
|
||||
const renderedRows = computed(() => hiddenRowCount.value > 0 ? readableRows.value.slice(-visibleRowLimit.value) : readableRows.value);
|
||||
const inputEventWindowed = computed(() => renderEvents.value.length < events.value.length);
|
||||
const hasOlderRows = computed(() => hiddenRowCount.value > 0 || inputEventWindowed.value);
|
||||
const traceSummaryLabel = computed(() => {
|
||||
const count = eventCount.value;
|
||||
if (count <= 0) return String(props.trace?.status ?? "").trim() || "pending";
|
||||
@@ -58,6 +63,12 @@ const emptyTraceLabel = computed(() => {
|
||||
});
|
||||
let programmaticToggleTarget: boolean | null = null;
|
||||
|
||||
watch(() => traceExpansionScope(), () => {
|
||||
visibleRowLimit.value = TRACE_RENDER_ROW_WINDOW;
|
||||
visibleEventLimit.value = TRACE_RENDER_EVENT_WINDOW;
|
||||
loadingOlder.value = false;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => [eventCount.value, props.trace?.status, readableRows.value.length, expanded.value], () => {
|
||||
if (!expanded.value) return;
|
||||
void keepBottomAfterUpdate();
|
||||
@@ -105,6 +116,26 @@ function onDisclosureToggle(event: Event): void {
|
||||
writeStoredExpanded(target.open);
|
||||
}
|
||||
|
||||
function onTimelineScroll(event: Event): void {
|
||||
onScroll();
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLOListElement)) return;
|
||||
if (target.scrollTop > TRACE_LOAD_OLDER_THRESHOLD_PX || !hasOlderRows.value || loadingOlder.value) return;
|
||||
void loadOlderRows(target);
|
||||
}
|
||||
|
||||
async function loadOlderRows(target: HTMLOListElement): Promise<void> {
|
||||
loadingOlder.value = true;
|
||||
const previousScrollTop = target.scrollTop;
|
||||
const previousScrollHeight = target.scrollHeight;
|
||||
visibleRowLimit.value = Math.min(events.value.length, visibleRowLimit.value + TRACE_RENDER_ROW_WINDOW);
|
||||
visibleEventLimit.value = Math.min(events.value.length, visibleEventLimit.value + TRACE_RENDER_EVENT_WINDOW);
|
||||
await nextTick();
|
||||
target.scrollTop = previousScrollTop + Math.max(0, target.scrollHeight - previousScrollHeight);
|
||||
onScroll();
|
||||
loadingOlder.value = false;
|
||||
}
|
||||
|
||||
function onToolDisclosureToggle(row: TraceEventRow, event: Event): void {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLDetailsElement)) return;
|
||||
@@ -180,8 +211,8 @@ function firstNonEmptyString(...values: unknown[]): string | null {
|
||||
<span class="trace-disclosure-count">{{ traceSummaryLabel }}</span>
|
||||
</summary>
|
||||
<div v-if="expanded" class="trace-disclosure-body">
|
||||
<p v-if="hiddenRowCount > 0 || inputEventWindowed" class="trace-window-notice">显示最近 {{ renderedRows.length }} 行,共 {{ eventCount }} events</p>
|
||||
<ol v-if="renderedRows.length > 0" ref="listRef" @scroll="onScroll">
|
||||
<p v-if="hasOlderRows" class="trace-window-notice" :data-loading="loadingOlder ? 'true' : 'false'">已显示 {{ renderedRows.length }} 行,共 {{ eventCount }} events</p>
|
||||
<ol v-if="renderedRows.length > 0" ref="listRef" @scroll="onTimelineScroll">
|
||||
<li v-for="(row, index) in renderedRows" :key="rowKey(row, index)" v-memo="[trace.traceId, row.rowId, row.seq, row.header, row.body, row.bodyFormat, row.tone, row.toolState, row.terminal, hiddenRowCount + index, expandedToolRowIds.has(row.rowId)]" class="trace-render-row" data-testid="trace-render-row" :data-trace-id="trace.traceId || undefined" :data-event-seq="row.seq ?? undefined" :data-event-seq-authority="row.sequenceAuthority" :data-projected-seq="row.sequenceAuthority === 'projected' ? row.seq ?? undefined : undefined" :data-event-kind="row.rowId" :data-event-status="row.tone" :data-tool-state="row.toolState ?? undefined" :data-terminal="row.terminal ? 'true' : 'false'" :data-row-kind="rowIsTool(row) ? 'tool' : 'message'" :data-row-id="row.rowId" :aria-posinset="hiddenRowCount + index + 1">
|
||||
<details v-if="rowIsTool(row)" class="trace-tool-details" :aria-busy="row.toolState === 'running' ? 'true' : undefined" @toggle="onToolDisclosureToggle(row, $event)">
|
||||
<summary :aria-label="traceToolAccessibilityLabel(row)" :title="traceToolAccessibilityLabel(row)" aria-live="polite" aria-atomic="true">
|
||||
|
||||
@@ -13,7 +13,6 @@ import { workbenchTraceTimelinePolicy } from "@/config/runtime";
|
||||
import { messageDiagnosticView } from "@/utils/workbench-error-runtime";
|
||||
import MessageMarkdown from "./MessageMarkdown.vue";
|
||||
import { traceLifecycleExpanded } from "./trace-lifecycle";
|
||||
import { semanticRuntimeStatusView } from "./workbench-semantic-runtime-status";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
message: ChatMessage;
|
||||
@@ -37,7 +36,6 @@ const diagnostic = computed(() => messageDiagnosticView(props.message));
|
||||
const visibleText = computed(() => visibleMessageText(props.message));
|
||||
const durationMeta = computed(() => messageDurationMeta(props.message));
|
||||
const activityMeta = computed(() => messageActivityMeta(props.message));
|
||||
const semanticRuntimeStatus = computed(() => semanticRuntimeStatusView(props.message, nowMs.value));
|
||||
const autoExpanded = computed(() => props.traceAutoExpanded ?? traceLifecycleExpanded(props.message, traceTimelinePolicy.value));
|
||||
|
||||
function isRunningMessage(message: ChatMessage): boolean {
|
||||
@@ -136,103 +134,20 @@ function traceStorageKey(message: ChatMessage): string {
|
||||
:data-trace-id="message.traceId || message.runnerTrace?.traceId || undefined"
|
||||
:data-turn-id="message.turnId || message.traceId || message.runnerTrace?.traceId || undefined"
|
||||
>
|
||||
<section v-if="semanticRuntimeStatus" class="semantic-failure-banner" :data-tone="semanticRuntimeStatus.tone" :data-emphasis="semanticRuntimeStatus.emphasis" role="status" aria-live="polite" :title="semanticRuntimeStatus.code ? `${semanticRuntimeStatus.title} · ${semanticRuntimeStatus.detail} · ${semanticRuntimeStatus.code}` : undefined">
|
||||
<span class="semantic-status-marker" aria-hidden="true" />
|
||||
<strong>{{ semanticRuntimeStatus.title }}</strong>
|
||||
<span>{{ semanticRuntimeStatus.detail }}</span>
|
||||
<code v-if="semanticRuntimeStatus.code && semanticRuntimeStatus.emphasis === 'alert'">{{ semanticRuntimeStatus.code }}</code>
|
||||
</section>
|
||||
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="autoExpanded" :storage-key="traceStorageKey(message)" final-response-placement="outer" :outer-final-response-text="visibleText" />
|
||||
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="启动中..." compact />
|
||||
<MessageMarkdown v-if="visibleText" class="message-text" :source="visibleText" />
|
||||
<ApiErrorDiagnostic v-if="diagnostic.visible" class="message-diagnostic projection-diagnostic" :error="diagnostic.text" :api-error="diagnostic.apiError" :diagnostic="diagnostic.diagnostic" :show-message="diagnostic.showMessage" compact />
|
||||
<footer v-if="message.role !== 'user'" class="message-meta-footer">
|
||||
<div class="message-header-main">
|
||||
<strong>{{ message.title }}</strong>
|
||||
<div v-if="running || activityMeta" class="message-activity-group">
|
||||
<span v-if="running" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
|
||||
<span v-if="activityMeta" class="message-activity-meta" :aria-label="activityMeta.label" :title="activityMeta.label">{{ activityMeta.text }}</span>
|
||||
</div>
|
||||
<div class="message-header-actions">
|
||||
<span v-if="running" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
|
||||
<StatusBadge v-if="showStatusBadge(message)" :status="message.status" />
|
||||
<button v-if="detailEnabled && message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="emit('details', message)">!</button>
|
||||
</div>
|
||||
<div class="message-footer-timing">
|
||||
<span v-if="activityMeta" class="message-activity-meta" :aria-label="activityMeta.label" :title="activityMeta.label">{{ activityMeta.text }}</span>
|
||||
<span v-if="durationMeta" class="message-timing-meta message-duration-meta" :aria-label="durationMeta.label" :title="durationMeta.label">{{ durationMeta.text }}</span>
|
||||
<button v-if="detailEnabled && message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="emit('details', message)">!</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.semantic-failure-banner {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin: 0.55rem 0;
|
||||
border-left: 3px solid #d97706;
|
||||
border-radius: 0.35rem;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
|
||||
.semantic-status-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.semantic-failure-banner[data-emphasis="quiet"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
margin: 0.25rem 0;
|
||||
border-left-width: 2px;
|
||||
border-radius: 0.2rem;
|
||||
background: transparent;
|
||||
padding: 0.2rem 0.45rem;
|
||||
color: #475569;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.semantic-failure-banner[data-emphasis="quiet"] .semantic-status-marker {
|
||||
display: block;
|
||||
flex: 0 0 0.42rem;
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.semantic-failure-banner[data-emphasis="quiet"] strong {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.semantic-failure-banner[data-emphasis="quiet"] span:not(.semantic-status-marker) {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.semantic-failure-banner[data-tone="recovered"] {
|
||||
border-left-color: #15803d;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.semantic-failure-banner[data-tone="info"] {
|
||||
border-left-color: #2563eb;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.semantic-failure-banner[data-tone="failed"] {
|
||||
border-left-color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.semantic-failure-banner span,
|
||||
.semantic-failure-banner code {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -510,22 +510,6 @@
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.message-header-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
|
||||
.message-header-main strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-timing-meta {
|
||||
display: inline-flex;
|
||||
min-width: 64px;
|
||||
@@ -545,39 +529,34 @@
|
||||
|
||||
.message-activity-meta {
|
||||
display: inline-flex;
|
||||
min-width: 86px;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
justify-content: flex-start;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.message-activity-group {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message-header-actions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-self: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-footer-timing {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-footer-timing .message-activity-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-footer-timing .message-duration-meta {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-detail-button {
|
||||
|
||||
Reference in New Issue
Block a user