feat: 统一聊天助手头像
- 工作流运行状态、智能体正式聊天与试运行展示助手头像 - 新增默认机器人头像及消息、空状态回退组件
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,11 +172,24 @@ 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,
|
||||||
|
},
|
||||||
]"
|
]"
|
||||||
|
>
|
||||||
|
<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
|
<ChatImageAttachments
|
||||||
v-if="messageItem.images?.length"
|
v-if="messageItem.images?.length"
|
||||||
@@ -221,6 +248,7 @@ function handleCopyAction() {
|
|||||||
@select-previous-variant="emit('selectPreviousVariant', messageItem)"
|
@select-previous-variant="emit('selectPreviousVariant', messageItem)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<ChatToolCard
|
<ChatToolCard
|
||||||
v-else-if="item.type === 'tool'"
|
v-else-if="item.type === 'tool'"
|
||||||
:item="item"
|
:item="item"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,2 @@
|
|||||||
|
/** EasyFlow 聊天助手的默认头像。 */
|
||||||
|
export { default as defaultAssistantAvatar } from './assets/default-assistant-avatar.png';
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user