499 lines
12 KiB
Vue
499 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
|
|
|
import { computed, ref } from 'vue';
|
|
|
|
import { ChatThinkingBlock, ChatTimeMarkdown } from '@easyflow/common-ui';
|
|
import { IconifyIcon } from '@easyflow/icons';
|
|
import { useUserStore } from '@easyflow/stores';
|
|
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
CircleCheck,
|
|
CopyDocument,
|
|
Loading,
|
|
RefreshRight,
|
|
} from '@element-plus/icons-vue';
|
|
import { ElAvatar, ElIcon, ElMessage } from 'element-plus';
|
|
|
|
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
|
|
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
|
|
import ShowJson from '#/components/json/ShowJson.vue';
|
|
|
|
interface Props {
|
|
allowRegenerate?: boolean;
|
|
allowVariantSwitch?: boolean;
|
|
bot: any;
|
|
messages: ChatTimeTimelineItem[];
|
|
regenerateDisabled?: boolean;
|
|
switchingRoundIds?: string[];
|
|
variantSwitchDisabled?: boolean;
|
|
}
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
allowRegenerate: false,
|
|
allowVariantSwitch: false,
|
|
regenerateDisabled: false,
|
|
switchingRoundIds: () => [],
|
|
variantSwitchDisabled: false,
|
|
});
|
|
const store = useUserStore();
|
|
const expandedToolState = ref<Record<string, boolean>>({});
|
|
const latestAssistantMessageId = computed(() => {
|
|
return [...props.messages].reverse().find((item) => item.role === 'assistant')?.id || '';
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
regenerate: [item: ChatTimeTimelineItem];
|
|
selectNextVariant: [item: ChatTimeTimelineItem];
|
|
selectPreviousVariant: [item: ChatTimeTimelineItem];
|
|
}>();
|
|
|
|
function getAssistantAvatar() {
|
|
return props.bot.icon || defaultAssistantAvatar;
|
|
}
|
|
function getUserAvatar() {
|
|
return store.userInfo?.avatar || defaultUserAvatar;
|
|
}
|
|
|
|
function formatTime(value: number | string) {
|
|
if (typeof value === 'number') {
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
return value || '';
|
|
}
|
|
|
|
function getToolName(item: ChatTimeTimelineItem) {
|
|
return item.role === 'tool' && item.name ? item.name : '工具调用';
|
|
}
|
|
|
|
function hasToolDetails(item: ChatTimeTimelineItem) {
|
|
return item.role === 'tool' && Boolean(item.arguments || item.result);
|
|
}
|
|
|
|
function isToolExpanded(item: ChatTimeTimelineItem) {
|
|
return item.role === 'tool' && Boolean(expandedToolState.value[item.id]);
|
|
}
|
|
|
|
function toggleToolExpanded(item: ChatTimeTimelineItem) {
|
|
if (item.role !== 'tool' || !hasToolDetails(item)) {
|
|
return;
|
|
}
|
|
expandedToolState.value = {
|
|
...expandedToolState.value,
|
|
[item.id]: !expandedToolState.value[item.id],
|
|
};
|
|
}
|
|
|
|
function canCopy(item: ChatTimeTimelineItem) {
|
|
return item.role !== 'tool' && Boolean(String(item.content || '').trim());
|
|
}
|
|
|
|
function canRegenerate(item: ChatTimeTimelineItem) {
|
|
return (
|
|
props.allowRegenerate &&
|
|
item.role === 'assistant' &&
|
|
item.id === latestAssistantMessageId.value
|
|
);
|
|
}
|
|
|
|
function shouldShowVariantNavigator(item: ChatTimeTimelineItem) {
|
|
return (
|
|
props.allowVariantSwitch &&
|
|
item.role === 'assistant' &&
|
|
isFinalAssistantInRound(item) &&
|
|
Number(item.variantCount || 0) > 1 &&
|
|
Boolean(item.roundId)
|
|
);
|
|
}
|
|
|
|
function canSwitchVariant(
|
|
item: ChatTimeTimelineItem,
|
|
direction: 'next' | 'previous',
|
|
) {
|
|
if (
|
|
item.role !== 'assistant' ||
|
|
!isFinalAssistantInRound(item) ||
|
|
props.variantSwitchDisabled ||
|
|
isVariantSwitching(item) ||
|
|
!item.switchable
|
|
) {
|
|
return false;
|
|
}
|
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
|
const total = Number(item.variantCount || 1);
|
|
if (direction === 'previous') {
|
|
return current > 1;
|
|
}
|
|
return current < total;
|
|
}
|
|
|
|
function isVariantSwitching(item: ChatTimeTimelineItem) {
|
|
return Boolean(
|
|
item.roundId && props.switchingRoundIds.includes(String(item.roundId)),
|
|
);
|
|
}
|
|
|
|
function isFinalAssistantInRound(item: ChatTimeTimelineItem) {
|
|
if (item.role !== 'assistant') {
|
|
return false;
|
|
}
|
|
if (!item.roundId) {
|
|
return true;
|
|
}
|
|
for (let index = props.messages.length - 1; index >= 0; index -= 1) {
|
|
const candidate = props.messages[index];
|
|
if (
|
|
candidate?.role === 'assistant' &&
|
|
candidate.roundId === item.roundId &&
|
|
String(candidate.variantIndex || '') === String(item.variantIndex || '')
|
|
) {
|
|
return candidate.id === item.id;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function handleCopy(item: ChatTimeTimelineItem) {
|
|
if (!canCopy(item)) {
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(String(item.content || ''));
|
|
ElMessage.success('已复制');
|
|
} catch {
|
|
ElMessage.error('复制失败');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<ElBubbleList :list="messages" max-height="calc(100vh - 345px)">
|
|
<!-- 自定义头像 -->
|
|
<template #avatar="{ item }">
|
|
<ElAvatar
|
|
:src="item.role === 'user' ? getUserAvatar() : getAssistantAvatar()"
|
|
:size="40"
|
|
/>
|
|
</template>
|
|
|
|
<!-- 自定义头部 -->
|
|
<template #header="{ item }">
|
|
<div class="flex flex-col">
|
|
<span class="text-foreground/50 text-xs">
|
|
{{ formatTime(item.created) }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 自定义气泡内容 -->
|
|
<template #content="{ item }">
|
|
<template v-if="item.role === 'assistant'">
|
|
<div class="flex flex-col gap-2">
|
|
<template v-for="segment in item.segments" :key="segment.id">
|
|
<ChatThinkingBlock
|
|
v-if="segment.type === 'thinking'"
|
|
v-model:expanded="segment.expanded"
|
|
:content="segment.content"
|
|
:status="segment.status"
|
|
class="chat-thinking-block-item"
|
|
/>
|
|
<ChatTimeMarkdown v-else :content="segment.content" />
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="item.role === 'tool'">
|
|
<div class="chat-tool-panel" :class="{ 'is-expanded': isToolExpanded(item) }">
|
|
<button
|
|
type="button"
|
|
class="chat-tool-header"
|
|
:class="{ 'is-clickable': hasToolDetails(item) }"
|
|
@click="toggleToolExpanded(item)"
|
|
>
|
|
<div class="chat-tool-title">
|
|
<ElIcon size="14">
|
|
<IconifyIcon icon="svg:wrench" />
|
|
</ElIcon>
|
|
<span class="chat-tool-title-text">{{ getToolName(item) }}</span>
|
|
</div>
|
|
<div class="chat-tool-meta">
|
|
<span
|
|
class="chat-tool-status"
|
|
:class="item.status === 'TOOL_CALL' ? 'is-calling' : 'is-done'"
|
|
>
|
|
<ElIcon size="13">
|
|
<IconifyIcon
|
|
v-if="item.status === 'TOOL_CALL'"
|
|
icon="mdi:clock-time-five-outline"
|
|
/>
|
|
<CircleCheck v-else />
|
|
</ElIcon>
|
|
<span>{{ item.status === 'TOOL_CALL' ? '调用中' : '已完成' }}</span>
|
|
</span>
|
|
<ElIcon
|
|
v-if="hasToolDetails(item)"
|
|
size="14"
|
|
class="chat-tool-arrow"
|
|
:class="{ 'is-expanded': isToolExpanded(item) }"
|
|
>
|
|
<IconifyIcon icon="ep:arrow-right" />
|
|
</ElIcon>
|
|
</div>
|
|
</button>
|
|
<div v-if="isToolExpanded(item) && hasToolDetails(item)" class="chat-tool-body">
|
|
<ShowJson
|
|
v-if="item.arguments"
|
|
:value="item.arguments"
|
|
plain
|
|
/>
|
|
<ShowJson
|
|
v-if="item.result"
|
|
:value="item.result"
|
|
plain
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<ChatTimeMarkdown v-else :content="item.content" />
|
|
</template>
|
|
|
|
<template #footer="{ item }">
|
|
<div
|
|
v-if="canCopy(item) || canRegenerate(item) || shouldShowVariantNavigator(item)"
|
|
class="chat-message-actions"
|
|
:class="{ 'is-user': item.role === 'user' }"
|
|
>
|
|
<div
|
|
v-if="shouldShowVariantNavigator(item)"
|
|
class="chat-message-actions__variant"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="chat-message-actions__button"
|
|
:disabled="!canSwitchVariant(item, 'previous')"
|
|
aria-label="查看上一版答案"
|
|
@click="emit('selectPreviousVariant', item)"
|
|
>
|
|
<Loading v-if="isVariantSwitching(item)" class="is-loading" />
|
|
<ArrowLeft v-else />
|
|
</button>
|
|
<span class="chat-message-actions__variant-label">
|
|
{{ Number(item.variantIndex || item.selectedVariantIndex || 1) }}/{{
|
|
Number(item.variantCount || 1)
|
|
}}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="chat-message-actions__button"
|
|
:disabled="!canSwitchVariant(item, 'next')"
|
|
aria-label="查看下一版答案"
|
|
@click="emit('selectNextVariant', item)"
|
|
>
|
|
<Loading v-if="isVariantSwitching(item)" class="is-loading" />
|
|
<ArrowRight v-else />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
v-if="canRegenerate(item)"
|
|
type="button"
|
|
class="chat-message-actions__button"
|
|
:disabled="regenerateDisabled"
|
|
aria-label="重新生成"
|
|
@click="emit('regenerate', item)"
|
|
>
|
|
<RefreshRight />
|
|
</button>
|
|
|
|
<button
|
|
v-if="canCopy(item)"
|
|
type="button"
|
|
class="chat-message-actions__button"
|
|
aria-label="复制消息"
|
|
@click="handleCopy(item)"
|
|
>
|
|
<CopyDocument />
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</ElBubbleList>
|
|
</template>
|
|
|
|
<style lang="css" scoped>
|
|
:deep(.el-bubble-header) {
|
|
width: 100%;
|
|
}
|
|
|
|
:deep(.el-bubble-end .el-bubble-header) {
|
|
width: fit-content;
|
|
}
|
|
|
|
:deep(.el-bubble-content-wrapper .el-bubble-content) {
|
|
--bubble-content-max-width: 100%;
|
|
}
|
|
|
|
.chat-thinking-block-item {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.chat-tool-panel {
|
|
min-width: 0;
|
|
background: hsl(var(--surface-panel) / 0.98);
|
|
border: 1px solid hsl(var(--divider-faint) / 0.16);
|
|
border-radius: 10px;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.chat-tool-header {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
min-height: 34px;
|
|
padding: 0 10px;
|
|
text-align: left;
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
|
|
.chat-tool-header.is-clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chat-tool-title {
|
|
display: inline-flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
min-width: 0;
|
|
color: hsl(var(--text-strong));
|
|
}
|
|
|
|
.chat-tool-title-text {
|
|
overflow: hidden;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.chat-tool-meta {
|
|
display: inline-flex;
|
|
flex-shrink: 0;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
|
|
.chat-tool-status {
|
|
display: inline-flex;
|
|
flex-shrink: 0;
|
|
gap: 4px;
|
|
align-items: center;
|
|
padding: 1px 8px;
|
|
font-size: 11px;
|
|
line-height: 1.4;
|
|
border: 1px solid transparent;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.chat-tool-status.is-calling {
|
|
color: hsl(var(--primary));
|
|
background: hsl(var(--primary) / 0.08);
|
|
border-color: hsl(var(--primary) / 0.12);
|
|
}
|
|
|
|
.chat-tool-status.is-done {
|
|
color: hsl(var(--success));
|
|
background: hsl(var(--success) / 0.08);
|
|
border-color: hsl(var(--success) / 0.12);
|
|
}
|
|
|
|
.chat-tool-arrow {
|
|
color: hsl(var(--text-muted));
|
|
transition: transform 0.15s ease;
|
|
}
|
|
|
|
.chat-tool-arrow.is-expanded {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.chat-tool-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding: 0 10px 10px;
|
|
}
|
|
|
|
.chat-message-actions {
|
|
display: inline-flex;
|
|
gap: 4px;
|
|
align-items: center;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.chat-message-actions.is-user {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.chat-message-actions__variant {
|
|
display: inline-flex;
|
|
gap: 2px;
|
|
align-items: center;
|
|
}
|
|
|
|
.chat-message-actions__variant-label {
|
|
min-width: 42px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
color: hsl(var(--text-muted));
|
|
}
|
|
|
|
.chat-message-actions__button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
padding: 0;
|
|
color: hsl(var(--text-muted));
|
|
cursor: pointer;
|
|
border: none;
|
|
border-radius: 999px;
|
|
background: transparent;
|
|
transition:
|
|
color 0.18s ease,
|
|
background-color 0.18s ease;
|
|
}
|
|
|
|
.chat-message-actions__button:hover:not(:disabled) {
|
|
color: hsl(var(--text-strong));
|
|
background: hsl(var(--foreground) / 0.05);
|
|
}
|
|
|
|
.chat-message-actions__button:focus-visible {
|
|
outline: 2px solid hsl(var(--primary) / 0.32);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.chat-message-actions__button:disabled {
|
|
opacity: 0.42;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.chat-message-actions__button :deep(svg) {
|
|
width: 15px;
|
|
height: 15px;
|
|
}
|
|
|
|
.chat-message-actions__button .is-loading {
|
|
animation: chat-action-spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes chat-action-spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|