feat: 重构聊天时间线与附件上传交互

- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染

- 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新

- 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
This commit is contained in:
2026-05-11 21:25:21 +08:00
parent e27834ee0c
commit 4a15124183
27 changed files with 2527 additions and 751 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -0,0 +1,96 @@
type ChatTimeTimelineRole = 'assistant' | 'tool' | 'user';
type ChatTimeToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
type ChatTimeThinkingStatus = 'end' | 'thinking';
interface ChatTimeTimelineItemBase {
created: number | string;
id: string;
loading?: boolean;
placement: 'end' | 'start';
role: ChatTimeTimelineRole;
senderName?: string;
typing?: boolean;
}
interface ChatTimeAssistantThinkingSegment {
content: string;
expanded: boolean;
id: string;
status: ChatTimeThinkingStatus;
type: 'thinking';
}
interface ChatTimeAssistantTextSegment {
content: string;
id: string;
type: 'text';
}
type ChatTimeAssistantSegment =
| ChatTimeAssistantTextSegment
| ChatTimeAssistantThinkingSegment;
interface ChatTimeAssistantItem extends ChatTimeTimelineItemBase {
content: string;
role: 'assistant';
segments: ChatTimeAssistantSegment[];
}
interface ChatTimeToolItem extends ChatTimeTimelineItemBase {
arguments?: string;
content: string;
name: string;
result?: string;
role: 'tool';
status: ChatTimeToolStatus;
toolCallId: string;
}
interface ChatTimeUserItem extends ChatTimeTimelineItemBase {
content: string;
role: 'user';
}
type ChatTimeTimelineItem =
| ChatTimeAssistantItem
| ChatTimeToolItem
| ChatTimeUserItem;
interface ChatTimeHistoryRecord {
chains?: Array<Record<string, any>>;
content?: string;
contentPayload?: null | Record<string, any>;
contentText?: string;
created?: number | string;
id?: number | string;
loading?: boolean;
placement?: 'end' | 'start';
role?: string;
senderName?: string;
senderRole?: string;
typing?: boolean;
}
interface ChatTimeToolMutationPayload {
created?: number | string;
name?: string;
result?: any;
toolCallId?: string;
value?: any;
}
export type {
ChatTimeAssistantItem,
ChatTimeAssistantSegment,
ChatTimeAssistantTextSegment,
ChatTimeAssistantThinkingSegment,
ChatTimeHistoryRecord,
ChatTimeThinkingStatus,
ChatTimeTimelineItem,
ChatTimeTimelineItemBase,
ChatTimeTimelineRole,
ChatTimeToolItem,
ChatTimeToolMutationPayload,
ChatTimeToolStatus,
ChatTimeUserItem,
};

View File

@@ -1,4 +1,5 @@
export type * from './api';
export type * from './bot';
export type * from './chat-time';
export type * from './user';
export type * from '@easyflow-core/typings';

View File

@@ -15,6 +15,7 @@
"dependencies": {
"@easyflow-core/shared": "workspace:*",
"@easyflow-core/typings": "workspace:*",
"@easyflow/types": "workspace:*",
"vue-router": "catalog:"
}
}

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import {
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
} from '../chat-time';
describe('chat-time timeline builder', () => {
it('builds assistant thinking and message in the same assistant item', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendUserMessage(items, {
content: '你好',
created: 1,
id: 'user-1',
});
ChatTimeTimelineBuilder.appendThinkingDelta(items, '先想一下', 2);
ChatTimeTimelineBuilder.appendMessageDelta(items, '最终回答', 3);
ChatTimeTimelineBuilder.finalize(items);
expect(items).toHaveLength(2);
expect(items[1]).toMatchObject({
content: '最终回答',
role: 'assistant',
});
expect(items[1].segments).toMatchObject([
{ content: '先想一下', status: 'end', type: 'thinking' },
{ content: '最终回答', type: 'text' },
]);
});
it('creates a new assistant item after tool result', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
ChatTimeTimelineBuilder.upsertToolCall(items, {
name: 'search_docs',
toolCallId: 'tool-1',
value: '{"query":"java"}',
});
ChatTimeTimelineBuilder.upsertToolResult(items, {
result: '{"hits":1}',
toolCallId: 'tool-1',
});
ChatTimeTimelineBuilder.appendThinkingDelta(items, '继续思考', 2);
ChatTimeTimelineBuilder.appendMessageDelta(items, '第二段回答', 3);
ChatTimeTimelineBuilder.finalize(items);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
expect(items[1]).toMatchObject({
name: 'search_docs',
result: '{"hits":1}',
role: 'tool',
status: 'TOOL_RESULT',
toolCallId: 'tool-1',
});
expect(items[2]).toMatchObject({ content: '第二段回答', role: 'assistant' });
expect(items[2].segments).toMatchObject([
{ content: '继续思考', status: 'end', type: 'thinking' },
{ content: '第二段回答', type: 'text' },
]);
});
});
describe('chat-time history mapper', () => {
it('expands structured messageChain into assistant and tool timeline items', () => {
const items = ChatTimeHistoryMapper.fromHistoryRecords([
{
contentPayload: {
messageChain: [
{
content: '先回答一点',
reasoningContent: '先思考',
role: 'assistant',
toolCalls: [
{
arguments: '{"query":"java"}',
id: 'tool-1',
name: 'search_docs',
},
],
},
{
content: '{"hits":1}',
role: 'tool',
toolCallId: 'tool-1',
},
{
content: '最后总结',
reasoningContent: '继续思考',
role: 'assistant',
},
],
},
created: 100,
id: 'assistant-record',
senderRole: 'assistant',
},
]);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({
content: '先回答一点',
role: 'assistant',
});
expect(items[1]).toMatchObject({
arguments: '{"query":"java"}',
name: 'search_docs',
result: '{"hits":1}',
role: 'tool',
toolCallId: 'tool-1',
});
expect(items[2]).toMatchObject({
content: '最后总结',
role: 'assistant',
});
expect(items[0]?.id).not.toBe(items[2]?.id);
});
it('falls back to legacy chains when messageChain is unavailable', () => {
const items = ChatTimeHistoryMapper.fromLegacyMessages([
{
chains: [
{
reasoning_content: '旧思考',
thinkingStatus: 'end',
},
{
id: 'tool-2',
name: 'legacy_tool',
result: '{"ok":true}',
status: 'TOOL_RESULT',
},
],
content: '旧回答',
created: '2026-05-11 10:00:00',
id: 'legacy-1',
role: 'assistant',
},
]);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
content: '旧回答',
role: 'assistant',
});
expect(items[0].segments).toMatchObject([
{ content: '旧思考', status: 'end', type: 'thinking' },
{ content: '旧回答', type: 'text' },
]);
expect(items[1]).toMatchObject({
name: 'legacy_tool',
result: '{"ok":true}',
role: 'tool',
toolCallId: 'tool-2',
});
});
});

View File

