419 lines
11 KiB
Vue
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>
|