feat: 重构聊天时间线与附件上传交互
- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染 - 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新 - 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
This commit is contained in:
@@ -1,26 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||
|
||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
import { Close } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElEmpty, ElIcon, ElScrollbar } from 'element-plus';
|
||||
|
||||
import { CircleCheck, Close } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElScrollbar,
|
||||
} from 'element-plus';
|
||||
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
||||
|
||||
interface ChatHistoryDetailDrawerProps {
|
||||
visible?: boolean;
|
||||
loading?: boolean;
|
||||
session?: any;
|
||||
messages?: any[];
|
||||
messages?: ChatTimeTimelineItem[];
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: (() => Promise<void> | void) | undefined;
|
||||
}
|
||||
@@ -38,7 +28,7 @@ const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
function formatTime(value?: string) {
|
||||
function formatTime(value?: number | string) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
@@ -58,17 +48,12 @@ function resolveSenderName(item: any) {
|
||||
if (item?.senderName) {
|
||||
return item.senderName;
|
||||
}
|
||||
if (item?.role === 'tool') {
|
||||
return '工具调用';
|
||||
}
|
||||
return item?.role === 'assistant' ? '聊天助手' : '聊天用户';
|
||||
}
|
||||
|
||||
function isThinkingChain(chain: any) {
|
||||
return !chain?.id;
|
||||
}
|
||||
|
||||
function toolStatusText(status?: string) {
|
||||
return status === 'TOOL_RESULT' ? '调用成功' : '工具调用中';
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
await props.onLoadMore?.();
|
||||
}
|
||||
@@ -140,7 +125,7 @@ async function handleLoadMore() {
|
||||
>
|
||||
<article
|
||||
v-for="item in messages"
|
||||
:key="item.key"
|
||||
:key="item.id"
|
||||
class="chat-history-detail__message"
|
||||
:class="`is-${item.role}`"
|
||||
>
|
||||
@@ -160,61 +145,7 @@ async function handleLoadMore() {
|
||||
class="chat-history-detail__message-bubble"
|
||||
:class="`is-${item.role}`"
|
||||
>
|
||||
<div
|
||||
v-if="item.chains?.length"
|
||||
class="chat-history-detail__message-chains"
|
||||
>
|
||||
<template
|
||||
v-for="(chain, index) in item.chains"
|
||||
:key="chain.id || index"
|
||||
>
|
||||
<ChatThinkingBlock
|
||||
v-if="isThinkingChain(chain)"
|
||||
v-model:expanded="chain.thinkingExpanded"
|
||||
:content="chain.reasoning_content"
|
||||
readonly
|
||||
:status="chain.thinkingStatus"
|
||||
class="chat-history-detail__thinking"
|
||||
/>
|
||||
|
||||
<ElCollapse v-else class="chat-history-detail__tool-panel">
|
||||
<ElCollapseItem :title="chain.name" :name="chain.id">
|
||||
<template #title>
|
||||
<div class="chat-history-detail__tool-title">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:wrench" />
|
||||
</ElIcon>
|
||||
<span class="chat-history-detail__tool-name">
|
||||
{{ chain.name }}
|
||||
</span>
|
||||
<div class="chat-history-detail__tool-status">
|
||||
<ElIcon
|
||||
v-if="chain.status === 'TOOL_RESULT'"
|
||||
size="14"
|
||||
color="var(--el-color-success)"
|
||||
>
|
||||
<CircleCheck />
|
||||
</ElIcon>
|
||||
<IconifyIcon
|
||||
v-else
|
||||
icon="mdi:clock-time-five-outline"
|
||||
/>
|
||||
<span>{{ toolStatusText(chain.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ShowJson :value="chain.result" />
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.content && String(item.content).trim()"
|
||||
class="chat-history-detail__markdown"
|
||||
>
|
||||
<ElXMarkdown :markdown="item.content" />
|
||||
</div>
|
||||
<ChatTimeMessageContent :item="item" readonly-thinking />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChatTimeAssistantSegment,
|
||||
ChatTimeTimelineItem,
|
||||
} from '@easyflow/types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
|
||||
|
||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
|
||||
import { CircleCheck } from '@element-plus/icons-vue';
|
||||
import { ElIcon } from 'element-plus';
|
||||
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
|
||||
interface Props {
|
||||
item: ChatTimeTimelineItem;
|
||||
readonlyThinking?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
readonlyThinking: false,
|
||||
});
|
||||
const toolExpanded = ref(false);
|
||||
|
||||
const renderableSegments = computed(() => {
|
||||
if (props.item.role !== 'assistant') {
|
||||
return [] as ChatTimeAssistantSegment[];
|
||||
}
|
||||
if (props.item.segments.length > 0) {
|
||||
return props.item.segments;
|
||||
}
|
||||
if (!props.item.content) {
|
||||
return [] as ChatTimeAssistantSegment[];
|
||||
}
|
||||
return [
|
||||
{
|
||||
content: props.item.content,
|
||||
id: `${props.item.id}-fallback-text`,
|
||||
type: 'text' as const,
|
||||
},
|
||||
] satisfies ChatTimeAssistantSegment[];
|
||||
});
|
||||
|
||||
function getToolName() {
|
||||
if (props.item.role !== 'tool') {
|
||||
return '';
|
||||
}
|
||||
return props.item.name || '工具调用';
|
||||
}
|
||||
|
||||
const hasToolDetails = computed(() => {
|
||||
return props.item.role === 'tool' && Boolean(props.item.arguments || props.item.result);
|
||||
});
|
||||
|
||||
function toggleToolExpanded() {
|
||||
if (!hasToolDetails.value) {
|
||||
return;
|
||||
}
|
||||
toolExpanded.value = !toolExpanded.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="item.role === 'assistant'">
|
||||
<div class="flex flex-col gap-2">
|
||||
<template v-for="segment in renderableSegments" :key="segment.id">
|
||||
<ChatThinkingBlock
|
||||
v-if="segment.type === 'thinking'"
|
||||
v-model:expanded="segment.expanded"
|
||||
:content="segment.content"
|
||||
:readonly="readonlyThinking"
|
||||
:status="segment.status"
|
||||
class="chat-thinking-block-item"
|
||||
/>
|
||||
<ElXMarkdown v-else :markdown="segment.content" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="item.role === 'tool'">
|
||||
<div class="chat-tool-panel" :class="{ 'is-expanded': toolExpanded }">
|
||||
<button
|
||||
type="button"
|
||||
class="chat-tool-header"
|
||||
:class="{ 'is-clickable': hasToolDetails }"
|
||||
@click="toggleToolExpanded"
|
||||
>
|
||||
<div class="chat-tool-title">
|
||||
<ElIcon size="14">
|
||||
<IconifyIcon icon="svg:wrench" />
|
||||
</ElIcon>
|
||||
<span class="chat-tool-title-text">{{ getToolName() }}</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"
|
||||
size="14"
|
||||
class="chat-tool-arrow"
|
||||
:class="{ 'is-expanded': toolExpanded }"
|
||||
>
|
||||
<IconifyIcon icon="ep:arrow-right" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="toolExpanded && hasToolDetails" 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>
|
||||
|
||||
<ElXMarkdown v-else :markdown="item.content" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -5,8 +5,7 @@ import type {
|
||||
} from 'vue-element-plus-x/types/BubbleList';
|
||||
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
|
||||
|
||||
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
|
||||
import type { BotInfo, ChatMessage } from '@easyflow/types';
|
||||
import type { BotInfo, ChatTimeTimelineItem } from '@easyflow/types';
|
||||
|
||||
import {
|
||||
nextTick,
|
||||
@@ -18,59 +17,36 @@ import {
|
||||
} from 'vue';
|
||||
import ElBubbleList from 'vue-element-plus-x/es/BubbleList/index.js';
|
||||
import ElSender from 'vue-element-plus-x/es/Sender/index.js';
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
import { $t } from '@easyflow/locales';
|
||||
import { useBotStore } from '@easyflow/stores';
|
||||
import { cloneDeep, cn, uuid } from '@easyflow/utils';
|
||||
import {
|
||||
ChatTimeHistoryMapper,
|
||||
ChatTimeTimelineBuilder,
|
||||
cn,
|
||||
uuid,
|
||||
} from '@easyflow/utils';
|
||||
|
||||
import {
|
||||
ArrowDownBold,
|
||||
CircleCheck,
|
||||
CopyDocument,
|
||||
Paperclip,
|
||||
RefreshRight,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElIcon,
|
||||
ElMessage,
|
||||
ElSpace,
|
||||
} from 'element-plus';
|
||||
import { ElButton, ElIcon, ElMessage, ElSpace } from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import { getMessageList, getPerQuestions } from '#/api';
|
||||
import { api, sseClient } from '#/api/request';
|
||||
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
||||
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
|
||||
import SendIcon from '#/components/icons/SendIcon.vue';
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
|
||||
|
||||
import BotAvatar from '../botAvatar/botAvatar.vue';
|
||||
import SendingIcon from '../icons/SendingIcon.vue';
|
||||
|
||||
type Think = {
|
||||
reasoning_content?: string;
|
||||
thinkingExpanded?: boolean;
|
||||
thinkingStatus?: ChatThinkingBlockStatus;
|
||||
};
|
||||
|
||||
type Tool = {
|
||||
id: string;
|
||||
name: string;
|
||||
result: string;
|
||||
status: 'TOOL_CALL' | 'TOOL_RESULT';
|
||||
};
|
||||
|
||||
type MessageItem = ChatMessage & {
|
||||
chains?: (Think | Tool)[];
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
bot?: BotInfo;
|
||||
conversationId?: string;
|
||||
@@ -90,7 +66,7 @@ const route = useRoute();
|
||||
const botId = ref<string>((route.params.id as string) || '');
|
||||
const router = useRouter();
|
||||
|
||||
const bubbleItems = ref<BubbleListProps<MessageItem>['list']>([]);
|
||||
const bubbleItems = ref<BubbleListProps<ChatTimeTimelineItem>['list']>([]);
|
||||
const bubbleListRef = ref<BubbleListInstance>();
|
||||
const messageContainerRef = ref<HTMLElement | null>(null);
|
||||
const bubbleListScrollElement = ref<HTMLElement | null>(null);
|
||||
@@ -153,14 +129,9 @@ watchEffect(async () => {
|
||||
});
|
||||
|
||||
if (res?.errorCode === 0) {
|
||||
bubbleItems.value = res.data.map((item) => ({
|
||||
...item,
|
||||
content:
|
||||
item.role === 'assistant'
|
||||
? item.content.replace(/^Final Answer:\s*/i, '')
|
||||
: item.content,
|
||||
placement: item.role === 'assistant' ? 'start' : 'end',
|
||||
}));
|
||||
bubbleItems.value = ChatTimeHistoryMapper.fromHistoryRecords(
|
||||
res.data as any[],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
bubbleItems.value = [];
|
||||
@@ -207,31 +178,17 @@ const bindBubbleListScroll = () => {
|
||||
}
|
||||
updateBackToBottomButtonVisible();
|
||||
};
|
||||
const updateLastBubbleItem = (patch: Partial<MessageItem>) => {
|
||||
const lastIndex = bubbleItems.value.length - 1;
|
||||
if (lastIndex < 0) {
|
||||
return;
|
||||
}
|
||||
bubbleItems.value[lastIndex] = {
|
||||
...bubbleItems.value[lastIndex]!,
|
||||
...patch,
|
||||
};
|
||||
};
|
||||
const finalizeLastBubbleItem = () => {
|
||||
updateLastBubbleItem({
|
||||
loading: false,
|
||||
typing: false,
|
||||
});
|
||||
const finalizeTimelineTail = () => {
|
||||
ChatTimeTimelineBuilder.finalize(bubbleItems.value);
|
||||
};
|
||||
const stopSse = () => {
|
||||
sseClient.abort();
|
||||
sending.value = false;
|
||||
finalizeLastBubbleItem();
|
||||
finalizeTimelineTail();
|
||||
};
|
||||
const clearSenderFiles = () => {
|
||||
files.value = [];
|
||||
attachmentsRef.value?.clearFiles();
|
||||
openCloseHeader();
|
||||
};
|
||||
const handleSubmit = async (refreshContent: string) => {
|
||||
const attachments = attachmentsRef.value?.getFileList();
|
||||
@@ -261,14 +218,11 @@ const handleSubmit = async (refreshContent: string) => {
|
||||
sseClient.post('/api/v1/bot/chat', data, {
|
||||
onMessage(message) {
|
||||
const event = message.event;
|
||||
const lastIndex = bubbleItems.value.length - 1;
|
||||
const lastBubbleItem = bubbleItems.value[lastIndex];
|
||||
|
||||
// finish
|
||||
if (event === 'done') {
|
||||
sending.value = false;
|
||||
finalizeLastBubbleItem();
|
||||
stopThinking();
|
||||
finalizeTimelineTail();
|
||||
return;
|
||||
}
|
||||
if (!message.data) {
|
||||
@@ -280,46 +234,30 @@ const handleSubmit = async (refreshContent: string) => {
|
||||
sseData?.domain === 'SYSTEM' &&
|
||||
sseData.payload?.code === 'SYSTEM_ERROR'
|
||||
) {
|
||||
const errorMessage = sseData.payload.message;
|
||||
if (!lastBubbleItem) return;
|
||||
bubbleItems.value[lastIndex] = {
|
||||
...lastBubbleItem,
|
||||
content: errorMessage,
|
||||
loading: false,
|
||||
typing: false,
|
||||
};
|
||||
ChatTimeTimelineBuilder.applySystemError(
|
||||
bubbleItems.value,
|
||||
sseData.payload.message,
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastIndex >= 0 && sseData?.domain === 'TOOL') {
|
||||
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
|
||||
const index = chains.findIndex(
|
||||
(chain) =>
|
||||
isTool(chain) && chain.id === sseData?.payload?.tool_call_id,
|
||||
);
|
||||
|
||||
if (index === -1) {
|
||||
chains.push({
|
||||
id: sseData?.payload?.tool_call_id,
|
||||
if (sseData?.domain === 'TOOL') {
|
||||
if (sseData?.type === 'TOOL_CALL') {
|
||||
ChatTimeTimelineBuilder.upsertToolCall(bubbleItems.value, {
|
||||
created: Date.now(),
|
||||
name: sseData?.payload?.name,
|
||||
status: sseData?.type,
|
||||
result:
|
||||
sseData?.type === 'TOOL_CALL'
|
||||
? sseData?.payload?.arguments
|
||||
: sseData?.payload?.result,
|
||||
toolCallId: sseData?.payload?.tool_call_id,
|
||||
value: sseData?.payload?.arguments,
|
||||
});
|
||||
} else {
|
||||
chains[index] = {
|
||||
...chains[index]!,
|
||||
status: sseData?.type,
|
||||
result:
|
||||
sseData?.type === 'TOOL_CALL'
|
||||
? sseData?.payload?.arguments
|
||||
: sseData?.payload?.result,
|
||||
};
|
||||
ChatTimeTimelineBuilder.upsertToolResult(bubbleItems.value, {
|
||||
created: Date.now(),
|
||||
name: sseData?.payload?.name,
|
||||
result: sseData?.payload?.result,
|
||||
toolCallId: sseData?.payload?.tool_call_id,
|
||||
});
|
||||
}
|
||||
bubbleItems.value[lastIndex]!.chains = chains;
|
||||
stopThinking();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -327,38 +265,19 @@ const handleSubmit = async (refreshContent: string) => {
|
||||
const delta = sseData.payload?.delta;
|
||||
const role = sseData.payload?.role;
|
||||
|
||||
if (lastBubbleItem && delta) {
|
||||
if (delta) {
|
||||
if (sseData.type === 'THINKING') {
|
||||
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
|
||||
const index = chains.findIndex(
|
||||
(chain) => isThink(chain) && chain.thinkingStatus === 'thinking',
|
||||
ChatTimeTimelineBuilder.appendThinkingDelta(
|
||||
bubbleItems.value,
|
||||
delta,
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
if (index === -1) {
|
||||
chains.push({
|
||||
thinkingStatus: 'thinking',
|
||||
thinkingExpanded: false,
|
||||
reasoning_content: delta,
|
||||
});
|
||||
} else {
|
||||
const think = chains[index]! as Think;
|
||||
chains[index] = {
|
||||
...think,
|
||||
reasoning_content: think.reasoning_content + delta,
|
||||
};
|
||||
}
|
||||
bubbleItems.value[lastIndex]!.chains = chains;
|
||||
} else if (sseData.type === 'MESSAGE') {
|
||||
bubbleItems.value[lastIndex] = {
|
||||
...lastBubbleItem,
|
||||
content: (lastBubbleItem.content + delta).replaceAll(
|
||||
'```echartsoption',
|
||||
'```echarts\noption',
|
||||
),
|
||||
loading: false,
|
||||
typing: true,
|
||||
};
|
||||
stopThinking();
|
||||
ChatTimeTimelineBuilder.appendMessageDelta(
|
||||
bubbleItems.value,
|
||||
delta,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,39 +291,16 @@ const handleSubmit = async (refreshContent: string) => {
|
||||
},
|
||||
onFinished() {
|
||||
sending.value = false;
|
||||
finalizeLastBubbleItem();
|
||||
stopThinking();
|
||||
finalizeTimelineTail();
|
||||
},
|
||||
onError(err) {
|
||||
console.error(err);
|
||||
sending.value = false;
|
||||
finalizeLastBubbleItem();
|
||||
finalizeTimelineTail();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isTool = (item: Think | Tool) => {
|
||||
return 'id' in item;
|
||||
};
|
||||
const isThink = (item: Think | Tool): item is Think => {
|
||||
return !('id' in item);
|
||||
};
|
||||
const stopThinking = () => {
|
||||
const lastIndex = bubbleItems.value.length - 1;
|
||||
|
||||
if (lastIndex >= 0 && bubbleItems.value[lastIndex]?.chains) {
|
||||
const chains = cloneDeep(bubbleItems.value[lastIndex].chains);
|
||||
|
||||
for (const chain of chains) {
|
||||
if (isThink(chain) && chain.thinkingStatus === 'thinking') {
|
||||
chain.thinkingStatus = 'end';
|
||||
}
|
||||
}
|
||||
|
||||
bubbleItems.value[lastIndex].chains = chains;
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = (_: TypewriterInstance, index: number) => {
|
||||
if (
|
||||
index === bubbleItems.value.length - 1 &&
|
||||
@@ -421,27 +317,14 @@ const handleComplete = (_: TypewriterInstance, index: number) => {
|
||||
};
|
||||
|
||||
const generateMockMessages = (refreshContent: string) => {
|
||||
const userMessage: MessageItem = {
|
||||
const userMessage: ChatTimeTimelineItem = {
|
||||
role: 'user',
|
||||
id: Date.now().toString(),
|
||||
fileList: [],
|
||||
content: refreshContent || senderValue.value,
|
||||
created: Date.now(),
|
||||
updateAt: Date.now(),
|
||||
placement: 'end',
|
||||
};
|
||||
|
||||
const assistantMessage: MessageItem = {
|
||||
role: 'assistant',
|
||||
id: Date.now().toString(),
|
||||
content: '',
|
||||
loading: true,
|
||||
created: Date.now(),
|
||||
updateAt: Date.now(),
|
||||
placement: 'start',
|
||||
};
|
||||
|
||||
return [userMessage, assistantMessage];
|
||||
return [userMessage];
|
||||
};
|
||||
|
||||
const handleCopy = (content: string) => {
|
||||
@@ -462,23 +345,17 @@ const scrollToBottom = () => {
|
||||
}
|
||||
showBackToBottomButton.value = false;
|
||||
};
|
||||
const showHeaderFlog = ref(false);
|
||||
function openCloseHeader() {
|
||||
if (showHeaderFlog.value) {
|
||||
senderRef.value?.closeHeader();
|
||||
files.value = [];
|
||||
} else {
|
||||
senderRef.value?.openHeader();
|
||||
}
|
||||
showHeaderFlog.value = !showHeaderFlog.value;
|
||||
}
|
||||
const attachmentsRef = ref();
|
||||
const files = ref<any[]>([]);
|
||||
function handlePasteFile(_: any, fileList: FileList) {
|
||||
showHeaderFlog.value = true;
|
||||
senderRef.value?.openHeader();
|
||||
files.value = [...fileList];
|
||||
}
|
||||
function triggerFileSelect() {
|
||||
attachmentsRef.value?.triggerFileSelect?.();
|
||||
}
|
||||
function handleDeleteAllSenderFiles() {
|
||||
files.value = [];
|
||||
}
|
||||
watch(
|
||||
() => [localeConversationId.value, bubbleItems.value.length],
|
||||
() => {
|
||||
@@ -527,78 +404,30 @@ onBeforeUnmount(() => {
|
||||
<span class="chat-bubble-item-time-style">
|
||||
{{ new Date(item.created).toLocaleString() }}
|
||||
</span>
|
||||
|
||||
<template v-if="item.chains">
|
||||
<template
|
||||
v-for="(chain, index) in item.chains"
|
||||
:key="chain.id || index"
|
||||
>
|
||||
<ChatThinkingBlock
|
||||
v-if="isThink(chain)"
|
||||
v-model:expanded="chain.thinkingExpanded"
|
||||
:content="chain.reasoning_content"
|
||||
:status="chain.thinkingStatus"
|
||||
class="chat-thinking-block-item"
|
||||
/>
|
||||
<ElCollapse v-else class="chat-tool-panel">
|
||||
<ElCollapseItem :title="chain.name" :name="chain.id">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 pl-5">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:wrench" />
|
||||
</ElIcon>
|
||||
<span>{{ chain.name }}</span>
|
||||
<template v-if="chain.status === 'TOOL_CALL'">
|
||||
<div
|
||||
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
|
||||
>
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon
|
||||
icon="mdi:clock-time-five-outline"
|
||||
/>
|
||||
</ElIcon>
|
||||
<span>{{ $t('bot.Running') }}...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
|
||||
>
|
||||
<ElIcon size="16" color="var(--el-color-success)">
|
||||
<CircleCheck />
|
||||
</ElIcon>
|
||||
<span>{{ $t('bot.Completed') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<ShowJson :value="chain.result" />
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 自定义头像 -->
|
||||
<template #avatar="{ item }">
|
||||
<BotAvatar
|
||||
v-if="item.role === 'assistant'"
|
||||
v-if="item.role !== 'user'"
|
||||
:src="bot?.icon"
|
||||
:size="40"
|
||||
/>
|
||||
</template>
|
||||
<template #content="{ item }">
|
||||
<ElXMarkdown :markdown="item.content" />
|
||||
<ChatTimeMessageContent :item="item" />
|
||||
</template>
|
||||
<!-- 自定义底部 -->
|
||||
<template #footer="{ item }">
|
||||
<ElSpace :size="10">
|
||||
<ElSpace>
|
||||
<ElSpace v-if="item.role !== 'tool'" :size="10">
|
||||
<ElSpace v-if="item.role === 'assistant'">
|
||||
<span @click="handleRefresh()" style="cursor: pointer">
|
||||
<ElIcon>
|
||||
<RefreshRight />
|
||||
</ElIcon>
|
||||
</span>
|
||||
</ElSpace>
|
||||
<ElSpace>
|
||||
<span @click="handleCopy(item.content)" style="cursor: pointer">
|
||||
<ElIcon>
|
||||
<CopyDocument />
|
||||
@@ -643,6 +472,12 @@ onBeforeUnmount(() => {
|
||||
</ElButton>
|
||||
</div>
|
||||
<!-- Sender -->
|
||||
<ChatFileUploader
|
||||
ref="attachmentsRef"
|
||||
:external-files="files"
|
||||
:max-size="10"
|
||||
@delete-all="handleDeleteAllSenderFiles"
|
||||
/>
|
||||
<ElSender
|
||||
ref="senderRef"
|
||||
class="w-full"
|
||||
@@ -654,18 +489,9 @@ onBeforeUnmount(() => {
|
||||
@submit="handleSubmit"
|
||||
@paste-file="handlePasteFile"
|
||||
>
|
||||
<!-- 自定义头部内容 -->
|
||||
<template #header>
|
||||
<ChatFileUploader
|
||||
ref="attachmentsRef"
|
||||
:external-files="files"
|
||||
:max-size="10"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #action-list>
|
||||
<ElSpace>
|
||||
<ElButton circle @click="openCloseHeader">
|
||||
<ElButton circle @click="triggerFileSelect">
|
||||
<ElIcon><Paperclip /></ElIcon>
|
||||
</ElButton>
|
||||
<!--<ElButton circle @click="uploadRef.triggerFileSelect()">
|
||||
@@ -770,39 +596,4 @@ onBeforeUnmount(() => {
|
||||
:deep(.el-bubble-end .el-bubble-header) {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.chat-thinking-block-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-tool-panel {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel.el-collapse) {
|
||||
background: hsl(var(--surface-panel) / 70%);
|
||||
border: 1px solid hsl(var(--divider-faint) / 26%);
|
||||
border-radius: 14px;
|
||||
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 20%);
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel .el-collapse-item) {
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel .el-collapse-item__wrap) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel .el-collapse-item__header) {
|
||||
min-height: 44px;
|
||||
padding-right: 14px;
|
||||
background: transparent;
|
||||
border-bottom-color: hsl(var(--divider-faint) / 16%);
|
||||
}
|
||||
|
||||
:deep(.chat-tool-panel .el-collapse-item__content) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,11 +10,15 @@ import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||
|
||||
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
|
||||
|
||||
defineProps({
|
||||
value: {
|
||||
required: true,
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
value: unknown;
|
||||
plain?: boolean;
|
||||
}>(),
|
||||
{
|
||||
plain: false,
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const themeMode = ref(preferences.theme.mode);
|
||||
watch(
|
||||
@@ -26,7 +30,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="res-container">
|
||||
<div class="res-container" :class="{ 'is-plain': plain }">
|
||||
<JsonViewer v-if="value" :value="value" copyable :theme="themeMode" />
|
||||
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" v-else />
|
||||
</div>
|
||||
@@ -38,4 +42,11 @@ watch(
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
|
||||
.res-container.is-plain {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,6 +33,7 @@ type SelfFilesCardProps = {
|
||||
} & FilesCardProps;
|
||||
|
||||
const files = ref<SelfFilesCardProps[]>([]);
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
/**
|
||||
* 上传前校验
|
||||
*/
|
||||
@@ -45,30 +46,11 @@ function handleBeforeUpload(file: File) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽上传处理
|
||||
*/
|
||||
async function handleUploadDrop(dropFiles: File[]) {
|
||||
if (dropFiles?.length) {
|
||||
if (dropFiles[0]?.type === '') {
|
||||
ElMessage.error('禁止上传文件夹!');
|
||||
return false;
|
||||
}
|
||||
for (const file of dropFiles) {
|
||||
if (handleBeforeUpload(file)) {
|
||||
await handleHttpRequest({ file });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义上传请求
|
||||
*/
|
||||
async function handleHttpRequest(options: { file: File }) {
|
||||
const { file } = options;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await api.upload(props.action, { file }, {});
|
||||
@@ -104,6 +86,18 @@ async function uploadExternalFiles(fileList: File[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理原生文件选择
|
||||
*/
|
||||
async function handleFileInputChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const selectedFiles = Array.from(target.files || []);
|
||||
if (selectedFiles.length > 0) {
|
||||
await uploadExternalFiles(selectedFiles);
|
||||
}
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件内部完成删除逻辑
|
||||
*/
|
||||
@@ -122,6 +116,13 @@ function getFileList(): string[] {
|
||||
return files.value.map((file) => file.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发原生文件选择
|
||||
*/
|
||||
function triggerFileSelect() {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.externalFiles,
|
||||
async (newFiles) => {
|
||||
@@ -135,6 +136,7 @@ watch(
|
||||
|
||||
defineExpose({
|
||||
getFileList,
|
||||
triggerFileSelect,
|
||||
clearFiles() {
|
||||
files.value = [];
|
||||
},
|
||||
@@ -142,17 +144,20 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display: flex; flex-direction: column; gap: 12px">
|
||||
<div v-if="files.length > 0" style="display: flex; flex-direction: column; gap: 12px">
|
||||
<Attachments
|
||||
:http-request="handleHttpRequest"
|
||||
:items="files"
|
||||
drag
|
||||
:before-upload="handleBeforeUpload"
|
||||
:hide-upload="false"
|
||||
@upload-drop="handleUploadDrop"
|
||||
:hide-upload="true"
|
||||
@delete-card="handleDeleteCard"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileInputChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { useEasyFlowDrawer } from '@easyflow/common-ui';
|
||||
import { ChatTimeHistoryMapper } from '@easyflow/utils';
|
||||
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import {
|
||||
@@ -43,7 +46,8 @@ const quickRangeOptions = [
|
||||
|
||||
const drawerLoading = ref(false);
|
||||
const currentSession = ref<any>();
|
||||
const messageList = ref<any[]>([]);
|
||||
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||||
const loadedMessageRecordCount = ref(0);
|
||||
const messagePage = ref({
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
@@ -63,7 +67,7 @@ const [Drawer, drawerApi] = useEasyFlowDrawer({
|
||||
});
|
||||
|
||||
const hasMoreMessages = computed(
|
||||
() => messageList.value.length < messagePage.value.total,
|
||||
() => loadedMessageRecordCount.value < messagePage.value.total,
|
||||
);
|
||||
|
||||
const selectedSessionId = computed(() =>
|
||||
@@ -111,6 +115,7 @@ async function openSession(sessionId: number | string) {
|
||||
sessions.value.find((item: any) => String(item.id) === String(sessionId)) ||
|
||||
undefined;
|
||||
messageList.value = [];
|
||||
loadedMessageRecordCount.value = 0;
|
||||
messagePage.value = {
|
||||
total: 0,
|
||||
pageNumber: 1,
|
||||
@@ -154,31 +159,15 @@ async function loadMessages(reset = false) {
|
||||
messageList.value = reset
|
||||
? normalized
|
||||
: [...normalized, ...messageList.value];
|
||||
loadedMessageRecordCount.value = reset
|
||||
? (res.data?.records || []).length
|
||||
: loadedMessageRecordCount.value + (res.data?.records || []).length;
|
||||
messagePage.value.total = res.data?.total || 0;
|
||||
messagePage.value.pageNumber = nextPageNumber;
|
||||
}
|
||||
|
||||
function normalizeMessages(records: any[]) {
|
||||
return [...records].reverse().map((item: any) => ({
|
||||
key: String(item.id),
|
||||
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
|
||||
content:
|
||||
item.senderRole === 'assistant'
|
||||
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
|
||||
: item.contentText,
|
||||
created: item.created,
|
||||
senderName: item.senderName,
|
||||
chains: Array.isArray(item.contentPayload?.chains)
|
||||
? item.contentPayload.chains.map((chain: any) =>
|
||||
chain?.id
|
||||
? chain
|
||||
: {
|
||||
...chain,
|
||||
thinkingExpanded: false,
|
||||
},
|
||||
)
|
||||
: [],
|
||||
}));
|
||||
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
|
||||
}
|
||||
|
||||
function changePage(pageNumber: number) {
|
||||
|
||||
Reference in New Issue
Block a user