@@ -0,0 +1,666 @@
import type {
ChatTimeAssistantItem,
ChatTimeHistoryRecord,
ChatTimeThinkingStatus,
ChatTimeTimelineItem,
ChatTimeToolItem,
ChatTimeToolMutationPayload,
ChatTimeToolStatus,
} from '../../../types/src/chat-time';
import { uuid } from './uuid';
type ChatTimeToolMeta = {
arguments?: string;
name?: string;
};
/**
* 聊天时间线实时构建器。
*/
class ChatTimeTimelineBuilder {
/**
* 追加用户消息。
*/
static appendUserMessage(
items: ChatTimeTimelineItem[],
payload: {
content?: string;
created?: number | string;
id?: string;
senderName?: string;
},
) {
items.push({
content: normalizePlainText(payload.content),
created: normalizeTimestamp(payload.created),
id: payload.id || uuid(),
placement: 'end',
role: 'user',
senderName: payload.senderName,
});
}
/**
* 追加思考增量。
*/
static appendThinkingDelta(
items: ChatTimeTimelineItem[],
delta?: string,
created?: number | string,
) {
const normalizedDelta = normalizePlainText(delta);
if (!normalizedDelta) {
return;
}
const assistant = ensureAssistantTail(items, created);
const tail = assistant.segments[assistant.segments.length - 1];
if (tail?.type === 'thinking' && tail.status === 'thinking') {
tail.content += normalizedDelta;
} else {
assistant.segments.push({
content: normalizedDelta,
expanded: false,
id: uuid(),
status: 'thinking',
type: 'thinking',
});
}
assistant.loading = true;
assistant.typing = true;
}
/**
* 追加回答增量。
*/
static appendMessageDelta(
items: ChatTimeTimelineItem[],
delta?: string,
created?: number | string,
) {
const normalizedDelta = normalizeAssistantText(delta);
if (!normalizedDelta) {
return;
}
const assistant = ensureAssistantTail(items, created);
stopThinkingForAssistant(assistant);
const tail = assistant.segments[assistant.segments.length - 1];
if (tail?.type === 'text') {
tail.content += normalizedDelta;
} else {
assistant.segments.push({
content: normalizedDelta,
id: uuid(),
type: 'text',
});
}
assistant.content += normalizedDelta;
assistant.loading = false;
assistant.typing = true;
}
/**
* 停止当前 assistant 的思考态。
*/
static stopThinking(items: ChatTimeTimelineItem[]) {
const last = items[items.length - 1];
if (!isAssistantItem(last)) {
return;
}
stopThinkingForAssistant(last);
}
/**
* 更新工具调用状态。
*/
static upsertToolCall(
items: ChatTimeTimelineItem[],
payload: ChatTimeToolMutationPayload,
) {
this.stopThinking(items);
const toolItem = ensureToolItem(
items,
payload.toolCallId,
payload.created,
payload.name,
);
toolItem.arguments = normalizePayloadValue(payload.value);
toolItem.content = '';
toolItem.status = 'TOOL_CALL';
}
/**
* 更新工具结果状态。
*/
static upsertToolResult(
items: ChatTimeTimelineItem[],
payload: ChatTimeToolMutationPayload,
) {
const toolItem = ensureToolItem(
items,
payload.toolCallId,
payload.created,
payload.name,
);
toolItem.result = normalizePayloadValue(payload.result);
toolItem.content = toolItem.result;
toolItem.status = 'TOOL_RESULT';
}
/**
* 追加系统错误。
*/
static applySystemError(
items: ChatTimeTimelineItem[],
message?: string,
created?: number | string,
) {
const errorMessage = normalizePlainText(message);
if (!errorMessage) {
return;
}
const last = items[items.length - 1];
if (isAssistantItem(last)) {
stopThinkingForAssistant(last);
appendAssistantText(last, errorMessage);
last.loading = false;
last.typing = false;
return;
}
const assistant = createAssistantItem(created);
appendAssistantText(assistant, errorMessage);
assistant.loading = false;
assistant.typing = false;
items.push(assistant);
}
/**
* 结束当前轮的 assistant 状态。
*/
static finalize(items: ChatTimeTimelineItem[]) {
const last = items[items.length - 1];
if (!isAssistantItem(last)) {
return;
}
stopThinkingForAssistant(last);
last.loading = false;
last.typing = false;
}
}
/**
* 聊天时间线历史映射器。
*/
class ChatTimeHistoryMapper {
/**
* 从聊天历史记录恢复时间线。
*/
static fromHistoryRecords(records: ChatTimeHistoryRecord[]) {
return records.flatMap((record) => this.fromHistoryRecord(record));
}
/**
* 从旧版消息列表恢复时间线。
*/
static fromLegacyMessages(records: ChatTimeHistoryRecord[]) {
return records.flatMap((record) => this.fromLegacyRecord(record));
}
private static fromHistoryRecord(record: ChatTimeHistoryRecord) {
const role = normalizeRole(record.senderRole || record.role);
if (role === 'user') {
return [createUserItem(record)];
}
if (role === 'tool') {
return [createToolItemFromTopLevelRecord(record)];
}
if (role !== 'assistant') {
return [];
}
const payload = toObjectRecord(record.contentPayload);
const messageChain = toObjectArray(payload.messageChain);
if (messageChain.length > 0) {
const structuredItems = this.fromStructuredAssistantRecord(
record,
messageChain,
);
if (structuredItems.length > 0) {
return structuredItems;
}
}
return this.fromLegacyRecord(record);
}
private static fromLegacyRecord(record: ChatTimeHistoryRecord) {
const role = normalizeRole(record.senderRole || record.role);
if (role === 'user') {
return [createUserItem(record)];
}
if (role === 'tool') {
return [createToolItemFromTopLevelRecord(record)];
}
if (role !== 'assistant') {
return [];
}
const content = normalizeAssistantText(record.contentText || record.content);
const chains = extractDisplayChains(record);
const assistant = createAssistantItem(record.created, {
id: record.id == null ? undefined : String(record.id),
loading: record.loading,
senderName: record.senderName,
typing: record.typing,
});
const tools: ChatTimeTimelineItem[] = [];
for (const rawChain of chains) {
const reasoning = normalizePlainText(rawChain.reasoning_content);
if (reasoning) {
assistant.segments.push({
content: reasoning,
expanded: Boolean(rawChain.thinkingExpanded),
id: uuid(),
status: normalizeThinkingStatus(rawChain.thinkingStatus),
type: 'thinking',
});
continue;
}
const toolItem = createToolItemFromChain(rawChain, record.created);
if (toolItem) {
tools.push(toolItem);
}
}
if (content) {
appendAssistantText(assistant, content);
}
const results: ChatTimeTimelineItem[] = [];
if (assistant.segments.length > 0) {
assistant.loading = false;
assistant.typing = false;
results.push(assistant);
}
return [...results, ...tools];
}
private static fromStructuredAssistantRecord(
record: ChatTimeHistoryRecord,
messageChain: Array<Record<string, any>>,
) {
const toolMetaMap = new Map<string, ChatTimeToolMeta>();
const items: ChatTimeTimelineItem[] = [];
let assistantIndex = 0;
for (const rawMessage of messageChain) {
const role = normalizeRole(rawMessage.role);
if (role === 'assistant') {
collectToolMeta(rawMessage, toolMetaMap);
const assistant = createAssistantItemFromStructuredMessage(
rawMessage,
record,
assistantIndex,
);
if (assistant) {
items.push(assistant);
assistantIndex += 1;
}
continue;
}
if (role === 'tool') {
items.push(
createToolItemFromStructuredMessage(
rawMessage,
toolMetaMap,
record.created,
),
);
}
}
return items;
}
}
function createAssistantItem(
created?: number | string,
patch?: Partial<ChatTimeAssistantItem>,
): ChatTimeAssistantItem {
return {
content: patch?.content || '',
created: normalizeTimestamp(created),
id: patch?.id || uuid(),
loading: patch?.loading,
placement: 'start',
role: 'assistant',
segments: patch?.segments ? [...patch.segments] : [],
senderName: patch?.senderName,
typing: patch?.typing,
};
}
function createAssistantItemFromStructuredMessage(
rawMessage: Record<string, any>,
record: ChatTimeHistoryRecord,
assistantIndex: number,
) {
const content = normalizeAssistantText(rawMessage.content);
const reasoning = normalizePlainText(
rawMessage.reasoningContent ?? rawMessage.reasoning_content,
);
if (!content && !reasoning) {
return null;
}
const assistant = createAssistantItem(record.created, {
id:
record.id == null
? undefined
: `${String(record.id)}-assistant-${assistantIndex}`,
loading: false,
senderName: record.senderName,
typing: false,
});
if (reasoning) {
assistant.segments.push({
content: reasoning,
expanded: false,
id: uuid(),
status: 'end',
type: 'thinking',
});
}
if (content) {
appendAssistantText(assistant, content);
}
return assistant;
}
function createToolItemFromChain(
rawChain: Record<string, any>,
created?: number | string,
) {
const toolCallId = normalizePlainText(rawChain.id);
const name = normalizePlainText(rawChain.name);
const argumentsValue = normalizePayloadValue(rawChain.arguments ?? rawChain.result);
const status = normalizeToolStatus(rawChain.status);
if (!toolCallId && !name && !argumentsValue) {
return null;
}
return createToolItem({
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
created,
id: toolCallId || uuid(),
name,
result: status === 'TOOL_RESULT' ? argumentsValue : undefined,
status,
toolCallId,
});
}
function createToolItemFromStructuredMessage(
rawMessage: Record<string, any>,
toolMetaMap: Map<string, ChatTimeToolMeta>,
created?: number | string,
) {
const toolCallId = normalizePlainText(
rawMessage.toolCallId ?? rawMessage.tool_call_id,
);
const toolMeta = toolMetaMap.get(toolCallId);
const result = normalizePayloadValue(rawMessage.content);
return createToolItem({
arguments: toolMeta?.arguments,
created,
id: toolCallId || uuid(),
name: toolMeta?.name,
result,
status: 'TOOL_RESULT',
toolCallId,
});
}
function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
const payload = toObjectRecord(record.contentPayload);
const toolCallId = normalizePlainText(
payload.toolCallId ?? payload.tool_call_id ?? record.id,
);
return createToolItem({
created: record.created,
id: record.id == null ? toolCallId || uuid() : String(record.id),
name: normalizePlainText(payload.name),
result: normalizePayloadValue(
payload.content ?? payload.result ?? record.contentText ?? record.content,
),
status: 'TOOL_RESULT',
toolCallId,
});
}
function createToolItem(payload: {
arguments?: string;
created?: number | string;
id?: string;
name?: string;
result?: string;
status: ChatTimeToolStatus;
toolCallId?: string;
}): ChatTimeToolItem {
return {
arguments: payload.arguments,
content: payload.result || '',
created: normalizeTimestamp(payload.created),
id: payload.id || payload.toolCallId || uuid(),
name: payload.name || '',
placement: 'start',
result: payload.result,
role: 'tool',
status: payload.status,
toolCallId: payload.toolCallId || payload.id || uuid(),
};
}
function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
return {
content: normalizePlainText(record.contentText || record.content),
created: normalizeTimestamp(record.created),
id: record.id == null ? uuid() : String(record.id),
loading: record.loading,
placement: record.placement || 'end',
role: 'user',
senderName: record.senderName,
typing: record.typing,
};
}
function appendAssistantText(item: ChatTimeAssistantItem, content: string) {
const normalized = normalizeAssistantText(content);
if (!normalized) {
return;
}
item.content += normalized;
item.segments.push({
content: normalized,
id: uuid(),
type: 'text',
});
}
function collectToolMeta(
rawMessage: Record<string, any>,
toolMetaMap: Map<string, ChatTimeToolMeta>,
) {
const toolCalls = toObjectArray(rawMessage.toolCalls ?? rawMessage.tool_calls);
for (const toolCall of toolCalls) {
const toolCallId = normalizePlainText(toolCall.id);
if (!toolCallId) {
continue;
}
toolMetaMap.set(toolCallId, {
arguments: normalizePayloadValue(toolCall.arguments),
name: normalizePlainText(toolCall.name),
});
}
}
function ensureAssistantTail(
items: ChatTimeTimelineItem[],
created?: number | string,
) {
const last = items[items.length - 1];
if (isAssistantItem(last)) {
return last;
}
const assistant = createAssistantItem(created, {
loading: true,
typing: true,
});
items.push(assistant);
return assistant;
}
function ensureToolItem(
items: ChatTimeTimelineItem[],
toolCallId?: string,
created?: number | string,
name?: string,
) {
const normalizedToolCallId = normalizePlainText(toolCallId);
const found = findToolItem(items, normalizedToolCallId);
if (found) {
if (name) {
found.name = name;
}
return found;
}
const toolItem = createToolItem({
created,
id: normalizedToolCallId || uuid(),
name,
status: 'TOOL_CALL',
toolCallId: normalizedToolCallId,
});
items.push(toolItem);
return toolItem;
}
function findToolItem(items: ChatTimeTimelineItem[], toolCallId?: string) {
if (toolCallId) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (isToolItem(item) && item.toolCallId === toolCallId) {
return item;
}
}
}
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (isToolItem(item) && item.status === 'TOOL_CALL') {
return item;
}
}
return null;
}
function extractDisplayChains(record: ChatTimeHistoryRecord) {
if (Array.isArray(record.chains)) {
return record.chains.map((item) => toObjectRecord(item));
}
const payload = toObjectRecord(record.contentPayload);
if (Array.isArray(payload.chains)) {
return payload.chains.map((item: unknown) => toObjectRecord(item));
}
return [];
}
function isAssistantItem(
item?: ChatTimeTimelineItem,
): item is ChatTimeAssistantItem {
return item?.role === 'assistant';
}
function isToolItem(item?: ChatTimeTimelineItem): item is ChatTimeToolItem {
return item?.role === 'tool';
}
function normalizeAssistantText(value: any) {
return normalizePlainText(value)
.replace(/^Final Answer:\s*/i, '')
.replaceAll('```echartsoption', '```echarts\noption');
}
function normalizePayloadValue(value: any) {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function normalizePlainText(value: any) {
return typeof value === 'string' ? value : value == null ? '' : String(value);
}
function normalizeRole(value: any) {
return normalizePlainText(value).trim().toLowerCase();
}
function normalizeThinkingStatus(value: any): ChatTimeThinkingStatus {
return normalizeRole(value) === 'thinking' ? 'thinking' : 'end';
}
function normalizeTimestamp(value?: number | string) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? value : parsed;
}
return Date.now();
}
function normalizeToolStatus(value: any): ChatTimeToolStatus {
return normalizePlainText(value).toUpperCase() === 'TOOL_CALL'
? 'TOOL_CALL'
: 'TOOL_RESULT';
}
function stopThinkingForAssistant(item: ChatTimeAssistantItem) {
item.segments = item.segments.map(
(segment: ChatTimeAssistantItem['segments'][number]) => {
if (segment.type !== 'thinking' || segment.status !== 'thinking') {
return segment;
}
return {
...segment,
status: 'end' as ChatTimeThinkingStatus,
};
},
);
}
function toObjectArray(value: any): Array<Record<string, any>> {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item: unknown) => toObjectRecord(item))
.filter((item) => Object.keys(item).length > 0);
}
function toObjectRecord(value: any): Record<string, any> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, any>;
}
export { ChatTimeHistoryMapper, ChatTimeTimelineBuilder };

