Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue
T

385 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>