feat: improve AgentRun trace continuity
Pipelines as Code CI / hwlab-nc01-v03-ci-poll-9f876ae42a3cd218986017e288372578a758ebbc Success

This commit is contained in:
root
2026-07-21 09:32:49 +02:00
parent 692e4fcc80
commit 9f876ae42a
5 changed files with 280 additions and 156 deletions
+55 -4
View File
@@ -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",
+167 -19
View File
@@ -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>
+16 -37
View File
@@ -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 {