385 lines
20 KiB
Vue
385 lines
20 KiB
Vue
<script setup lang="ts">
|
||
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response; PJ2026-010401 Web工作台 draft-2026-06-20-p1-view-local-timing-ticker.
|
||
// Confirms Workbench message visibility after Vue render and keeps sealed final responses separate from diagnostics.
|
||
|
||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||
import type { ApiError, ChatMessage, ErrorDiagnostic } from "@/types";
|
||
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
|
||
import LoadingState from "@/components/common/LoadingState.vue";
|
||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||
import TraceTimeline from "@/components/agent/TraceTimeline.vue";
|
||
import CodeAgentStatusSummary from "@/components/workbench/CodeAgentStatusSummary.vue";
|
||
import MessageActions from "@/components/workbench/MessageActions.vue";
|
||
import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue";
|
||
import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue";
|
||
import { workbenchTraceTimelinePolicy } from "@/config/runtime";
|
||
import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
|
||
import { useClipboard } from "@/composables/useClipboard";
|
||
import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker";
|
||
import { useWorkbenchStore } from "@/stores/workbench";
|
||
import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance";
|
||
import { traceIdentityText } from "./message-rendering";
|
||
import { traceLifecycleExpanded } from "./trace-lifecycle";
|
||
|
||
const workbench = useWorkbenchStore();
|
||
const clipboard = useClipboard();
|
||
const panelRef = ref<HTMLElement | null>(null);
|
||
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 });
|
||
const detailMessageId = ref<string | null>(null);
|
||
const displayedDurationFloorMs = new Map<string, number>();
|
||
const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages);
|
||
const hasRunningAgentMessages = computed(() => visibleMessages.value.some(isRunningMessage));
|
||
const { nowMs } = useWorkbenchNowTicker(hasRunningAgentMessages);
|
||
const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null);
|
||
const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy());
|
||
const scrollSignature = computed(() => workbench.activeMessages.map((message) => [
|
||
message.id,
|
||
message.status,
|
||
visibleMessageText(message).length,
|
||
message.updatedAt ?? "",
|
||
message.runnerTrace?.eventCount ?? message.runnerTrace?.events?.length ?? 0,
|
||
message.runnerTrace?.status ?? ""
|
||
].join(":")));
|
||
|
||
watch(scrollSignature, async () => {
|
||
await keepBottomAfterUpdate();
|
||
acknowledgeVisibleMessages();
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await nextTick();
|
||
scrollToBottom();
|
||
acknowledgeVisibleMessages();
|
||
});
|
||
|
||
watch(() => workbench.sessionDetailLoading, async () => {
|
||
await nextTick();
|
||
acknowledgeVisibleMessages();
|
||
});
|
||
|
||
watch(visibleMessages, (messages) => {
|
||
const visibleKeys = new Set(messages.map(messageDurationFloorKey));
|
||
for (const key of Array.from(displayedDurationFloorMs.keys())) {
|
||
if (!visibleKeys.has(key)) displayedDurationFloorMs.delete(key);
|
||
}
|
||
}, { immediate: true });
|
||
|
||
function openDetails(message: ChatMessage): void {
|
||
detailMessageId.value = message.id;
|
||
}
|
||
|
||
function isRunningMessage(message: ChatMessage): boolean {
|
||
return message.role === "agent" && ["pending", "running"].includes(String(message.status ?? "").toLowerCase());
|
||
}
|
||
|
||
function isTerminalAgentMessage(message: ChatMessage): boolean {
|
||
if (message.role !== "agent") return false;
|
||
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
|
||
}
|
||
|
||
function isCompletedAgentMessage(message: ChatMessage): boolean {
|
||
if (message.role !== "agent") return false;
|
||
return [message.status, message.runnerTrace?.status].some((value) => String(value ?? "").trim().toLowerCase().replace(/_/gu, "-") === "completed");
|
||
}
|
||
|
||
function hasSealedCompletedText(message: ChatMessage): boolean {
|
||
return isCompletedAgentMessage(message) && Boolean(visibleMessageText(message));
|
||
}
|
||
|
||
function visibleMessageText(message: ChatMessage): string {
|
||
const text = String(message.text ?? "").trim();
|
||
if (message.role === "agent" && !isTerminalAgentMessage(message)) return "";
|
||
return text;
|
||
}
|
||
|
||
function showStatusBadge(message: ChatMessage): boolean {
|
||
return message.role !== "agent" || !isRunningMessage(message);
|
||
}
|
||
|
||
function isAwaitingAgentBody(message: ChatMessage): boolean {
|
||
return isRunningMessage(message) && !message.runnerTrace && !visibleMessageText(message);
|
||
}
|
||
|
||
function showMessageText(message: ChatMessage): boolean {
|
||
return Boolean(visibleMessageText(message));
|
||
}
|
||
|
||
function messageDiagnosticText(message: ChatMessage): string | null {
|
||
if (message.role === "agent") return projectionDiagnosticText(message);
|
||
if (message.role !== "user" || String(message.text ?? "").trim()) return null;
|
||
return "消息内容缺失:Workbench read model 未返回 user text。";
|
||
}
|
||
|
||
function messageHasDiagnostic(message: ChatMessage): boolean {
|
||
if (hasSealedCompletedText(message)) return false;
|
||
return Boolean(messageDiagnosticText(message) || messageApiError(message) || messageErrorDiagnostic(message));
|
||
}
|
||
|
||
function projectionDiagnosticText(message: ChatMessage): string | null {
|
||
if (hasSealedCompletedText(message)) return null;
|
||
const projection = messageProjection(message);
|
||
const health = projection?.projectionHealth ?? message.projectionHealth ?? message.runnerTrace?.projectionHealth;
|
||
if (!projection || health === "caught-up") return null;
|
||
return firstNonEmptyString(projection.blocker?.userMessage, projection.blocker?.message, projection.blocker?.summary, projection.blocker?.code ? `状态更新异常:${projection.blocker.code}` : null);
|
||
}
|
||
|
||
function messageProjection(message: ChatMessage): ChatMessage["projection"] {
|
||
return message.projection ?? message.runnerTrace?.projection ?? null;
|
||
}
|
||
|
||
function messageApiError(message: ChatMessage): ApiError | null {
|
||
if (hasSealedCompletedText(message)) return null;
|
||
const projection = messageProjection(message);
|
||
const blocker = recordValue(projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker);
|
||
const error = recordValue(message.error);
|
||
const apiError = recordValue(projection?.apiError ?? error?.apiError);
|
||
const diagnostic = messageErrorDiagnostic(message);
|
||
if (!projectionDiagnosticText(message) && !blocker && !error && !apiError && !diagnostic) return null;
|
||
const source = apiError ?? error ?? blocker ?? {};
|
||
const messageText = firstNonEmptyString(source.userMessage, source.message, blocker?.userMessage, blocker?.message, blocker?.summary, diagnostic?.code ? String(diagnostic.code) : null, messageDiagnosticText(message));
|
||
return {
|
||
...source,
|
||
message: messageText ?? "Workbench 诊断",
|
||
userMessage: firstNonEmptyString(source.userMessage, blocker?.userMessage, messageText),
|
||
code: firstValue(source.code, blocker?.code, diagnostic?.code),
|
||
retryable: firstBooleanValue(source.retryable, blocker?.retryable, diagnostic?.retryable),
|
||
layer: firstNonEmptyString(source.layer, blocker?.layer, diagnostic?.layer),
|
||
category: firstNonEmptyString(source.category, blocker?.category, diagnostic?.category),
|
||
route: firstNonEmptyString(source.route, blocker?.route, diagnostic?.route),
|
||
traceId: firstNonEmptyString(source.traceId, blocker?.traceId, diagnostic?.traceId, message.traceId, message.runnerTrace?.traceId),
|
||
requestId: firstNonEmptyString(source.requestId, blocker?.requestId, diagnostic?.requestId),
|
||
source: firstNonEmptyString(source.source, blocker?.source, diagnostic?.source),
|
||
diagnostic,
|
||
valuesPrinted: source.valuesPrinted === true || blocker?.valuesPrinted === true
|
||
} as ApiError;
|
||
}
|
||
|
||
function messageErrorDiagnostic(message: ChatMessage): ErrorDiagnostic | null {
|
||
if (hasSealedCompletedText(message)) return null;
|
||
const projection = messageProjection(message);
|
||
const blocker = recordValue(projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker);
|
||
const error = recordValue(message.error);
|
||
const apiError = recordValue(projection?.apiError ?? error?.apiError);
|
||
if (!projectionDiagnosticText(message) && !blocker && !error && !apiError && !projection?.diagnostic) return null;
|
||
const nested = normalizeErrorDiagnostic(projection?.diagnostic, blocker?.diagnostic, error?.diagnostic, apiError?.diagnostic);
|
||
if (nested) return nested;
|
||
const code = firstValue(error?.code, apiError?.code, blocker?.code);
|
||
const traceId = firstNonEmptyString(error?.traceId, apiError?.traceId, blocker?.traceId, message.traceId, message.runnerTrace?.traceId);
|
||
const requestId = firstNonEmptyString(error?.requestId, apiError?.requestId, blocker?.requestId);
|
||
const route = firstNonEmptyString(error?.route, apiError?.route, blocker?.route);
|
||
const layer = firstNonEmptyString(error?.layer, apiError?.layer, blocker?.layer);
|
||
const category = firstNonEmptyString(error?.category, apiError?.category, blocker?.category);
|
||
const source = firstNonEmptyString(error?.source, apiError?.source, blocker?.source);
|
||
const httpStatus = firstNumber(error?.httpStatus, apiError?.httpStatus, blocker?.httpStatus, error?.providerStatus);
|
||
if (!code && !traceId && !requestId && !route && !layer && !category && !source && httpStatus === null) return null;
|
||
return { contractVersion: "hwlab-error-diagnostic-v1", traceId, requestId, route, layer, category, code, httpStatus, source, retryable: firstBooleanValue(error?.retryable, apiError?.retryable, blocker?.retryable), valuesPrinted: false };
|
||
}
|
||
|
||
function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null {
|
||
for (const value of values) {
|
||
const record = recordValue(value);
|
||
if (!record) continue;
|
||
return {
|
||
...record,
|
||
contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"),
|
||
traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id),
|
||
requestId: firstNonEmptyString(record.requestId, record.request_id),
|
||
serviceId: firstNonEmptyString(record.serviceId, record.service_id),
|
||
route: firstNonEmptyString(record.route),
|
||
layer: firstNonEmptyString(record.layer),
|
||
category: firstNonEmptyString(record.category),
|
||
code: firstValue(record.code),
|
||
httpStatus: firstNumber(record.httpStatus, record.http_status),
|
||
source: firstNonEmptyString(record.source),
|
||
observedAt: firstNonEmptyString(record.observedAt, record.observed_at),
|
||
retryable: firstBooleanValue(record.retryable),
|
||
valuesPrinted: record.valuesPrinted === true
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||
}
|
||
|
||
function firstNonEmptyString(...values: unknown[]): string | null {
|
||
for (const value of values) {
|
||
if (typeof value !== "string") continue;
|
||
const text = value.trim();
|
||
if (text) return text;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function firstValue(...values: unknown[]): string | number | null {
|
||
for (const value of values) {
|
||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||
if (typeof value === "string" && value.trim()) return value.trim();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function firstNumber(...values: unknown[]): number | null {
|
||
for (const value of values) {
|
||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function firstBooleanValue(...values: unknown[]): boolean | null {
|
||
for (const value of values) if (typeof value === "boolean") return value;
|
||
return null;
|
||
}
|
||
|
||
function acknowledgeVisibleMessages(): void {
|
||
acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.activeMessages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading });
|
||
}
|
||
|
||
function traceStorageKey(message: ChatMessage): string {
|
||
return `hwlab.workbench.trace-open.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
|
||
}
|
||
|
||
function traceAutoExpanded(message: ChatMessage): boolean | null {
|
||
return traceLifecycleExpanded(message, traceTimelinePolicy.value);
|
||
}
|
||
|
||
function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] {
|
||
if (!message.runnerTrace) return message.runnerTrace;
|
||
const status = firstNonEmptyString(message.status, message.runnerTrace.status);
|
||
if (!status || status === message.runnerTrace.status) return message.runnerTrace;
|
||
return { ...message.runnerTrace, status };
|
||
}
|
||
|
||
function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null {
|
||
if (message.role !== "agent") return null;
|
||
const timing = messageTimingForDisplay(message);
|
||
const rawDurationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : terminalMessageDurationMs(message, timing);
|
||
if (rawDurationMs === null) return null;
|
||
const durationMs = isRunningMessage(message) ? monotonicDisplayDurationMs(message, rawDurationMs) : sealedDisplayDurationMs(message, rawDurationMs);
|
||
const value = formatDuration(durationMs);
|
||
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
|
||
}
|
||
|
||
function monotonicDisplayDurationMs(message: ChatMessage, durationMs: number): number {
|
||
const key = messageDurationFloorKey(message);
|
||
const previous = displayedDurationFloorMs.get(key);
|
||
const next = previous === undefined ? durationMs : Math.max(previous, durationMs);
|
||
if (next !== previous) displayedDurationFloorMs.set(key, next);
|
||
return next;
|
||
}
|
||
|
||
function sealedDisplayDurationMs(message: ChatMessage, durationMs: number): number {
|
||
displayedDurationFloorMs.delete(messageDurationFloorKey(message));
|
||
return durationMs;
|
||
}
|
||
|
||
function messageDurationFloorKey(message: ChatMessage): string {
|
||
return firstNonEmptyString(message.id, message.runnerTrace?.traceId, message.traceId) ?? "unknown-agent-message";
|
||
}
|
||
|
||
function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null {
|
||
if (!isRunningMessage(message)) return null;
|
||
const timing = messageTimingForDisplay(message);
|
||
const ageMs = durationSince(timing?.lastEventAt);
|
||
if (ageMs === null) return null;
|
||
const value = formatDuration(ageMs) + "前";
|
||
return { text: `最近 ${value}`, label: `最近事件:${value}` };
|
||
}
|
||
|
||
function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
|
||
if (!isRunningMessage(message)) return message.timing ?? message.runnerTrace?.timing ?? null;
|
||
return message.runnerTrace?.timing ?? message.timing ?? null;
|
||
}
|
||
|
||
function terminalMessageDurationMs(message: ChatMessage, timing: ChatMessage["timing"]): number | null {
|
||
return finiteDurationMs(message.durationMs) ?? finiteDurationMs(timing?.durationMs) ?? finiteDurationMs(message.runnerTrace?.durationMs);
|
||
}
|
||
|
||
function durationSince(timestamp: unknown): number | null {
|
||
const startedAtMs = timestampMs(timestamp);
|
||
return startedAtMs === null ? null : Math.max(0, nowMs.value - startedAtMs);
|
||
}
|
||
|
||
function timestampMs(value: unknown): number | null {
|
||
if (typeof value !== "string") return null;
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return null;
|
||
const parsed = Date.parse(trimmed);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function finiteDurationMs(value: unknown): number | null {
|
||
const number = Number(value);
|
||
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
|
||
}
|
||
|
||
function formatDuration(ms: number): string {
|
||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||
if (seconds < 60) return `${seconds} 秒`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`;
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return `${hours} 小时 ${minutes % 60} 分`;
|
||
const days = Math.floor(hours / 24);
|
||
return `${days} 天 ${hours % 24} 小时`;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section id="conversation-list" ref="panelRef" class="conversation-panel" :data-following="following ? 'true' : 'false'" @scroll="onPanelScroll">
|
||
<LoadingState v-if="workbench.sessionDetailLoading" class="conversation-detail-loading" label="加载中" />
|
||
<article
|
||
v-for="message in visibleMessages"
|
||
:key="message.id"
|
||
class="message-card"
|
||
:data-role="message.role"
|
||
:data-status="message.status"
|
||
:data-session-id="message.sessionId || undefined"
|
||
:data-message-id="message.id || undefined"
|
||
:data-trace-id="message.traceId || message.runnerTrace?.traceId || undefined"
|
||
:data-turn-id="message.turnId || message.traceId || message.runnerTrace?.traceId || undefined"
|
||
>
|
||
<header v-if="message.role !== 'user'">
|
||
<div class="message-header-main">
|
||
<strong>{{ message.title }}</strong>
|
||
<span v-if="messageDurationMeta(message)" class="message-timing-meta message-duration-meta" :aria-label="messageDurationMeta(message)?.label" :title="messageDurationMeta(message)?.label">{{ messageDurationMeta(message)?.text }}</span>
|
||
</div>
|
||
<div class="message-header-actions">
|
||
<span v-if="messageActivityMeta(message)" class="message-activity-meta" :aria-label="messageActivityMeta(message)?.label" :title="messageActivityMeta(message)?.label">{{ messageActivityMeta(message)?.text }}</span>
|
||
<span v-if="isRunningMessage(message)" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
|
||
<StatusBadge v-if="showStatusBadge(message)" :status="message.status" />
|
||
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(message)">!</button>
|
||
</div>
|
||
</header>
|
||
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="traceForDisplay(message)" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" />
|
||
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
|
||
<MessageMarkdown v-if="showMessageText(message)" class="message-text" :source="visibleMessageText(message)" />
|
||
<ApiErrorDiagnostic v-if="messageHasDiagnostic(message)" class="message-diagnostic projection-diagnostic" :error="messageDiagnosticText(message)" :api-error="messageApiError(message)" :diagnostic="messageErrorDiagnostic(message)" compact />
|
||
</article>
|
||
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败:{{ workbench.error }}</div>
|
||
<div v-else-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0" class="conversation-empty-hint">发起对话,或从左侧选择 session。</div>
|
||
<div v-if="detailMessage" class="workbench-dialog-backdrop" role="presentation" @click.self="detailMessageId = null">
|
||
<section id="message-detail-dialog" class="workbench-dialog wide" role="dialog" aria-modal="true" aria-labelledby="message-detail-title">
|
||
<header>
|
||
<div class="dialog-title-stack">
|
||
<h2 id="message-detail-title">消息详情</h2>
|
||
<p>{{ detailMessage.traceId || detailMessage.runnerTrace?.traceId || 'trace 未观测' }}</p>
|
||
</div>
|
||
<button type="button" class="dialog-close" aria-label="关闭" @click="detailMessageId = null">x</button>
|
||
</header>
|
||
<CodeAgentStatusSummary :message="detailMessage" />
|
||
<ApiErrorDiagnostic v-if="messageHasDiagnostic(detailMessage)" class="message-detail-diagnostic" title="诊断" :error="messageDiagnosticText(detailMessage)" :api-error="messageApiError(detailMessage)" :diagnostic="messageErrorDiagnostic(detailMessage)" compact />
|
||
<MessageTraceDebugPanel :message="detailMessage" />
|
||
<MessageActions
|
||
:message="detailMessage"
|
||
@cancel="workbench.cancelAgentMessage"
|
||
@retry="workbench.retryAgentMessage"
|
||
@copy-trace="(target: ChatMessage) => clipboard.copy(traceIdentityText(target))"
|
||
/>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</template>
|