118 lines
5.7 KiB
Vue
118 lines
5.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||
import type { ChatMessage } from "@/types";
|
||
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 { useClipboard } from "@/composables/useClipboard";
|
||
import { useWorkbenchStore } from "@/stores/workbench";
|
||
import { traceIdentityText } from "./message-rendering";
|
||
import { traceLifecycleExpanded } from "./trace-lifecycle";
|
||
|
||
const workbench = useWorkbenchStore();
|
||
const clipboard = useClipboard();
|
||
const panelRef = ref<HTMLElement | null>(null);
|
||
const following = ref(true);
|
||
const detailMessageId = ref<string | null>(null);
|
||
const detailMessage = computed(() => workbench.messages.find((message) => message.id === detailMessageId.value) ?? null);
|
||
const scrollSignature = computed(() => workbench.messages.map((message) => [
|
||
message.id,
|
||
message.status,
|
||
message.text?.length ?? 0,
|
||
message.updatedAt ?? "",
|
||
message.runnerTrace?.eventCount ?? message.runnerTrace?.events?.length ?? 0,
|
||
message.runnerTrace?.status ?? ""
|
||
].join(":")));
|
||
|
||
watch(scrollSignature, async () => {
|
||
if (!following.value) return;
|
||
await nextTick();
|
||
scrollToBottom();
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await nextTick();
|
||
scrollToBottom();
|
||
});
|
||
|
||
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 showStatusBadge(message: ChatMessage): boolean {
|
||
return message.role !== "agent" || !isRunningMessage(message);
|
||
}
|
||
|
||
function isAwaitingAgentBody(message: ChatMessage): boolean {
|
||
return isRunningMessage(message) && !message.runnerTrace && !String(message.text ?? "").trim();
|
||
}
|
||
|
||
function showMessageText(message: ChatMessage): boolean {
|
||
return message.role !== "agent" || Boolean(String(message.text ?? "").trim());
|
||
}
|
||
|
||
function onPanelScroll(): void {
|
||
const panel = panelRef.value;
|
||
if (!panel) return;
|
||
following.value = panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 56;
|
||
}
|
||
|
||
function scrollToBottom(): void {
|
||
const panel = panelRef.value;
|
||
if (!panel) return;
|
||
panel.scrollTop = panel.scrollHeight;
|
||
}
|
||
|
||
function traceStorageKey(message: ChatMessage): string {
|
||
return `hwlab.workbench.trace-open.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section id="conversation-list" ref="panelRef" class="conversation-panel" :data-following="following ? 'true' : 'false'" @scroll="onPanelScroll">
|
||
<LoadingState v-if="workbench.conversationDetailLoading" class="conversation-detail-loading" label="加载中" />
|
||
<article v-for="message in workbench.conversationDetailLoading ? [] : workbench.messages" :key="message.id" class="message-card" :data-role="message.role" :data-status="message.status">
|
||
<header v-if="message.role !== 'user'">
|
||
<strong>{{ message.title }}</strong>
|
||
<div class="message-header-actions">
|
||
<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="message.runnerTrace" :auto-expanded="traceLifecycleExpanded(message)" :storage-key="traceStorageKey(message)" />
|
||
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
|
||
<MessageMarkdown v-if="showMessageText(message)" class="message-text" :source="message.text" />
|
||
</article>
|
||
<div v-if="!workbench.conversationDetailLoading && workbench.messages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败:{{ workbench.error }}</div>
|
||
<div v-else-if="!workbench.conversationDetailLoading && workbench.messages.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" />
|
||
<MessageTraceDebugPanel :message="detailMessage" />
|
||
<MessageActions
|
||
:message="detailMessage"
|
||
@cancel="workbench.cancelAgentMessage"
|
||
@retry="workbench.retryAgentMessage"
|
||
@copy-trace="(target: ChatMessage) => clipboard.copy(traceIdentityText(target))"
|
||
/>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</template>
|