Files
EasyFlow/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/ChatTimelineItem.vue
陈子默 b13280cf1f fix: 支持模型异常后继续文档对话
- 将本轮文档上下文纳入可持久化的 Agent 用户消息

=- 在异常提示中提供请重试动作并自动发送继续
2026-08-26 22:56:33 +08:00

419 lines
11 KiB
Vue

<script setup lang="ts">
import type {
ChatArtifactLoader,
ChatDocumentLoader,
ChatImageLoader,
ChatTimelineErrorItem,
ChatTimelineItem,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineToolApprovalPayload,
} from './types';
import { computed, ref } from 'vue';
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
import ChatArtifactAttachment from './ChatArtifactAttachment.vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatDocumentAttachments from './ChatDocumentAttachments.vue';
import ChatErrorNotice from './ChatErrorNotice.vue';
import ChatImageAttachments from './ChatImageAttachments.vue';
import ChatKnowledgeCard from './ChatKnowledgeCard.vue';
import ChatMessageToolbar from './ChatMessageToolbar.vue';
import ChatTextBlock from './ChatTextBlock.vue';
import ChatTimelineStatusRow from './ChatTimelineStatusRow.vue';
import ChatToolCard from './ChatToolCard.vue';
const props = defineProps<{
approvalLoading?: boolean;
artifactLoader?: ChatArtifactLoader;
assistantActionsVisible?: boolean;
assistantAvatar?: string;
copyable?: boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader;
errorActionDisabled?: boolean;
errorActionLabel?: string;
imageLoader?: ChatImageLoader;
item: ChatTimelineItem;
regenerable?: boolean;
regenerateDisabled?: boolean;
variantLoading?: boolean;
}>();
const emit = defineEmits<{
approve: [payload: ChatTimelineToolApprovalPayload];
copyMessage: [item: ChatTimelineMessageItem];
errorAction: [item: ChatTimelineErrorItem];
regenerateMessage: [item: ChatTimelineMessageItem];
reject: [payload: ChatTimelineToolApprovalPayload];
selectNextVariant: [item: ChatTimelineMessageItem];
selectPreviousVariant: [item: ChatTimelineMessageItem];
thinkingToggle: [];
}>();
const messageItem = computed(() =>
props.item.type === 'message' ? props.item : undefined,
);
// 流式快照会替换消息对象,用户手动选择需要由稳定的组件实例单独保留。
const thinkingExpandedOverrides = ref<Record<string, boolean>>({});
const alignmentClass = computed(() => {
if (props.item.type === 'message' && props.item.role === 'user') {
return 'is-user';
}
return 'is-assistant';
});
const showAssistantAvatar = computed(
() =>
messageItem.value?.role === 'assistant' && Boolean(props.assistantAvatar),
);
const showStatusAvatar = computed(
() =>
props.item.type === 'status' &&
props.item.presentation !== 'separator' &&
Boolean(props.assistantAvatar),
);
const variantCurrent = computed(() => {
const item = messageItem.value;
return Number(item?.selectedVariantIndex || item?.variantIndex || 1);
});
const variantTotal = computed(() =>
Number(messageItem.value?.variantCount || 1),
);
const showVariantNavigator = computed(() => {
const item = messageItem.value;
return Boolean(
item?.role === 'assistant' &&
item.roundCompleted &&
item.roundId &&
item.switchable !== false &&
variantTotal.value > 1,
);
});
const disabledVariantPrevious = computed(
() => props.variantLoading || variantCurrent.value <= 1,
);
const disabledVariantNext = computed(
() => props.variantLoading || variantCurrent.value >= variantTotal.value,
);
const showToolbar = computed(() => {
const item = messageItem.value;
return Boolean(
item &&
(item.role === 'assistant'
? (props.assistantActionsVisible ?? true) &&
(props.copyable || props.regenerable || showVariantNavigator.value)
: props.copyable),
);
});
function getMessageParts(item: ChatTimelineMessageItem) {
const textParts = item.parts.filter(
(part): part is Extract<ChatTimelineMessagePart, { type: 'text' }> =>
part.type === 'text',
);
if (textParts.length <= 1) {
return item.parts;
}
return [
...item.parts.filter((part) => part.type !== 'text'),
{
content: textParts.map((part) => part.content).join(''),
id: `${item.id}-merged-text`,
type: 'text' as const,
},
];
}
function hasMessageBubble(item: ChatTimelineMessageItem) {
return (
getMessageParts(item).length > 0 || Boolean(item.knowledgeItems?.length)
);
}
function updateThinkingExpanded(partId: string, expanded: boolean) {
const item = messageItem.value;
if (!item) {
return;
}
const part = item.parts.find((current) => current.id === partId);
if (part?.type === 'thinking') {
thinkingExpandedOverrides.value = {
...thinkingExpandedOverrides.value,
[partId]: expanded,
};
emit('thinkingToggle');
part.expanded = expanded;
}
}
function resolveThinkingExpanded(
part: Extract<ChatTimelineMessagePart, { type: 'thinking' }>,
) {
return (
thinkingExpandedOverrides.value[part.id] ??
part.expanded ??
part.status === 'thinking'
);
}
function handleCopyAction() {
const item = messageItem.value;
if (!item || !props.copyAction) {
return false;
}
return props.copyAction(item);
}
</script>
<template>
<div class="chat-timeline-item" :class="alignmentClass">
<div
v-if="messageItem"
class="chat-timeline-item__message-row"
:class="[
`is-${messageItem.role}`,
{
'has-assistant-avatar': showAssistantAvatar,
'has-variant-navigator': showVariantNavigator,
},
]"
>
<div
v-if="showAssistantAvatar"
class="chat-timeline-item__assistant-avatar"
>
<ChatAssistantAvatar :src="assistantAvatar" />
</div>
<div
class="chat-timeline-item__message"
:class="`is-${messageItem.role}`"
>
<ChatImageAttachments
v-if="messageItem.images?.length"
:items="messageItem.images"
:image-loader="imageLoader"
compact
/>
<ChatDocumentAttachments
v-if="messageItem.documents?.length"
:items="messageItem.documents"
:document-loader="documentLoader"
compact
/>
<div
v-if="hasMessageBubble(messageItem)"
class="chat-timeline-item__bubble"
:class="`is-${messageItem.role}`"
>
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
<ChatThinkingBlock
v-if="part.type === 'thinking'"
:content="part.content"
:expanded="resolveThinkingExpanded(part)"
:status="part.status"
class="chat-timeline-item__thinking"
readonly
@update:expanded="updateThinkingExpanded(part.id, $event)"
/>
<ChatTextBlock
v-else
:content="part.content"
:streaming="messageItem.status === 'streaming'"
/>
</template>
<ChatKnowledgeCard
v-if="messageItem.knowledgeItems?.length"
:items="messageItem.knowledgeItems"
/>
</div>
<ChatMessageToolbar
v-if="showToolbar && messageItem"
:align="messageItem.role === 'user' ? 'end' : 'start'"
:allow-copy="copyable"
:allow-regenerate="regenerable"
:copy-action="copyAction ? handleCopyAction : undefined"
:disabled-variant-next="disabledVariantNext"
:disabled-variant-previous="disabledVariantPrevious"
:regenerate-disabled="regenerateDisabled"
:show-variant-navigator="showVariantNavigator"
:variant-current="variantCurrent"
:variant-loading="variantLoading"
:variant-total="variantTotal"
@copy="emit('copyMessage', messageItem)"
@regenerate="emit('regenerateMessage', messageItem)"
@select-next-variant="emit('selectNextVariant', messageItem)"
@select-previous-variant="emit('selectPreviousVariant', messageItem)"
/>
</div>
</div>
<ChatToolCard
v-else-if="item.type === 'tool'"
:item="item"
:loading="approvalLoading"
@approve="emit('approve', $event)"
@reject="emit('reject', $event)"
/>
<ChatArtifactAttachment
v-else-if="item.type === 'artifact'"
:artifact-loader="artifactLoader"
:item="item"
/>
<ChatKnowledgeCard
v-else-if="item.type === 'knowledge'"
:items="item.items"
/>
<div
v-else-if="item.type === 'status'"
class="chat-timeline-item__status-row"
:class="{ 'has-assistant-avatar': showStatusAvatar }"
>
<div v-if="showStatusAvatar" class="chat-timeline-item__assistant-avatar">
<ChatAssistantAvatar :src="assistantAvatar" />
</div>
<ChatTimelineStatusRow :item="item" />
</div>
<ChatErrorNotice
v-else-if="item.type === 'error'"
:action-disabled="errorActionDisabled"
:action-label="errorActionLabel"
:message="item.message"
@action="emit('errorAction', item)"
/>
</div>
</template>
<style scoped>
.chat-timeline-item {
display: flex;
width: 100%;
--chat-assistant-avatar-size: 28px;
--chat-message-max-width: 680px;
}
.chat-timeline-item.is-user {
justify-content: flex-end;
}
.chat-timeline-item.is-assistant {
justify-content: flex-start;
}
.chat-timeline-item__message-row {
display: flex;
min-width: 0;
max-width: min(78%, var(--chat-message-max-width));
}
.chat-timeline-item__message-row.is-user {
justify-content: flex-end;
}
.chat-timeline-item__message-row.is-assistant {
gap: var(--space-2);
align-items: flex-start;
}
.chat-timeline-item__message-row.has-assistant-avatar {
max-width: min(
78%,
calc(
var(--chat-message-max-width) + var(--chat-assistant-avatar-size) +
var(--space-2)
)
);
}
.chat-timeline-item__assistant-avatar {
flex: 0 0 var(--chat-assistant-avatar-size);
width: var(--chat-assistant-avatar-size);
height: var(--chat-assistant-avatar-size);
overflow: hidden;
border-radius: var(--radius-control);
}
.chat-timeline-item__status-row {
display: flex;
gap: var(--space-2);
align-items: flex-start;
min-width: 0;
}
.chat-timeline-item__bubble {
display: flex;
flex-direction: column;
gap: 8px;
width: fit-content;
min-width: 0;
max-width: 100%;
}
.chat-timeline-item__message {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
max-width: var(--chat-message-max-width);
}
.chat-timeline-item__message.is-user {
align-items: flex-end;
color: var(--el-text-color-primary);
}
.chat-timeline-item__message.is-assistant,
.chat-timeline-item__message.is-system {
align-items: flex-start;
}
.chat-timeline-item__message :deep(.chat-text-block),
.chat-timeline-item__message :deep(.chat-thinking-block) {
padding: 0;
color: var(--el-text-color-primary);
background: transparent;
border: 0;
}
.chat-timeline-item__message :deep(.chat-text-block) {
font-size: 14px;
line-height: 22px;
}
.chat-timeline-item__message-row.has-variant-navigator {
width: min(78%, var(--chat-message-max-width));
}
.chat-timeline-item__message-row.has-assistant-avatar.has-variant-navigator {
width: min(
78%,
calc(
var(--chat-message-max-width) + var(--chat-assistant-avatar-size) +
var(--space-2)
)
);
}
.chat-timeline-item__message-row.has-assistant-avatar.has-variant-navigator
.chat-timeline-item__message {
width: calc(100% - var(--chat-assistant-avatar-size) - var(--space-2));
}
.chat-timeline-item__bubble.is-assistant,
.chat-timeline-item__bubble.is-system {
color: var(--el-text-color-primary);
}
.chat-timeline-item__thinking {
margin-bottom: 0;
}
</style>