View File

@@ -1,3 +1,4 @@
export * from './chat-time';
export * from './find-menu-by-path';
export * from './generate-menus';
export * from './generate-routes-backend';

View File

@@ -1718,6 +1718,9 @@ importers:
'@easyflow-core/typings':
specifier: workspace:*
version: link:../@core/base/typings
'@easyflow/types':
specifier: workspace:*
version: link:../types
vue-router:
specifier: 'catalog:'
version: 4.6.3(vue@3.5.24(typescript@5.9.3))

View File

@@ -1,10 +1,16 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
import { ref } from 'vue';
import { ChatThinkingBlock } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import { useUserStore } from '@easyflow/stores';
import { CircleCheck } from '@element-plus/icons-vue';
import { ElAvatar, ElCollapse, ElCollapseItem, ElIcon } from 'element-plus';
import { ElAvatar, ElIcon } from 'element-plus';
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
@@ -12,10 +18,11 @@ import ShowJson from '#/components/json/ShowJson.vue';
interface Props {
bot: any;
messages: any[];
messages: ChatTimeTimelineItem[];
}
const props = defineProps<Props>();
const store = useUserStore();
const expandedToolState = ref<Record<string, boolean>>({});
function getAssistantAvatar() {
return props.bot.icon || defaultAssistantAvatar;
@@ -23,6 +30,35 @@ function getAssistantAvatar() {
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],
};
}
</script>
<template>
@@ -30,9 +66,7 @@ function getUserAvatar() {
<!-- 自定义头像 -->
<template #avatar="{ item }">
<ElAvatar
:src="
item.role === 'assistant' ? getAssistantAvatar() : getUserAvatar()
"
:src="item.role === 'user' ? getUserAvatar() : getAssistantAvatar()"
:size="40"
/>
</template>
@@ -41,63 +75,80 @@ function getUserAvatar() {
<template #header="{ item }">
<div class="flex flex-col">
<span class="text-foreground/50 text-xs">
{{ item.created }}
{{ formatTime(item.created) }}
</span>
<template v-if="item.chains">
<template
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ChatThinkingBlock
v-if="!('id' in 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>工具调用中...</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>调用成功</span>
</div>
</template>
</div>
</template>
<ShowJson :value="chain.result" />
</ElCollapseItem>
</ElCollapse>
</template>
</template>
</div>
</template>
<!-- 自定义气泡内容 -->
<template #content="{ item }">
<ElXMarkdown :markdown="item.content" />
<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"
/>
<ElXMarkdown v-else :markdown="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>
<ElXMarkdown v-else :markdown="item.content" />
</template>
<!-- 自定义底部 -->
@@ -130,29 +181,94 @@ function getUserAvatar() {
}
.chat-thinking-block-item {
margin-bottom: 8px;
margin-bottom: 0;
}
:deep(.chat-tool-panel.el-collapse) {
.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;
border: 1px solid hsl(var(--divider-faint) / 0.26);
border-radius: 14px;
background: hsl(var(--surface-panel) / 0.7);
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 0.2);
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.chat-tool-panel .el-collapse-item__wrap) {
background: transparent;
.chat-tool-meta {
display: inline-flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
}
:deep(.chat-tool-panel .el-collapse-item__header) {
min-height: 44px;
padding-right: 14px;
background: transparent;
border-bottom-color: hsl(var(--divider-faint) / 0.16);
.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;
}
:deep(.chat-tool-panel .el-collapse-item__content) {
padding-bottom: 0;
.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>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { nextTick, provide, ref, watch } from 'vue';
import { IconifyIcon } from '@easyflow/icons';
import { cn } from '@easyflow/utils';
import { ChatTimeHistoryMapper, cn } from '@easyflow/utils';
import { Delete, Edit, MoreFilled } from '@element-plus/icons-vue';
import {
@@ -29,7 +31,7 @@ import { $t } from '#/locales';
interface Props {
bot: any;
isFold: boolean;
onMessageList?: (list: any[]) => void;
onMessageList?: (list: ChatTimeTimelineItem[]) => void;
toggleFold: () => void;
}
const props = defineProps<Props>();
@@ -101,19 +103,7 @@ function getMessageList() {
if (res.errorCode === 0) {
const records = Array.isArray(res.data?.records) ? [...res.data.records] : [];
props.onMessageList?.(
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,
placement: item.senderRole === 'assistant' ? 'start' : 'end',
created: item.created,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains
: undefined,
})),
ChatTimeHistoryMapper.fromHistoryRecords(records.reverse()),
);
}
});

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { inject, ref } from 'vue';
import { cloneDeep, uuid } from '@easyflow/utils';
import { ChatTimeTimelineBuilder, uuid } from '@easyflow/utils';
import { Paperclip, Promotion } from '@element-plus/icons-vue';
import { ElButton, ElIcon } from 'element-plus';
@@ -12,33 +11,10 @@ import { ElButton, ElIcon } from 'element-plus';
import { sseClient } from '#/api/request';
import SendingIcon from '#/components/icons/SendingIcon.vue';
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
// import PaperclipIcon from '#/components/icons/PaperclipIcon.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 = BubbleProps & {
chains?: (Think | Tool)[];
key: string;
role: 'assistant' | 'user';
};
interface Props {
conversationId: string | undefined;
bot: any;
addMessage: (message: MessageItem) => void;
updateLastMessage: (item: any) => void;
stopThinking: () => void;
mutateMessages: (mutator: (messages: ChatTimeTimelineItem[]) => void) => void;
}
const props = defineProps<Props>();
@@ -48,7 +24,6 @@ const getSessionList = inject<any>('getSessionList');
const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
openCloseHeader();
};
function sendMessage() {
if (getDisabled()) {
@@ -62,25 +37,15 @@ function sendMessage() {
};
clearSenderFiles();
btnLoading.value = true;
props.addMessage({
key: uuid(),
role: 'user',
placement: 'end',
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendUserMessage(messages, {
content: senderValue.value,
typing: true,
created: Date.now(),
id: uuid(),
});
props.addMessage({
key: uuid(),
role: 'assistant',
placement: 'start',
content: '',
loading: true,
typing: true,
});
senderValue.value = '';
let content = '';
sseClient.post('/userCenter/bot/chat', data, {
onMessage(res) {
if (!res.data) {
@@ -100,127 +65,90 @@ function sendMessage() {
sseData.payload?.code === 'SYSTEM_ERROR'
) {
const errorMessage = sseData.payload.message;
props.updateLastMessage({
content: errorMessage,
loading: false,
typing: false,
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.applySystemError(messages, errorMessage);
});
return;
}
if (sseData?.domain === 'TOOL') {
props.updateLastMessage((message: MessageItem) => {
const chains = cloneDeep(message.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,
props.mutateMessages((messages) => {
if (sseData?.type === 'TOOL_CALL') {
ChatTimeTimelineBuilder.upsertToolCall(messages, {
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,
};
return;
}
return { chains };
ChatTimeTimelineBuilder.upsertToolResult(messages, {
created: Date.now(),
name: sseData?.payload?.name,
result: sseData?.payload?.result,
toolCallId: sseData?.payload?.tool_call_id,
});
});
props.stopThinking();
return;
}
if (sseData.type === 'THINKING') {
props.updateLastMessage((message: MessageItem) => {
const chains = cloneDeep(message.chains ?? []);
const index = chains.findIndex(
(chain) => isThink(chain) && chain.thinkingStatus === 'thinking',
);
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,
};
}
return { chains };
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendThinkingDelta(messages, delta, Date.now());
});
} else if (sseData.type === 'MESSAGE') {
props.updateLastMessage({
thinkingStatus: 'end',
loading: false,
content: (content += delta),
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.appendMessageDelta(messages, delta, Date.now());
});
props.stopThinking();
}
},
onError(err) {
console.error(err);
btnLoading.value = false;
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.finalize(messages);
});
},
onFinished() {
senderValue.value = '';
btnLoading.value = false;
props.updateLastMessage({ loading: false });
props.stopThinking();
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.finalize(messages);
});
},
});
}
const isTool = (item: Think | Tool) => {
return 'id' in item;
};
const isThink = (item: Think | Tool): item is Think => {
return !('id' in item);
};
function getDisabled() {
return !senderValue.value || !props.conversationId;
}
const stopSse = () => {
sseClient.abort();
btnLoading.value = false;
props.mutateMessages((messages) => {
ChatTimeTimelineBuilder.finalize(messages);
});
};
const showHeaderFlog = ref(false);
const attachmentsRef = ref();
const senderRef = ref();
const files = ref<any[]>([]);
function handlePasteFile(_: any, fileList: FileList) {
showHeaderFlog.value = true;
senderRef.value?.openHeader();
files.value = [...fileList];
}
function openCloseHeader() {
if (showHeaderFlog.value) {
senderRef.value?.closeHeader();
function triggerFileSelect() {
attachmentsRef.value?.triggerFileSelect?.();
}
function handleDeleteAllSenderFiles() {
files.value = [];
} else {
senderRef.value?.openHeader();
}
showHeaderFlog.value = !showHeaderFlog.value;
}
</script>
<template>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
@delete-all="handleDeleteAllSenderFiles"
:max-size="10"
/>
<ElSender
ref="senderRef"
v-model="senderValue"
variant="updown"
:auto-size="{ minRows: 2, maxRows: 5 }"
@@ -230,21 +158,9 @@ function openCloseHeader() {
@keyup.enter="sendMessage"
@paste-file="handlePasteFile"
>
<!-- 自定义 prefix 前缀 -->
<!-- <template #prefix>
</template> -->
<!-- 自定义头部内容 -->
<template #header>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
@delete-all="openCloseHeader"
:max-size="10"
/>
</template>
<template #action-list>
<div class="flex items-center gap-2">
<ElButton circle @click="openCloseHeader">
<ElButton circle @click="triggerFileSelect">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<!-- <ElButton :icon="PaperclipIcon" link /> -->

View File

@@ -8,11 +8,15 @@ import { JsonViewer } from 'vue3-json-viewer';
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
defineProps({
value: {
required: true,
withDefaults(
defineProps<{
value: unknown;
plain?: boolean;
}>(),
{
plain: false,
},
});
);
const emptyImageUrl = `${import.meta.env.BASE_URL || '/'}empty.png`;
const themeMode = ref(preferences.theme.mode);
@@ -25,7 +29,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="emptyImageUrl" v-else />
</div>
@@ -37,4 +41,11 @@ watch(
border-radius: var(--el-border-radius-base);
padding: 10px;
}
.res-container.is-plain {
padding: 0;
background: transparent;
border: none;
border-radius: 0;
}
</style>

View File

@@ -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>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { cloneDeep } from '@easyflow/utils';
import { ArrowLeft, Minus, Plus } from '@element-plus/icons-vue';
import {
ElAside,
@@ -81,40 +81,12 @@ function removeBotFromRecentlyUsed(botId: any) {
}
});
}
const messageList = ref<any>([]);
function addMessage(message: any) {
messageList.value.push(message);
const messageList = ref<ChatTimeTimelineItem[]>([]);
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
const next = [...messageList.value];
mutator(next);
messageList.value = next;
}
function updateLastMessage(item: any) {
const lastIndex = messageList.value.length - 1;
let message = item;
if (typeof item === 'function') {
message = item(messageList.value[lastIndex]);
}
if (lastIndex >= 0) {
messageList.value[lastIndex] = {
...messageList.value[lastIndex],
...message,
};
}
}
const stopThinking = () => {
const lastIndex = messageList.value.length - 1;
if (lastIndex >= 0 && messageList.value[lastIndex]?.chains) {
const chains = cloneDeep(messageList.value[lastIndex].chains);
for (const chain of chains) {
if (!('id' in chain) && chain.thinkingStatus === 'thinking') {
chain.thinkingStatus = 'end';
}
}
messageList.value[lastIndex].chains = chains;
}
};
</script>
<template>
@@ -145,11 +117,9 @@ const stopThinking = () => {
<ChatBubbleList v-else :bot="botInfo" :messages="messageList" />
<ChatSender
class="absolute bottom-5 left-0 w-full"
:add-message="addMessage"
:update-last-message="updateLastMessage"
:stop-thinking="stopThinking"
:bot="botInfo"
:conversation-id="conversationId"
:mutate-messages="mutateMessages"
/>
</div>
</ElMain>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@easyflow/icons';
import { cloneDeep, cn } from '@easyflow/utils';
import { cn } from '@easyflow/utils';
import { ElAside, ElContainer, ElMain } from 'element-plus';
@@ -34,43 +36,15 @@ function getAssistantList() {
}
});
}
const messageList = ref<any>([]);
function addMessage(message: any) {
messageList.value.push(message);
}
function updateLastMessage(item: any) {
const lastIndex = messageList.value.length - 1;
let message = item;
if (typeof item === 'function') {
message = item(messageList.value[lastIndex]);
}
if (lastIndex >= 0) {
messageList.value[lastIndex] = {
...messageList.value[lastIndex],
...message,
};
}
}
const stopThinking = () => {
const lastIndex = messageList.value.length - 1;
if (lastIndex >= 0 && messageList.value[lastIndex]?.chains) {
const chains = cloneDeep(messageList.value[lastIndex].chains);
for (const chain of chains) {
if (!('id' in chain) && chain.thinkingStatus === 'thinking') {
chain.thinkingStatus = 'end';
}
}
messageList.value[lastIndex].chains = chains;
}
};
function setMessageList(messages: any) {
const messageList = ref<ChatTimeTimelineItem[]>([]);
function setMessageList(messages: ChatTimeTimelineItem[]) {
messageList.value = messages;
}
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
const next = [...messageList.value];
mutator(next);
messageList.value = next;
}
const isFold = ref(false);
const toggleFold = () => {
isFold.value = !isFold.value;
@@ -95,11 +69,9 @@ const toggleFold = () => {
<ChatBubbleList :bot="currentBot" :messages="messageList" />
<div class="mx-auto w-full max-w-[1000px]">
<ChatSender
:add-message="addMessage"
:update-last-message="updateLastMessage"
:stop-thinking="stopThinking"
:bot="currentBot"
:conversation-id="conversationId"
:mutate-messages="mutateMessages"
/>
</div>
</div>

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import type { ChatTimeTimelineItem } from '@easyflow/types';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ChatTimeHistoryMapper } from '@easyflow/utils';
import { Delete, Edit, Search } from '@element-plus/icons-vue';
import {
ElButton,
@@ -43,7 +46,8 @@ const pageState = ref({
const drawerVisible = ref(false);
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,
@@ -123,6 +127,7 @@ async function openSession(sessionId: string | number) {
}
currentSession.value = summaryRes.data;
messageList.value = [];
loadedMessageRecordCount.value = 0;
messagePage.value = {
total: 0,
pageNumber: 1,
@@ -162,32 +167,22 @@ async function loadMessages(reset = false) {
} else {
messageList.value = [...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,
placement: item.senderRole === 'assistant' ? 'start' : 'end',
created: item.created,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains
: undefined,
}));
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
}
function closeDrawer() {
drawerVisible.value = false;
currentSession.value = undefined;
messageList.value = [];
loadedMessageRecordCount.value = 0;
router.replace({ path: '/chatHistory', query: {} });
}
@@ -349,7 +344,7 @@ function formatTime(value?: string) {
<div class="flex-1 overflow-hidden">
<div class="mb-4 flex justify-center">
<ElButton
v-if="messageList.length < messagePage.total"
v-if="loadedMessageRecordCount < messagePage.total"
text
type="primary"
@click="loadMessages(false)"

View File

@@ -0,0 +1,96 @@
type ChatTimeTimelineRole = 'assistant' | 'tool' | 'user';
type ChatTimeToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
type ChatTimeThinkingStatus = 'end' | 'thinking';
interface ChatTimeTimelineItemBase {
created: number | string;
id: string;
loading?: boolean;
placement: 'end' | 'start';
role: ChatTimeTimelineRole;
senderName?: string;
typing?: boolean;
}
interface ChatTimeAssistantThinkingSegment {
content: string;
expanded: boolean;
id: string;
status: ChatTimeThinkingStatus;
type: 'thinking';
}
interface ChatTimeAssistantTextSegment {
content: string;
id: string;
type: 'text';
}
type ChatTimeAssistantSegment =
| ChatTimeAssistantTextSegment
| ChatTimeAssistantThinkingSegment;
interface ChatTimeAssistantItem extends ChatTimeTimelineItemBase {
content: string;
role: 'assistant';
segments: ChatTimeAssistantSegment[];
}
interface ChatTimeToolItem extends ChatTimeTimelineItemBase {
arguments?: string;
content: string;
name: string;
result?: string;
role: 'tool';
status: ChatTimeToolStatus;
toolCallId: string;
}
interface ChatTimeUserItem extends ChatTimeTimelineItemBase {
content: string;
role: 'user';
}
type ChatTimeTimelineItem =
| ChatTimeAssistantItem
| ChatTimeToolItem
| ChatTimeUserItem;
interface ChatTimeHistoryRecord {
chains?: Array<Record<string, any>>;
content?: string;
contentPayload?: null | Record<string, any>;
contentText?: string;
created?: number | string;
id?: number | string;
loading?: boolean;
placement?: 'end' | 'start';
role?: string;
senderName?: string;
senderRole?: string;
typing?: boolean;
}
interface ChatTimeToolMutationPayload {
created?: number | string;
name?: string;
result?: any;
toolCallId?: string;
value?: any;
}
export type {
ChatTimeAssistantItem,
ChatTimeAssistantSegment,
ChatTimeAssistantTextSegment,
ChatTimeAssistantThinkingSegment,
ChatTimeHistoryRecord,
ChatTimeThinkingStatus,
ChatTimeTimelineItem,
ChatTimeTimelineItemBase,
ChatTimeTimelineRole,
ChatTimeToolItem,
ChatTimeToolMutationPayload,
ChatTimeToolStatus,
ChatTimeUserItem,
};

View File

@@ -1,3 +1,4 @@
export type * from './bot';
export type * from './chat-time';
export type * from './user';
export type * from '@easyflow-core/typings';

View File

@@ -15,6 +15,7 @@
"dependencies": {
"@easyflow-core/shared": "workspace:*",
"@easyflow-core/typings": "workspace:*",
"@easyflow/types": "workspace:*",
"vue-router": "catalog:"
}
}

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import {
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
} from '../chat-time';
describe('chat-time timeline builder', () => {
it('builds assistant thinking and message in the same assistant item', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendUserMessage(items, {
content: '你好',
created: 1,
id: 'user-1',
});
ChatTimeTimelineBuilder.appendThinkingDelta(items, '先想一下', 2);
ChatTimeTimelineBuilder.appendMessageDelta(items, '最终回答', 3);
ChatTimeTimelineBuilder.finalize(items);
expect(items).toHaveLength(2);
expect(items[1]).toMatchObject({
content: '最终回答',
role: 'assistant',
});
expect(items[1].segments).toMatchObject([
{ content: '先想一下', status: 'end', type: 'thinking' },
{ content: '最终回答', type: 'text' },
]);
});
it('creates a new assistant item after tool result', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
ChatTimeTimelineBuilder.upsertToolCall(items, {
name: 'search_docs',
toolCallId: 'tool-1',
value: '{"query":"java"}',
});
ChatTimeTimelineBuilder.upsertToolResult(items, {
result: '{"hits":1}',
toolCallId: 'tool-1',
});
ChatTimeTimelineBuilder.appendThinkingDelta(items, '继续思考', 2);
ChatTimeTimelineBuilder.appendMessageDelta(items, '第二段回答', 3);
ChatTimeTimelineBuilder.finalize(items);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
expect(items[1]).toMatchObject({
name: 'search_docs',
result: '{"hits":1}',
role: 'tool',
status: 'TOOL_RESULT',
toolCallId: 'tool-1',
});
expect(items[2]).toMatchObject({ content: '第二段回答', role: 'assistant' });
expect(items[2].segments).toMatchObject([
{ content: '继续思考', status: 'end', type: 'thinking' },
{ content: '第二段回答', type: 'text' },
]);
});
});
describe('chat-time history mapper', () => {
it('expands structured messageChain into assistant and tool timeline items', () => {
const items = ChatTimeHistoryMapper.fromHistoryRecords([
{
contentPayload: {
messageChain: [
{
content: '先回答一点',
reasoningContent: '先思考',
role: 'assistant',
toolCalls: [
{
arguments: '{"query":"java"}',
id: 'tool-1',
name: 'search_docs',
},
],
},
{
content: '{"hits":1}',
role: 'tool',
toolCallId: 'tool-1',
},
{
content: '最后总结',
reasoningContent: '继续思考',
role: 'assistant',
},
],
},
created: 100,
id: 'assistant-record',
senderRole: 'assistant',
},
]);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({
content: '先回答一点',
role: 'assistant',
});
expect(items[1]).toMatchObject({
arguments: '{"query":"java"}',
name: 'search_docs',
result: '{"hits":1}',
role: 'tool',
toolCallId: 'tool-1',
});
expect(items[2]).toMatchObject({
content: '最后总结',
role: 'assistant',
});
expect(items[0]?.id).not.toBe(items[2]?.id);
});
it('falls back to legacy chains when messageChain is unavailable', () => {
const items = ChatTimeHistoryMapper.fromLegacyMessages([
{
chains: [
{
reasoning_content: '旧思考',
thinkingStatus: 'end',
},
{
id: 'tool-2',
name: 'legacy_tool',
result: '{"ok":true}',
status: 'TOOL_RESULT',
},
],
content: '旧回答',
created: '2026-05-11 10:00:00',
id: 'legacy-1',
role: 'assistant',
},
]);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
content: '旧回答',
role: 'assistant',
});
expect(items[0].segments).toMatchObject([
{ content: '旧思考', status: 'end', type: 'thinking' },
{ content: '旧回答', type: 'text' },
]);
expect(items[1]).toMatchObject({
name: 'legacy_tool',
result: '{"ok":true}',
role: 'tool',
toolCallId: 'tool-2',
});
});
});

