Files
EasyFlow/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineTurn.vue
陈子默 407b85c8a9 fix: 移除 Timeline 合成处理状态
- 仅依据真实思考、工具和状态事件展示处理过程

- 更新快速工具调用状态测试
2026-08-20 11:24:41 +08:00

487 lines
14 KiB
Vue

<script setup lang="ts">
import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineToolApprovalPayload,
} from './types';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItemView from './ChatTimelineItem.vue';
const props = defineProps<{
actionAnchorId?: string;
approvalLoading?: boolean;
artifactLoader?: ChatArtifactLoader;
assistantAvatar?: string;
copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader;
imageLoader?: ChatImageLoader;
items: ChatTimelineItem[];
regenerable?: (item: ChatTimelineMessageItem) => boolean;
regenerateDisabled?: boolean;
roundId: string;
variantLoading?: (item: ChatTimelineMessageItem) => boolean;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem];
layoutChanged: [];
layoutToggle: [roundId: string];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
selectPreviousVariant: [item: ChatTimelineMessageItem];
}>();
const turnSucceeded = computed(() =>
props.items.some((item) => item.turnSucceeded === true),
);
const latestAssistantContentSource = computed(() =>
[...props.items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(item.parts.some((part) => part.type === 'text' && part.content) ||
Boolean(item.knowledgeItems?.length)),
),
);
const finalMessageSource = computed(() =>
turnSucceeded.value ? latestAssistantContentSource.value : undefined,
);
const finalMessage = computed<ChatTimelineMessageItem | undefined>(() => {
const item = finalMessageSource.value;
if (!item) {
return undefined;
}
return {
...item,
parts: item.parts.filter((part) => part.type === 'text'),
};
});
const processItems = computed<ChatTimelineItem[]>(() => {
const finalSource = finalMessageSource.value;
return props.items.flatMap((item) => {
if (item.type === 'artifact') {
return [];
}
if (
item.type === 'message' &&
item.role === 'assistant' &&
item.parts.length === 0 &&
!item.knowledgeItems?.length &&
!item.images?.length &&
!item.documents?.length
) {
return [];
}
if (item !== finalSource) {
return [item];
}
const thinkingParts = item.parts.filter((part) => part.type === 'thinking');
if (thinkingParts.length === 0) {
return [];
}
return [
{
...item,
id: `${item.id}-process`,
knowledgeItems: undefined,
parts: thinkingParts,
roundCompleted: false,
},
];
});
});
const artifactItems = computed(() =>
props.items.filter((item) => item.type === 'artifact'),
);
const turnStartedAt = computed(() => {
const starts = props.items
.map((item) => item.turnStartedAt)
.filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value),
);
return starts.length > 0 ? Math.min(...starts) : undefined;
});
const turnFinishedAt = computed(() => {
const finishes = props.items
.map((item) => item.turnFinishedAt)
.filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value),
);
return finishes.length > 0 ? Math.max(...finishes) : undefined;
});
const turnFinished = computed(() => turnFinishedAt.value !== undefined);
const turnActive = computed(() => !turnSucceeded.value && !turnFinished.value);
const hasPendingApproval = computed(() =>
props.items.some(
(item) =>
item.type === 'tool' &&
(item.status === 'approving' || item.status === 'pending_approval'),
),
);
const canCollapse = computed(
() =>
turnSucceeded.value &&
Boolean(finalMessage.value) &&
!hasPendingApproval.value,
);
const processId = computed(() => `chat-turn-process-${props.roundId}`);
const expanded = ref(!canCollapse.value);
watch(
canCollapse,
(value, previous) => {
if (value && !previous) {
if (previous === false) {
emit('layoutToggle', props.roundId);
}
expanded.value = false;
if (processItems.value.length === 0) {
void nextTick(() => emit('layoutChanged'));
}
} else if (!value) {
expanded.value = true;
}
},
{ immediate: true },
);
const observedStartedAt = Date.now();
const clockNow = ref(observedStartedAt);
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
function stopElapsedTimer() {
if (elapsedTimer === undefined) {
return;
}
clearInterval(elapsedTimer);
elapsedTimer = undefined;
}
function syncElapsedTimer() {
clockNow.value = Date.now();
stopElapsedTimer();
if (!turnActive.value) {
return;
}
elapsedTimer = setInterval(() => {
clockNow.value = Date.now();
}, 1000);
}
watch([turnStartedAt, turnActive], syncElapsedTimer, { immediate: true });
onBeforeUnmount(stopElapsedTimer);
const durationLabel = computed(() => {
const startedAt =
turnStartedAt.value ?? (turnActive.value ? observedStartedAt : undefined);
const finishedAt =
turnFinishedAt.value ?? (turnActive.value ? clockNow.value : undefined);
if (startedAt === undefined || finishedAt === undefined) {
return '';
}
const seconds = Math.max(1, Math.round((finishedAt - startedAt) / 1000));
if (seconds < 60) {
return `${seconds}`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) {
return `${minutes}${remainingSeconds}`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours} 小时 ${remainingMinutes}${remainingSeconds}`;
});
const summaryLabel = computed(() => {
if (turnSucceeded.value) {
return durationLabel.value ? `已处理 ${durationLabel.value}` : '已处理';
}
if (turnFinished.value) {
return '处理未完成';
}
return `已处理 ${durationLabel.value || '1 秒'}`;
});
function isAssistantActionAnchor(item: ChatTimelineItem) {
return item.type === 'message' && item.id === props.actionAnchorId;
}
function canCopyMessage(item: ChatTimelineItem) {
return item.type === 'message' && (props.copyable?.(item) ?? false);
}
function canRegenerateMessage(item: ChatTimelineItem) {
return item.type === 'message' && (props.regenerable?.(item) ?? false);
}
function isVariantLoading(item: ChatTimelineItem) {
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
}
function isLiveContentAnchor(item: ChatTimelineItem) {
return !turnSucceeded.value && item === latestAssistantContentSource.value;
}
function toggleProcess() {
if (!canCollapse.value) {
return;
}
emit('layoutToggle', props.roundId);
expanded.value = !expanded.value;
if (processItems.value.length === 0) {
void nextTick(() => emit('layoutChanged'));
}
}
function handleNestedLayoutToggle() {
emit('layoutToggle', props.roundId);
void nextTick(() => emit('layoutChanged'));
}
</script>
<template>
<section class="chat-timeline-turn" :data-round-id="roundId">
<header class="chat-timeline-turn__header">
<span class="chat-timeline-turn__avatar">
<ChatAssistantAvatar :src="assistantAvatar" />
</span>
<button
class="chat-timeline-turn__summary"
:class="{ 'is-toggleable': canCollapse }"
:disabled="!canCollapse"
type="button"
:aria-controls="canCollapse ? processId : undefined"
:aria-expanded="canCollapse ? expanded : undefined"
@click="toggleProcess"
>
<span>{{ summaryLabel }}</span>
<svg
v-if="canCollapse"
class="chat-timeline-turn__chevron"
:class="{ 'is-expanded': expanded }"
aria-hidden="true"
viewBox="0 0 16 16"
>
<path d="M5.5 3.5 10 8l-4.5 4.5" />
</svg>
</button>
</header>
<div class="chat-timeline-turn__content">
<Transition
name="chat-turn-process"
@after-enter="emit('layoutChanged')"
@after-leave="emit('layoutChanged')"
>
<div
v-if="expanded && processItems.length > 0"
:id="processId"
class="chat-timeline-turn__process"
>
<template v-for="item in processItems" :key="item.id">
<slot
v-if="item.type === 'custom'"
name="custom-item"
:item="item"
></slot>
<ChatTimelineItemView
v-else
:assistant-actions-visible="isAssistantActionAnchor(item)"
:artifact-loader="artifactLoader"
:data-chat-turn-live-anchor="
isLiveContentAnchor(item) ? '' : undefined
"
:item="item"
:document-loader="documentLoader"
:image-loader="imageLoader"
:approval-loading="approvalLoading"
:copy-action="copyAction"
:copyable="canCopyMessage(item)"
:regenerable="canRegenerateMessage(item)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(item)"
@approve="emit('approve', $event)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@reject="emit('reject', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
@thinking-toggle="handleNestedLayoutToggle"
/>
</template>
</div>
</Transition>
<div v-if="finalMessage" data-chat-turn-final>
<ChatTimelineItemView
:assistant-actions-visible="isAssistantActionAnchor(finalMessage)"
:artifact-loader="artifactLoader"
:item="finalMessage"
:document-loader="documentLoader"
:image-loader="imageLoader"
:copy-action="copyAction"
:copyable="canCopyMessage(finalMessage)"
:regenerable="canRegenerateMessage(finalMessage)"
:regenerate-disabled="regenerateDisabled"
:variant-loading="isVariantLoading(finalMessage)"
@copy-message="emit('copyMessage', $event)"
@regenerate-message="emit('regenerateMessage', $event)"
@select-next-variant="emit('selectNextVariant', $event)"
@select-previous-variant="emit('selectPreviousVariant', $event)"
/>
</div>
<div
v-if="artifactItems.length > 0"
class="chat-timeline-turn__artifacts"
>
<ChatTimelineItemView
v-for="item in artifactItems"
:key="item.id"
:artifact-loader="artifactLoader"
:item="item"
/>
</div>
</div>
</section>
</template>
<style scoped>
.chat-timeline-turn {
--chat-turn-avatar-size: 28px;
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
min-width: 0;
}
.chat-timeline-turn__header {
display: flex;
gap: var(--space-2);
align-items: center;
min-height: var(--chat-turn-avatar-size);
}
.chat-timeline-turn__avatar {
flex: 0 0 var(--chat-turn-avatar-size);
width: var(--chat-turn-avatar-size);
height: var(--chat-turn-avatar-size);
overflow: hidden;
border-radius: var(--radius-control);
}
.chat-timeline-turn__summary {
display: inline-flex;
gap: var(--space-1);
align-items: center;
min-width: 0;
padding: 0;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 20px;
color: var(--el-text-color-secondary);
appearance: none;
cursor: default;
background: transparent;
border: 0;
}
.chat-timeline-turn__summary.is-toggleable {
cursor: pointer;
}
.chat-timeline-turn__summary.is-toggleable:hover {
color: var(--el-text-color-primary);
}
.chat-timeline-turn__summary.is-toggleable:focus-visible {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 3px;
border-radius: var(--radius-control);
}
.chat-timeline-turn__summary:disabled {
opacity: 1;
}
.chat-timeline-turn__chevron {
width: 14px;
height: 14px;
fill: none;
stroke: currentcolor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.chat-timeline-turn__chevron.is-expanded {
transform: rotate(90deg);
}
.chat-timeline-turn__content {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
padding-inline-start: calc(var(--chat-turn-avatar-size) + var(--space-2));
}
.chat-timeline-turn__process {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 0;
}
.chat-timeline-turn__artifacts {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.chat-turn-process-enter-active,
.chat-turn-process-leave-active {
transition:
opacity var(--motion-duration-base) var(--motion-ease-standard),
transform var(--motion-duration-base) var(--motion-ease-standard);
}
.chat-turn-process-enter-from,
.chat-turn-process-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@media (prefers-reduced-motion: reduce) {
.chat-timeline-turn__chevron,
.chat-turn-process-enter-active,
.chat-turn-process-leave-active {
transition: none;
}
}
</style>