feat: 统一聊天助手头像

- 工作流运行状态、智能体正式聊天与试运行展示助手头像

- 新增默认机器人头像及消息、空状态回退组件
This commit is contained in:
2026-08-03 11:24:12 +08:00
parent dc99d1aa52
commit e3228837f4
12 changed files with 380 additions and 86 deletions

View File

@@ -24,6 +24,7 @@ import {
ChatImageAttachments, ChatImageAttachments,
ChatTimeline, ChatTimeline,
ChatTimelineBuilder, ChatTimelineBuilder,
defaultAssistantAvatar,
} from '@easyflow/common-ui'; } from '@easyflow/common-ui';
import { copyTextToClipboard } from '@easyflow/utils'; import { copyTextToClipboard } from '@easyflow/utils';
@@ -1240,6 +1241,7 @@ onBeforeUnmount(() => {
/> />
<ChatTimeline <ChatTimeline
v-else v-else
:assistant-avatar="selectedAgent?.avatar || defaultAssistantAvatar"
:items="timelineItems" :items="timelineItems"
:document-loader="loadAgentChatDocument" :document-loader="loadAgentChatDocument"
:image-loader="loadAgentChatImage" :image-loader="loadAgentChatImage"

View File

@@ -12,6 +12,7 @@ import { computed, markRaw, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAccess } from '@easyflow/access'; import { useAccess } from '@easyflow/access';
import { defaultAssistantAvatar } from '@easyflow/common-ui';
import { import {
Check, Check,
@@ -25,7 +26,6 @@ import {
import { ElIcon, ElMessage, ElMessageBox, ElPopover } from 'element-plus'; import { ElIcon, ElMessage, ElMessageBox, ElPopover } from 'element-plus';
import { tryit } from 'radash'; import { tryit } from 'radash';
import defaultAgentAvatar from '#/assets/defaultUserAvatar.png';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue'; import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import PageData from '#/components/page/PageData.vue'; import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue'; import PageSide from '#/components/page/PageSide.vue';
@@ -354,7 +354,7 @@ async function handleDeleteAction(row: AgentInfo) {
<CardList <CardList
title-field="name" title-field="name"
icon-field="avatar" icon-field="avatar"
:default-icon="defaultAgentAvatar" :default-icon="defaultAssistantAvatar"
:data="pageList" :data="pageList"
:primary-action="primaryAction" :primary-action="primaryAction"
:actions="actions" :actions="actions"

View File

@@ -17,6 +17,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { import {
CHAT_DOCUMENT_SUPPORTED_FORMATS, CHAT_DOCUMENT_SUPPORTED_FORMATS,
ChatTimeline, ChatTimeline,
defaultAssistantAvatar,
} from '@easyflow/common-ui'; } from '@easyflow/common-ui';
import { BrushCleaning } from '@easyflow/icons'; import { BrushCleaning } from '@easyflow/icons';
import { copyTextToClipboard } from '@easyflow/utils'; import { copyTextToClipboard } from '@easyflow/utils';
@@ -395,6 +396,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
/> />
<ChatTimeline <ChatTimeline
v-else v-else
:assistant-avatar="agent.avatar || defaultAssistantAvatar"
:items="timelineItems" :items="timelineItems"
:document-loader="loadAgentChatDocument" :document-loader="loadAgentChatDocument"
:image-loader="loadAgentChatImage" :image-loader="loadAgentChatImage"

View File

@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { ref } from 'vue';
import { ChatAssistantAvatar } from '@easyflow/common-ui';
import { ArrowRight } from '@element-plus/icons-vue'; import { ArrowRight } from '@element-plus/icons-vue';
import { useResizeObserver } from '@vueuse/core'; import { useResizeObserver } from '@vueuse/core';
import { ElIcon } from 'element-plus'; import { ElIcon } from 'element-plus';
const props = defineProps<{ defineProps<{
agentName: string; agentName: string;
avatar?: string; avatar?: string;
disabled?: boolean; disabled?: boolean;
@@ -17,11 +19,9 @@ const emit = defineEmits<{
selectQuestion: [question: string]; selectQuestion: [question: string];
}>(); }>();
const avatarFailed = ref(false);
const welcomeRef = ref<HTMLElement>(); const welcomeRef = ref<HTMLElement>();
const legacyCompact = ref(false); const legacyCompact = ref(false);
const headingId = `agent-welcome-${Math.random().toString(36).slice(2, 9)}`; const headingId = `agent-welcome-${Math.random().toString(36).slice(2, 9)}`;
const initial = computed(() => [...props.agentName.trim()][0] || 'A');
const needsContainerQueryFallback = const needsContainerQueryFallback =
typeof CSS === 'undefined' || !CSS.supports('container-type', 'inline-size'); typeof CSS === 'undefined' || !CSS.supports('container-type', 'inline-size');
@@ -29,13 +29,6 @@ useResizeObserver(welcomeRef, ([entry]) => {
if (!needsContainerQueryFallback || !entry) return; if (!needsContainerQueryFallback || !entry) return;
legacyCompact.value = entry.contentRect.width <= 560; legacyCompact.value = entry.contentRect.width <= 560;
}); });
watch(
() => props.avatar,
() => {
avatarFailed.value = false;
},
);
</script> </script>
<template> <template>
@@ -47,13 +40,7 @@ watch(
> >
<div class="agent-welcome__content"> <div class="agent-welcome__content">
<div class="agent-welcome__avatar" aria-hidden="true"> <div class="agent-welcome__avatar" aria-hidden="true">
<img <ChatAssistantAvatar :src="avatar" />
v-if="avatar && !avatarFailed"
:src="avatar"
alt=""
@error="avatarFailed = true"
/>
<span v-else>{{ initial }}</span>
</div> </div>
<h2 :id="headingId" class="agent-welcome__name">{{ agentName }}</h2> <h2 :id="headingId" class="agent-welcome__name">{{ agentName }}</h2>
<p class="agent-welcome__message">{{ welcomeMessage }}</p> <p class="agent-welcome__message">{{ welcomeMessage }}</p>
@@ -112,12 +99,6 @@ watch(
border-radius: var(--radius-panel); border-radius: var(--radius-panel);
} }
.agent-welcome__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.agent-welcome__name { .agent-welcome__name {
margin: var(--space-3) 0 0; margin: var(--space-3) 0 0;
font-size: 16px; font-size: 16px;

View File

@@ -15,7 +15,7 @@ import type {
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { ChatTimeline } from '@easyflow/common-ui'; import { ChatTimeline, defaultAssistantAvatar } from '@easyflow/common-ui';
import { import {
ArrowLeft, ArrowLeft,
@@ -931,8 +931,10 @@ function executionTraceText(
<template v-else> <template v-else>
<ChatTimeline <ChatTimeline
class="workflow-chat__timeline" class="workflow-chat__timeline"
:assistant-avatar="defaultAssistantAvatar"
:items="timelineItems" :items="timelineItems"
:empty-text="emptyText" :empty-text="emptyText"
:empty-title="descriptor.title || '工作流'"
:copy-action="copyMessage" :copy-action="copyMessage"
:copyable="(item) => item.parts.some((part) => part.content)" :copyable="(item) => item.parts.some((part) => part.content)"
> >

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { defaultAssistantAvatar } from './assistantAvatar';
const props = defineProps<{
src?: string;
}>();
const imageFailed = ref(false);
const avatarSrc = computed(() => {
const source = props.src?.trim();
return imageFailed.value || !source ? defaultAssistantAvatar : source;
});
watch(
() => props.src,
() => {
imageFailed.value = false;
},
);
function handleImageError() {
if (avatarSrc.value !== defaultAssistantAvatar) {
imageFailed.value = true;
}
}
</script>
<template>
<span class="chat-assistant-avatar" aria-hidden="true">
<img :src="avatarSrc" alt="" @error="handleImageError" />
</span>
</template>
<style scoped>
.chat-assistant-avatar,
.chat-assistant-avatar img {
display: block;
width: 100%;
height: 100%;
}
.chat-assistant-avatar {
overflow: hidden;
border-radius: inherit;
}
.chat-assistant-avatar img {
object-fit: cover;
}
</style>

View File

@@ -9,14 +9,17 @@ import type {
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatTimelineItem from './ChatTimelineItem.vue'; import ChatTimelineItem from './ChatTimelineItem.vue';
const props = defineProps<{ const props = defineProps<{
approvalLoading?: boolean; approvalLoading?: boolean;
assistantAvatar?: string;
copyable?: (item: ChatTimelineMessageItem) => boolean; copyable?: (item: ChatTimelineMessageItem) => boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>; copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader; documentLoader?: ChatDocumentLoader;
emptyText?: string; emptyText?: string;
emptyTitle?: string;
imageLoader?: ChatImageLoader; imageLoader?: ChatImageLoader;
items: ChatTimelineItemType[]; items: ChatTimelineItemType[];
regenerable?: (item: ChatTimelineMessageItem) => boolean; regenerable?: (item: ChatTimelineMessageItem) => boolean;
@@ -143,7 +146,16 @@ watch(
@scroll.passive="handleTimelineScroll" @scroll.passive="handleTimelineScroll"
> >
<div v-if="items.length === 0" class="chat-timeline__empty"> <div v-if="items.length === 0" class="chat-timeline__empty">
<div class="chat-timeline__empty-icon" aria-hidden="true"></div> <div
class="chat-timeline__empty-icon"
:class="{ 'has-avatar': assistantAvatar }"
aria-hidden="true"
>
<ChatAssistantAvatar v-if="assistantAvatar" :src="assistantAvatar" />
</div>
<div v-if="emptyTitle" class="chat-timeline__empty-title">
{{ emptyTitle }}
</div>
<div class="chat-timeline__empty-text"> <div class="chat-timeline__empty-text">
{{ emptyText || '开始对话' }} {{ emptyText || '开始对话' }}
</div> </div>
@@ -158,6 +170,7 @@ watch(
<ChatTimelineItem <ChatTimelineItem
v-else v-else
:assistant-actions-visible="isAssistantActionAnchor(item)" :assistant-actions-visible="isAssistantActionAnchor(item)"
:assistant-avatar="assistantAvatar"
:item="item" :item="item"
:document-loader="documentLoader" :document-loader="documentLoader"
:image-loader="imageLoader" :image-loader="imageLoader"
@@ -185,7 +198,7 @@ watch(
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
gap: 12px; gap: var(--space-3);
min-height: 0; min-height: 0;
padding: 16px; padding: 16px;
overflow: auto; overflow: auto;
@@ -200,11 +213,17 @@ watch(
border-radius: 8px; border-radius: 8px;
} }
.chat-timeline__empty-icon.has-avatar {
background: transparent;
border: 0;
border-radius: var(--radius-panel);
}
.chat-timeline__empty { .chat-timeline__empty {
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
gap: 12px; gap: var(--space-2);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 180px; min-height: 180px;
@@ -215,4 +234,11 @@ watch(
font-size: 13px; font-size: 13px;
line-height: 20px; line-height: 20px;
} }
.chat-timeline__empty-title {
font-size: 16px;
font-weight: 600;
line-height: 24px;
color: hsl(var(--text-strong));
}
</style> </style>

View File

@@ -11,6 +11,7 @@ import type {
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue'; import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
import ChatAssistantAvatar from './ChatAssistantAvatar.vue';
import ChatDocumentAttachments from './ChatDocumentAttachments.vue'; import ChatDocumentAttachments from './ChatDocumentAttachments.vue';
import ChatErrorNotice from './ChatErrorNotice.vue'; import ChatErrorNotice from './ChatErrorNotice.vue';
import ChatImageAttachments from './ChatImageAttachments.vue'; import ChatImageAttachments from './ChatImageAttachments.vue';
@@ -23,6 +24,7 @@ import ChatToolCard from './ChatToolCard.vue';
const props = defineProps<{ const props = defineProps<{
approvalLoading?: boolean; approvalLoading?: boolean;
assistantActionsVisible?: boolean; assistantActionsVisible?: boolean;
assistantAvatar?: string;
copyable?: boolean; copyable?: boolean;
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>; copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
documentLoader?: ChatDocumentLoader; documentLoader?: ChatDocumentLoader;
@@ -56,6 +58,18 @@ const alignmentClass = computed(() => {
return 'is-assistant'; 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 variantCurrent = computed(() => {
const item = messageItem.value; const item = messageItem.value;
return Number(item?.selectedVariantIndex || item?.variantIndex || 1); return Number(item?.selectedVariantIndex || item?.variantIndex || 1);
@@ -158,68 +172,82 @@ function handleCopyAction() {
<div class="chat-timeline-item" :class="alignmentClass"> <div class="chat-timeline-item" :class="alignmentClass">
<div <div
v-if="messageItem" v-if="messageItem"
class="chat-timeline-item__message" class="chat-timeline-item__message-row"
:class="[ :class="[
`is-${messageItem.role}`, `is-${messageItem.role}`,
{ 'has-variant-navigator': showVariantNavigator }, {
'has-assistant-avatar': showAssistantAvatar,
'has-variant-navigator': showVariantNavigator,
},
]" ]"
> >
<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 <div
v-if="hasMessageBubble(messageItem)" v-if="showAssistantAvatar"
class="chat-timeline-item__bubble" class="chat-timeline-item__assistant-avatar"
>
<ChatAssistantAvatar :src="assistantAvatar" />
</div>
<div
class="chat-timeline-item__message"
:class="`is-${messageItem.role}`" :class="`is-${messageItem.role}`"
> >
<template v-for="part in getMessageParts(messageItem)" :key="part.id"> <ChatImageAttachments
<ChatThinkingBlock v-if="messageItem.images?.length"
v-if="part.type === 'thinking'" :items="messageItem.images"
:content="part.content" :image-loader="imageLoader"
:expanded="resolveThinkingExpanded(part)" compact
:status="part.status" />
class="chat-timeline-item__thinking" <ChatDocumentAttachments
readonly v-if="messageItem.documents?.length"
@update:expanded="updateThinkingExpanded(part.id, $event)" :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"
/> />
<ChatTextBlock </div>
v-else <ChatMessageToolbar
:content="part.content" v-if="showToolbar && messageItem"
:streaming="messageItem.status === 'streaming'" :align="messageItem.role === 'user' ? 'end' : 'start'"
/> :allow-copy="copyable"
</template> :allow-regenerate="regenerable"
<ChatKnowledgeCard :copy-action="copyAction ? handleCopyAction : undefined"
v-if="messageItem.knowledgeItems?.length" :disabled-variant-next="disabledVariantNext"
:items="messageItem.knowledgeItems" :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>
<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 <ChatToolCard
v-else-if="item.type === 'tool'" v-else-if="item.type === 'tool'"
@@ -232,7 +260,16 @@ function handleCopyAction() {
v-else-if="item.type === 'knowledge'" v-else-if="item.type === 'knowledge'"
:items="item.items" :items="item.items"
/> />
<ChatTimelineStatusRow v-else-if="item.type === 'status'" :item="item" /> <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 <ChatErrorNotice
v-else-if="item.type === 'error'" v-else-if="item.type === 'error'"
:message="item.message" :message="item.message"
@@ -244,6 +281,8 @@ function handleCopyAction() {
.chat-timeline-item { .chat-timeline-item {
display: flex; display: flex;
width: 100%; width: 100%;
--chat-assistant-avatar-size: 28px;
--chat-message-max-width: 680px;
} }
.chat-timeline-item.is-user { .chat-timeline-item.is-user {
@@ -254,6 +293,46 @@ function handleCopyAction() {
justify-content: flex-start; 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 { .chat-timeline-item__bubble {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -268,7 +347,7 @@ function handleCopyAction() {
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
min-width: 0; min-width: 0;
max-width: min(78%, 680px); max-width: var(--chat-message-max-width);
} }
.chat-timeline-item__message.is-user { .chat-timeline-item__message.is-user {
@@ -294,8 +373,23 @@ function handleCopyAction() {
line-height: 22px; line-height: 22px;
} }
.chat-timeline-item__message.is-assistant.has-variant-navigator { .chat-timeline-item__message-row.has-variant-navigator {
width: min(78%, 680px); 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-assistant,

View File

@@ -0,0 +1,131 @@
import type { ChatTimelineItem } from '../types';
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defaultAssistantAvatar } from '../assistantAvatar';
import ChatTimeline from '../ChatTimeline.vue';
function assistantMessage(): Extract<ChatTimelineItem, { type: 'message' }> {
return {
id: 'assistant-message',
parts: [{ content: '已完成', id: 'text-1', type: 'text' }],
role: 'assistant',
type: 'message',
};
}
describe('chat timeline assistant avatar', () => {
it('renders the configured assistant identity in the empty state', () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
emptyTitle: '数据查询',
emptyText: '输入问题开始运行',
items: [],
},
});
expect(wrapper.find('.chat-timeline__empty-title').text()).toBe('数据查询');
expect(
wrapper.find('.chat-timeline__empty-icon img').attributes('src'),
).toBe('/assistant.svg');
});
it('renders the assistant avatar only next to assistant messages', () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: [
{
id: 'user-message',
parts: [{ content: '你好', id: 'text-2', type: 'text' }],
role: 'user',
type: 'message',
},
assistantMessage(),
],
},
});
expect(
wrapper.findAll('.chat-timeline-item__assistant-avatar'),
).toHaveLength(1);
expect(
wrapper
.find('.chat-timeline-item__assistant-avatar img')
.attributes('src'),
).toBe('/assistant.svg');
});
it('renders the assistant avatar beside inline running statuses', () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: [
{
id: 'running-status',
label: '正在运行 · 大模型',
status: 'running',
statusKey: 'workflow-running',
type: 'status',
},
],
},
});
expect(
wrapper.findAll(
'.chat-timeline-item__status-row .chat-timeline-item__assistant-avatar',
),
).toHaveLength(1);
expect(
wrapper.find('.chat-timeline-item__status-row img').attributes('src'),
).toBe('/assistant.svg');
});
it('keeps separator statuses unchanged', () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/assistant.svg',
items: [
{
id: 'separator-status',
label: '今天',
presentation: 'separator',
status: 'done',
statusKey: 'date-separator',
type: 'status',
},
],
},
});
expect(
wrapper.find('.chat-timeline-item__status-row').classes(),
).not.toContain('has-assistant-avatar');
expect(
wrapper.findAll('.chat-timeline-item__assistant-avatar'),
).toHaveLength(0);
});
it('falls back to the bundled avatar when a custom image cannot load', async () => {
const wrapper = mount(ChatTimeline, {
props: {
assistantAvatar: '/broken-avatar.svg',
items: [assistantMessage()],
},
});
await wrapper
.find('.chat-timeline-item__assistant-avatar img')
.trigger('error');
expect(
wrapper
.find('.chat-timeline-item__assistant-avatar img')
.attributes('src'),
).toBe(defaultAssistantAvatar);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

View File

@@ -0,0 +1,2 @@
/** EasyFlow 聊天助手的默认头像。 */
export { default as defaultAssistantAvatar } from './assets/default-assistant-avatar.png';

View File

@@ -1,4 +1,6 @@
export { defaultAssistantAvatar } from './assistantAvatar';
export { ChatTimelineBuilder } from './builder'; export { ChatTimelineBuilder } from './builder';
export { default as ChatAssistantAvatar } from './ChatAssistantAvatar.vue';
export { default as ChatDocumentAttachments } from './ChatDocumentAttachments.vue'; export { default as ChatDocumentAttachments } from './ChatDocumentAttachments.vue';
export { default as ChatErrorNotice } from './ChatErrorNotice.vue'; export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
export { default as ChatImageAttachments } from './ChatImageAttachments.vue'; export { default as ChatImageAttachments } from './ChatImageAttachments.vue';