487 lines
14 KiB
Vue
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>
|