View File

@@ -0,0 +1,666 @@
import type {
ChatTimeAssistantItem,
ChatTimeHistoryRecord,
ChatTimeThinkingStatus,
ChatTimeTimelineItem,
ChatTimeToolItem,
ChatTimeToolMutationPayload,
ChatTimeToolStatus,
} from '../../../types/src/chat-time';
import { uuid } from './uuid';
type ChatTimeToolMeta = {
arguments?: string;
name?: string;
};
/**
* 聊天时间线实时构建器。
*/
class ChatTimeTimelineBuilder {
/**
* 追加用户消息。
*/
static appendUserMessage(
items: ChatTimeTimelineItem[],
payload: {
content?: string;
created?: number | string;
id?: string;
senderName?: string;
},
) {
items.push({
content: normalizePlainText(payload.content),
created: normalizeTimestamp(payload.created),
id: payload.id || uuid(),
placement: 'end',
role: 'user',
senderName: payload.senderName,
});
}
/**
* 追加思考增量。
*/
static appendThinkingDelta(
items: ChatTimeTimelineItem[],
delta?: string,
created?: number | string,
) {
const normalizedDelta = normalizePlainText(delta);
if (!normalizedDelta) {
return;
}
const assistant = ensureAssistantTail(items, created);
const tail = assistant.segments[assistant.segments.length - 1];
if (tail?.type === 'thinking' && tail.status === 'thinking') {
tail.content += normalizedDelta;
} else {
assistant.segments.push({
content: normalizedDelta,
expanded: false,
id: uuid(),
status: 'thinking',
type: 'thinking',
});
}
assistant.loading = true;
assistant.typing = true;
}
/**
* 追加回答增量。
*/
static appendMessageDelta(
items: ChatTimeTimelineItem[],
delta?: string,
created?: number | string,
) {
const normalizedDelta = normalizeAssistantText(delta);
if (!normalizedDelta) {
return;
}
const assistant = ensureAssistantTail(items, created);
stopThinkingForAssistant(assistant);
const tail = assistant.segments[assistant.segments.length - 1];
if (tail?.type === 'text') {
tail.content += normalizedDelta;
} else {
assistant.segments.push({
content: normalizedDelta,
id: uuid(),
type: 'text',
});
}
assistant.content += normalizedDelta;
assistant.loading = false;
assistant.typing = true;
}
/**
* 停止当前 assistant 的思考态。
*/
static stopThinking(items: ChatTimeTimelineItem[]) {
const last = items[items.length - 1];
if (!isAssistantItem(last)) {
return;
}
stopThinkingForAssistant(last);
}
/**
* 更新工具调用状态。
*/
static upsertToolCall(
items: ChatTimeTimelineItem[],
payload: ChatTimeToolMutationPayload,
) {
this.stopThinking(items);
const toolItem = ensureToolItem(
items,
payload.toolCallId,
payload.created,
payload.name,
);
toolItem.arguments = normalizePayloadValue(payload.value);
toolItem.content = '';
toolItem.status = 'TOOL_CALL';
}
/**
* 更新工具结果状态。
*/
static upsertToolResult(
items: ChatTimeTimelineItem[],
payload: ChatTimeToolMutationPayload,
) {
const toolItem = ensureToolItem(
items,
payload.toolCallId,
payload.created,
payload.name,
);
toolItem.result = normalizePayloadValue(payload.result);
toolItem.content = toolItem.result;
toolItem.status = 'TOOL_RESULT';
}
/**
* 追加系统错误。
*/
static applySystemError(
items: ChatTimeTimelineItem[],
message?: string,
created?: number | string,
) {
const errorMessage = normalizePlainText(message);
if (!errorMessage) {
return;
}
const last = items[items.length - 1];
if (isAssistantItem(last)) {
stopThinkingForAssistant(last);
appendAssistantText(last, errorMessage);
last.loading = false;
last.typing = false;
return;
}
const assistant = createAssistantItem(created);
appendAssistantText(assistant, errorMessage);
assistant.loading = false;
assistant.typing = false;
items.push(assistant);
}
/**
* 结束当前轮的 assistant 状态。
*/
static finalize(items: ChatTimeTimelineItem[]) {
const last = items[items.length - 1];
if (!isAssistantItem(last)) {
return;
}
stopThinkingForAssistant(last);
last.loading = false;
last.typing = false;
}
}
/**
* 聊天时间线历史映射器。
*/
class ChatTimeHistoryMapper {
/**
* 从聊天历史记录恢复时间线。
*/
static fromHistoryRecords(records: ChatTimeHistoryRecord[]) {
return records.flatMap((record) => this.fromHistoryRecord(record));
}
/**
* 从旧版消息列表恢复时间线。
*/
static fromLegacyMessages(records: ChatTimeHistoryRecord[]) {
return records.flatMap((record) => this.fromLegacyRecord(record));
}
private static fromHistoryRecord(record: ChatTimeHistoryRecord) {
const role = normalizeRole(record.senderRole || record.role);
if (role === 'user') {
return [createUserItem(record)];
}
if (role === 'tool') {
return [createToolItemFromTopLevelRecord(record)];
}
if (role !== 'assistant') {
return [];
}
const payload = toObjectRecord(record.contentPayload);
const messageChain = toObjectArray(payload.messageChain);
if (messageChain.length > 0) {
const structuredItems = this.fromStructuredAssistantRecord(
record,
messageChain,
);
if (structuredItems.length > 0) {
return structuredItems;
}
}
return this.fromLegacyRecord(record);
}
private static fromLegacyRecord(record: ChatTimeHistoryRecord) {
const role = normalizeRole(record.senderRole || record.role);
if (role === 'user') {
return [createUserItem(record)];
}
if (role === 'tool') {
return [createToolItemFromTopLevelRecord(record)];
}
if (role !== 'assistant') {
return [];
}
const content = normalizeAssistantText(record.contentText || record.content);
const chains = extractDisplayChains(record);
const assistant = createAssistantItem(record.created, {
id: record.id == null ? undefined : String(record.id),
loading: record.loading,
senderName: record.senderName,
typing: record.typing,
});
const tools: ChatTimeTimelineItem[] = [];
for (const rawChain of chains) {
const reasoning = normalizePlainText(rawChain.reasoning_content);
if (reasoning) {
assistant.segments.push({
content: reasoning,
expanded: Boolean(rawChain.thinkingExpanded),
id: uuid(),
status: normalizeThinkingStatus(rawChain.thinkingStatus),
type: 'thinking',
});
continue;
}
const toolItem = createToolItemFromChain(rawChain, record.created);
if (toolItem) {
tools.push(toolItem);
}
}
if (content) {
appendAssistantText(assistant, content);
}
const results: ChatTimeTimelineItem[] = [];
if (assistant.segments.length > 0) {
assistant.loading = false;
assistant.typing = false;
results.push(assistant);
}
return [...results, ...tools];
}
private static fromStructuredAssistantRecord(
record: ChatTimeHistoryRecord,
messageChain: Array<Record<string, any>>,
) {
const toolMetaMap = new Map<string, ChatTimeToolMeta>();
const items: ChatTimeTimelineItem[] = [];
let assistantIndex = 0;
for (const rawMessage of messageChain) {
const role = normalizeRole(rawMessage.role);
if (role === 'assistant') {
collectToolMeta(rawMessage, toolMetaMap);
const assistant = createAssistantItemFromStructuredMessage(
rawMessage,
record,
assistantIndex,
);
if (assistant) {
items.push(assistant);
assistantIndex += 1;
}
continue;
}
if (role === 'tool') {
items.push(
createToolItemFromStructuredMessage(
rawMessage,
toolMetaMap,
record.created,
),
);
}
}
return items;
}
}
function createAssistantItem(
created?: number | string,
patch?: Partial<ChatTimeAssistantItem>,
): ChatTimeAssistantItem {
return {
content: patch?.content || '',
created: normalizeTimestamp(created),
id: patch?.id || uuid(),
loading: patch?.loading,
placement: 'start',
role: 'assistant',
segments: patch?.segments ? [...patch.segments] : [],
senderName: patch?.senderName,
typing: patch?.typing,
};
}
function createAssistantItemFromStructuredMessage(
rawMessage: Record<string, any>,
record: ChatTimeHistoryRecord,
assistantIndex: number,
) {
const content = normalizeAssistantText(rawMessage.content);
const reasoning = normalizePlainText(
rawMessage.reasoningContent ?? rawMessage.reasoning_content,
);
if (!content && !reasoning) {
return null;
}
const assistant = createAssistantItem(record.created, {
id:
record.id == null
? undefined
: `${String(record.id)}-assistant-${assistantIndex}`,
loading: false,
senderName: record.senderName,
typing: false,
});
if (reasoning) {
assistant.segments.push({
content: reasoning,
expanded: false,
id: uuid(),
status: 'end',
type: 'thinking',
});
}
if (content) {
appendAssistantText(assistant, content);
}
return assistant;
}
function createToolItemFromChain(
rawChain: Record<string, any>,
created?: number | string,
) {
const toolCallId = normalizePlainText(rawChain.id);
const name = normalizePlainText(rawChain.name);
const argumentsValue = normalizePayloadValue(rawChain.arguments ?? rawChain.result);
const status = normalizeToolStatus(rawChain.status);
if (!toolCallId && !name && !argumentsValue) {
return null;
}
return createToolItem({
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
created,
id: toolCallId || uuid(),
name,
result: status === 'TOOL_RESULT' ? argumentsValue : undefined,
status,
toolCallId,
});
}
function createToolItemFromStructuredMessage(
rawMessage: Record<string, any>,
toolMetaMap: Map<string, ChatTimeToolMeta>,
created?: number | string,
) {
const toolCallId = normalizePlainText(
rawMessage.toolCallId ?? rawMessage.tool_call_id,
);
const toolMeta = toolMetaMap.get(toolCallId);
const result = normalizePayloadValue(rawMessage.content);
return createToolItem({
arguments: toolMeta?.arguments,
created,
id: toolCallId || uuid(),
name: toolMeta?.name,
result,
status: 'TOOL_RESULT',
toolCallId,
});
}
function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
const payload = toObjectRecord(record.contentPayload);
const toolCallId = normalizePlainText(
payload.toolCallId ?? payload.tool_call_id ?? record.id,
);
return createToolItem({
created: record.created,
id: record.id == null ? toolCallId || uuid() : String(record.id),
name: normalizePlainText(payload.name),
result: normalizePayloadValue(
payload.content ?? payload.result ?? record.contentText ?? record.content,
),
status: 'TOOL_RESULT',
toolCallId,
});
}
function createToolItem(payload: {
arguments?: string;
created?: number | string;
id?: string;
name?: string;
result?: string;
status: ChatTimeToolStatus;
toolCallId?: string;
}): ChatTimeToolItem {
return {
arguments: payload.arguments,
content: payload.result || '',
created: normalizeTimestamp(payload.created),
id: payload.id || payload.toolCallId || uuid(),
name: payload.name || '',
placement: 'start',
result: payload.result,
role: 'tool',
status: payload.status,
toolCallId: payload.toolCallId || payload.id || uuid(),
};
}
function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
return {
content: normalizePlainText(record.contentText || record.content),
created: normalizeTimestamp(record.created),
id: record.id == null ? uuid() : String(record.id),
loading: record.loading,
placement: record.placement || 'end',
role: 'user',
senderName: record.senderName,
typing: record.typing,
};
}
function appendAssistantText(item: ChatTimeAssistantItem, content: string) {
const normalized = normalizeAssistantText(content);
if (!normalized) {
return;
}
item.content += normalized;
item.segments.push({
content: normalized,
id: uuid(),
type: 'text',
});
}
function collectToolMeta(
rawMessage: Record<string, any>,
toolMetaMap: Map<string, ChatTimeToolMeta>,
) {
const toolCalls = toObjectArray(rawMessage.toolCalls ?? rawMessage.tool_calls);
for (const toolCall of toolCalls) {
const toolCallId = normalizePlainText(toolCall.id);
if (!toolCallId) {
continue;
}
toolMetaMap.set(toolCallId, {
arguments: normalizePayloadValue(toolCall.arguments),
name: normalizePlainText(toolCall.name),
});
}
}
function ensureAssistantTail(
items: ChatTimeTimelineItem[],
created?: number | string,
) {
const last = items[items.length - 1];
if (isAssistantItem(last)) {
return last;
}
const assistant = createAssistantItem(created, {
loading: true,
typing: true,
});
items.push(assistant);
return assistant;
}
function ensureToolItem(
items: ChatTimeTimelineItem[],
toolCallId?: string,
created?: number | string,
name?: string,
) {
const normalizedToolCallId = normalizePlainText(toolCallId);
const found = findToolItem(items, normalizedToolCallId);
if (found) {
if (name) {
found.name = name;
}
return found;
}
const toolItem = createToolItem({
created,
id: normalizedToolCallId || uuid(),
name,
status: 'TOOL_CALL',
toolCallId: normalizedToolCallId,
});
items.push(toolItem);
return toolItem;
}
function findToolItem(items: ChatTimeTimelineItem[], toolCallId?: string) {
if (toolCallId) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (isToolItem(item) && item.toolCallId === toolCallId) {
return item;
}
}
}
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (isToolItem(item) && item.status === 'TOOL_CALL') {
return item;
}
}
return null;
}
function extractDisplayChains(record: ChatTimeHistoryRecord) {
if (Array.isArray(record.chains)) {
return record.chains.map((item) => toObjectRecord(item));
}
const payload = toObjectRecord(record.contentPayload);
if (Array.isArray(payload.chains)) {
return payload.chains.map((item: unknown) => toObjectRecord(item));
}
return [];
}
function isAssistantItem(
item?: ChatTimeTimelineItem,
): item is ChatTimeAssistantItem {
return item?.role === 'assistant';
}
function isToolItem(item?: ChatTimeTimelineItem): item is ChatTimeToolItem {
return item?.role === 'tool';
}
function normalizeAssistantText(value: any) {
return normalizePlainText(value)
.replace(/^Final Answer:\s*/i, '')
.replaceAll('```echartsoption', '```echarts\noption');
}
function normalizePayloadValue(value: any) {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function normalizePlainText(value: any) {
return typeof value === 'string' ? value : value == null ? '' : String(value);
}
function normalizeRole(value: any) {
return normalizePlainText(value).trim().toLowerCase();
}
function normalizeThinkingStatus(value: any): ChatTimeThinkingStatus {
return normalizeRole(value) === 'thinking' ? 'thinking' : 'end';
}
function normalizeTimestamp(value?: number | string) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? value : parsed;
}
return Date.now();
}
function normalizeToolStatus(value: any): ChatTimeToolStatus {
return normalizePlainText(value).toUpperCase() === 'TOOL_CALL'
? 'TOOL_CALL'
: 'TOOL_RESULT';
}
function stopThinkingForAssistant(item: ChatTimeAssistantItem) {
item.segments = item.segments.map(
(segment: ChatTimeAssistantItem['segments'][number]) => {
if (segment.type !== 'thinking' || segment.status !== 'thinking') {
return segment;
}
return {
...segment,
status: 'end' as ChatTimeThinkingStatus,
};
},
);
}
function toObjectArray(value: any): Array<Record<string, any>> {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item: unknown) => toObjectRecord(item))
.filter((item) => Object.keys(item).length > 0);
}
function toObjectRecord(value: any): Record<string, any> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, any>;
}
export { ChatTimeHistoryMapper, ChatTimeTimelineBuilder };

View File

@@ -1,3 +1,4 @@
export * from './chat-time';
export * from './clipboard';
export * from './find-menu-by-path';
export * from './generate-menus';