feat: 重构聊天时间线与附件上传交互
- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染 - 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新 - 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
This commit is contained in:
@@ -1,26 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<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 { Close } from '@element-plus/icons-vue';
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { ElButton, ElEmpty, ElIcon, ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
import { CircleCheck, Close } from '@element-plus/icons-vue';
|
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
||||||
import {
|
|
||||||
ElButton,
|
|
||||||
ElCollapse,
|
|
||||||
ElCollapseItem,
|
|
||||||
ElEmpty,
|
|
||||||
ElIcon,
|
|
||||||
ElScrollbar,
|
|
||||||
} from 'element-plus';
|
|
||||||
|
|
||||||
import ShowJson from '#/components/json/ShowJson.vue';
|
|
||||||
|
|
||||||
interface ChatHistoryDetailDrawerProps {
|
interface ChatHistoryDetailDrawerProps {
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
session?: any;
|
session?: any;
|
||||||
messages?: any[];
|
messages?: ChatTimeTimelineItem[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
onLoadMore?: (() => Promise<void> | void) | undefined;
|
onLoadMore?: (() => Promise<void> | void) | undefined;
|
||||||
}
|
}
|
||||||
@@ -38,7 +28,7 @@ const emit = defineEmits<{
|
|||||||
close: [];
|
close: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function formatTime(value?: string) {
|
function formatTime(value?: number | string) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
@@ -58,17 +48,12 @@ function resolveSenderName(item: any) {
|
|||||||
if (item?.senderName) {
|
if (item?.senderName) {
|
||||||
return item.senderName;
|
return item.senderName;
|
||||||
}
|
}
|
||||||
|
if (item?.role === 'tool') {
|
||||||
|
return '工具调用';
|
||||||
|
}
|
||||||
return item?.role === 'assistant' ? '聊天助手' : '聊天用户';
|
return item?.role === 'assistant' ? '聊天助手' : '聊天用户';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isThinkingChain(chain: any) {
|
|
||||||
return !chain?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolStatusText(status?: string) {
|
|
||||||
return status === 'TOOL_RESULT' ? '调用成功' : '工具调用中';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLoadMore() {
|
async function handleLoadMore() {
|
||||||
await props.onLoadMore?.();
|
await props.onLoadMore?.();
|
||||||
}
|
}
|
||||||
@@ -140,7 +125,7 @@ async function handleLoadMore() {
|
|||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
v-for="item in messages"
|
v-for="item in messages"
|
||||||
:key="item.key"
|
:key="item.id"
|
||||||
class="chat-history-detail__message"
|
class="chat-history-detail__message"
|
||||||
:class="`is-${item.role}`"
|
:class="`is-${item.role}`"
|
||||||
>
|
>
|
||||||
@@ -160,61 +145,7 @@ async function handleLoadMore() {
|
|||||||
class="chat-history-detail__message-bubble"
|
class="chat-history-detail__message-bubble"
|
||||||
:class="`is-${item.role}`"
|
:class="`is-${item.role}`"
|
||||||
>
|
>
|
||||||
<div
|
<ChatTimeMessageContent :item="item" readonly-thinking />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
ChatTimeAssistantSegment,
|
||||||
|
ChatTimeTimelineItem,
|
||||||
|
} from '@easyflow/types';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
|
||||||
|
|
||||||
|
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||||
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
|
|
||||||
|
import { CircleCheck } from '@element-plus/icons-vue';
|
||||||
|
import { ElIcon } from 'element-plus';
|
||||||
|
|
||||||
|
import ShowJson from '#/components/json/ShowJson.vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: ChatTimeTimelineItem;
|
||||||
|
readonlyThinking?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
readonlyThinking: false,
|
||||||
|
});
|
||||||
|
const toolExpanded = ref(false);
|
||||||
|
|
||||||
|
const renderableSegments = computed(() => {
|
||||||
|
if (props.item.role !== 'assistant') {
|
||||||
|
return [] as ChatTimeAssistantSegment[];
|
||||||
|
}
|
||||||
|
if (props.item.segments.length > 0) {
|
||||||
|
return props.item.segments;
|
||||||
|
}
|
||||||
|
if (!props.item.content) {
|
||||||
|
return [] as ChatTimeAssistantSegment[];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
content: props.item.content,
|
||||||
|
id: `${props.item.id}-fallback-text`,
|
||||||
|
type: 'text' as const,
|
||||||
|
},
|
||||||
|
] satisfies ChatTimeAssistantSegment[];
|
||||||
|
});
|
||||||
|
|
||||||
|
function getToolName() {
|
||||||
|
if (props.item.role !== 'tool') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return props.item.name || '工具调用';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasToolDetails = computed(() => {
|
||||||
|
return props.item.role === 'tool' && Boolean(props.item.arguments || props.item.result);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleToolExpanded() {
|
||||||
|
if (!hasToolDetails.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toolExpanded.value = !toolExpanded.value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="item.role === 'assistant'">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<template v-for="segment in renderableSegments" :key="segment.id">
|
||||||
|
<ChatThinkingBlock
|
||||||
|
v-if="segment.type === 'thinking'"
|
||||||
|
v-model:expanded="segment.expanded"
|
||||||
|
:content="segment.content"
|
||||||
|
:readonly="readonlyThinking"
|
||||||
|
:status="segment.status"
|
||||||
|
class="chat-thinking-block-item"
|
||||||
|
/>
|
||||||
|
<ElXMarkdown v-else :markdown="segment.content" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="item.role === 'tool'">
|
||||||
|
<div class="chat-tool-panel" :class="{ 'is-expanded': toolExpanded }">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-tool-header"
|
||||||
|
:class="{ 'is-clickable': hasToolDetails }"
|
||||||
|
@click="toggleToolExpanded"
|
||||||
|
>
|
||||||
|
<div class="chat-tool-title">
|
||||||
|
<ElIcon size="14">
|
||||||
|
<IconifyIcon icon="svg:wrench" />
|
||||||
|
</ElIcon>
|
||||||
|
<span class="chat-tool-title-text">{{ getToolName() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-tool-meta">
|
||||||
|
<span
|
||||||
|
class="chat-tool-status"
|
||||||
|
:class="item.status === 'TOOL_CALL' ? 'is-calling' : 'is-done'"
|
||||||
|
>
|
||||||
|
<ElIcon size="13">
|
||||||
|
<IconifyIcon
|
||||||
|
v-if="item.status === 'TOOL_CALL'"
|
||||||
|
icon="mdi:clock-time-five-outline"
|
||||||
|
/>
|
||||||
|
<CircleCheck v-else />
|
||||||
|
</ElIcon>
|
||||||
|
<span>{{ item.status === 'TOOL_CALL' ? '调用中' : '已完成' }}</span>
|
||||||
|
</span>
|
||||||
|
<ElIcon
|
||||||
|
v-if="hasToolDetails"
|
||||||
|
size="14"
|
||||||
|
class="chat-tool-arrow"
|
||||||
|
:class="{ 'is-expanded': toolExpanded }"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="ep:arrow-right" />
|
||||||
|
</ElIcon>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div v-if="toolExpanded && hasToolDetails" class="chat-tool-body">
|
||||||
|
<ShowJson v-if="item.arguments" :value="item.arguments" plain />
|
||||||
|
<ShowJson v-if="item.result" :value="item.result" plain />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElXMarkdown v-else :markdown="item.content" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-thinking-block-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-panel {
|
||||||
|
min-width: 0;
|
||||||
|
background: hsl(var(--surface-panel) / 0.98);
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 0.16);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-header.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-title {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-title-text {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-status {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-status.is-calling {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 0.08);
|
||||||
|
border-color: hsl(var(--primary) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-status.is-done {
|
||||||
|
color: hsl(var(--success));
|
||||||
|
background: hsl(var(--success) / 0.08);
|
||||||
|
border-color: hsl(var(--success) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-arrow {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-arrow.is-expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,8 +5,7 @@ import type {
|
|||||||
} from 'vue-element-plus-x/types/BubbleList';
|
} from 'vue-element-plus-x/types/BubbleList';
|
||||||
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
|
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
|
||||||
|
|
||||||
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
|
import type { BotInfo, ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
import type { BotInfo, ChatMessage } from '@easyflow/types';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
nextTick,
|
nextTick,
|
||||||
@@ -18,59 +17,36 @@ import {
|
|||||||
} from 'vue';
|
} from 'vue';
|
||||||
import ElBubbleList from 'vue-element-plus-x/es/BubbleList/index.js';
|
import ElBubbleList from 'vue-element-plus-x/es/BubbleList/index.js';
|
||||||
import ElSender from 'vue-element-plus-x/es/Sender/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 { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
import { useBotStore } from '@easyflow/stores';
|
import { useBotStore } from '@easyflow/stores';
|
||||||
import { cloneDeep, cn, uuid } from '@easyflow/utils';
|
import {
|
||||||
|
ChatTimeHistoryMapper,
|
||||||
|
ChatTimeTimelineBuilder,
|
||||||
|
cn,
|
||||||
|
uuid,
|
||||||
|
} from '@easyflow/utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowDownBold,
|
ArrowDownBold,
|
||||||
CircleCheck,
|
|
||||||
CopyDocument,
|
CopyDocument,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
RefreshRight,
|
RefreshRight,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
import {
|
import { ElButton, ElIcon, ElMessage, ElSpace } from 'element-plus';
|
||||||
ElButton,
|
|
||||||
ElCollapse,
|
|
||||||
ElCollapseItem,
|
|
||||||
ElIcon,
|
|
||||||
ElMessage,
|
|
||||||
ElSpace,
|
|
||||||
} from 'element-plus';
|
|
||||||
import { tryit } from 'radash';
|
import { tryit } from 'radash';
|
||||||
|
|
||||||
import { getMessageList, getPerQuestions } from '#/api';
|
import { getMessageList, getPerQuestions } from '#/api';
|
||||||
import { api, sseClient } from '#/api/request';
|
import { api, sseClient } from '#/api/request';
|
||||||
|
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
||||||
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
|
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
|
||||||
import SendIcon from '#/components/icons/SendIcon.vue';
|
import SendIcon from '#/components/icons/SendIcon.vue';
|
||||||
import ShowJson from '#/components/json/ShowJson.vue';
|
|
||||||
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
|
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
|
||||||
|
|
||||||
import BotAvatar from '../botAvatar/botAvatar.vue';
|
import BotAvatar from '../botAvatar/botAvatar.vue';
|
||||||
import SendingIcon from '../icons/SendingIcon.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<{
|
const props = defineProps<{
|
||||||
bot?: BotInfo;
|
bot?: BotInfo;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
@@ -90,7 +66,7 @@ const route = useRoute();
|
|||||||
const botId = ref<string>((route.params.id as string) || '');
|
const botId = ref<string>((route.params.id as string) || '');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const bubbleItems = ref<BubbleListProps<MessageItem>['list']>([]);
|
const bubbleItems = ref<BubbleListProps<ChatTimeTimelineItem>['list']>([]);
|
||||||
const bubbleListRef = ref<BubbleListInstance>();
|
const bubbleListRef = ref<BubbleListInstance>();
|
||||||
const messageContainerRef = ref<HTMLElement | null>(null);
|
const messageContainerRef = ref<HTMLElement | null>(null);
|
||||||
const bubbleListScrollElement = ref<HTMLElement | null>(null);
|
const bubbleListScrollElement = ref<HTMLElement | null>(null);
|
||||||
@@ -153,14 +129,9 @@ watchEffect(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res?.errorCode === 0) {
|
if (res?.errorCode === 0) {
|
||||||
bubbleItems.value = res.data.map((item) => ({
|
bubbleItems.value = ChatTimeHistoryMapper.fromHistoryRecords(
|
||||||
...item,
|
res.data as any[],
|
||||||
content:
|
);
|
||||||
item.role === 'assistant'
|
|
||||||
? item.content.replace(/^Final Answer:\s*/i, '')
|
|
||||||
: item.content,
|
|
||||||
placement: item.role === 'assistant' ? 'start' : 'end',
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bubbleItems.value = [];
|
bubbleItems.value = [];
|
||||||
@@ -207,31 +178,17 @@ const bindBubbleListScroll = () => {
|
|||||||
}
|
}
|
||||||
updateBackToBottomButtonVisible();
|
updateBackToBottomButtonVisible();
|
||||||
};
|
};
|
||||||
const updateLastBubbleItem = (patch: Partial<MessageItem>) => {
|
const finalizeTimelineTail = () => {
|
||||||
const lastIndex = bubbleItems.value.length - 1;
|
ChatTimeTimelineBuilder.finalize(bubbleItems.value);
|
||||||
if (lastIndex < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bubbleItems.value[lastIndex] = {
|
|
||||||
...bubbleItems.value[lastIndex]!,
|
|
||||||
...patch,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const finalizeLastBubbleItem = () => {
|
|
||||||
updateLastBubbleItem({
|
|
||||||
loading: false,
|
|
||||||
typing: false,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const stopSse = () => {
|
const stopSse = () => {
|
||||||
sseClient.abort();
|
sseClient.abort();
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
finalizeLastBubbleItem();
|
finalizeTimelineTail();
|
||||||
};
|
};
|
||||||
const clearSenderFiles = () => {
|
const clearSenderFiles = () => {
|
||||||
files.value = [];
|
files.value = [];
|
||||||
attachmentsRef.value?.clearFiles();
|
attachmentsRef.value?.clearFiles();
|
||||||
openCloseHeader();
|
|
||||||
};
|
};
|
||||||
const handleSubmit = async (refreshContent: string) => {
|
const handleSubmit = async (refreshContent: string) => {
|
||||||
const attachments = attachmentsRef.value?.getFileList();
|
const attachments = attachmentsRef.value?.getFileList();
|
||||||
@@ -261,14 +218,11 @@ const handleSubmit = async (refreshContent: string) => {
|
|||||||
sseClient.post('/api/v1/bot/chat', data, {
|
sseClient.post('/api/v1/bot/chat', data, {
|
||||||
onMessage(message) {
|
onMessage(message) {
|
||||||
const event = message.event;
|
const event = message.event;
|
||||||
const lastIndex = bubbleItems.value.length - 1;
|
|
||||||
const lastBubbleItem = bubbleItems.value[lastIndex];
|
|
||||||
|
|
||||||
// finish
|
// finish
|
||||||
if (event === 'done') {
|
if (event === 'done') {
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
finalizeLastBubbleItem();
|
finalizeTimelineTail();
|
||||||
stopThinking();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!message.data) {
|
if (!message.data) {
|
||||||
@@ -280,46 +234,30 @@ const handleSubmit = async (refreshContent: string) => {
|
|||||||
sseData?.domain === 'SYSTEM' &&
|
sseData?.domain === 'SYSTEM' &&
|
||||||
sseData.payload?.code === 'SYSTEM_ERROR'
|
sseData.payload?.code === 'SYSTEM_ERROR'
|
||||||
) {
|
) {
|
||||||
const errorMessage = sseData.payload.message;
|
ChatTimeTimelineBuilder.applySystemError(
|
||||||
if (!lastBubbleItem) return;
|
bubbleItems.value,
|
||||||
bubbleItems.value[lastIndex] = {
|
sseData.payload.message,
|
||||||
...lastBubbleItem,
|
Date.now(),
|
||||||
content: errorMessage,
|
);
|
||||||
loading: false,
|
|
||||||
typing: false,
|
|
||||||
};
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex >= 0 && sseData?.domain === 'TOOL') {
|
if (sseData?.domain === 'TOOL') {
|
||||||
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
|
if (sseData?.type === 'TOOL_CALL') {
|
||||||
const index = chains.findIndex(
|
ChatTimeTimelineBuilder.upsertToolCall(bubbleItems.value, {
|
||||||
(chain) =>
|
created: Date.now(),
|
||||||
isTool(chain) && chain.id === sseData?.payload?.tool_call_id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
chains.push({
|
|
||||||
id: sseData?.payload?.tool_call_id,
|
|
||||||
name: sseData?.payload?.name,
|
name: sseData?.payload?.name,
|
||||||
status: sseData?.type,
|
toolCallId: sseData?.payload?.tool_call_id,
|
||||||
result:
|
value: sseData?.payload?.arguments,
|
||||||
sseData?.type === 'TOOL_CALL'
|
|
||||||
? sseData?.payload?.arguments
|
|
||||||
: sseData?.payload?.result,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
chains[index] = {
|
ChatTimeTimelineBuilder.upsertToolResult(bubbleItems.value, {
|
||||||
...chains[index]!,
|
created: Date.now(),
|
||||||
status: sseData?.type,
|
name: sseData?.payload?.name,
|
||||||
result:
|
result: sseData?.payload?.result,
|
||||||
sseData?.type === 'TOOL_CALL'
|
toolCallId: sseData?.payload?.tool_call_id,
|
||||||
? sseData?.payload?.arguments
|
});
|
||||||
: sseData?.payload?.result,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
bubbleItems.value[lastIndex]!.chains = chains;
|
|
||||||
stopThinking();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,38 +265,19 @@ const handleSubmit = async (refreshContent: string) => {
|
|||||||
const delta = sseData.payload?.delta;
|
const delta = sseData.payload?.delta;
|
||||||
const role = sseData.payload?.role;
|
const role = sseData.payload?.role;
|
||||||
|
|
||||||
if (lastBubbleItem && delta) {
|
if (delta) {
|
||||||
if (sseData.type === 'THINKING') {
|
if (sseData.type === 'THINKING') {
|
||||||
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
|
ChatTimeTimelineBuilder.appendThinkingDelta(
|
||||||
const index = chains.findIndex(
|
bubbleItems.value,
|
||||||
(chain) => isThink(chain) && chain.thinkingStatus === 'thinking',
|
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') {
|
} else if (sseData.type === 'MESSAGE') {
|
||||||
bubbleItems.value[lastIndex] = {
|
ChatTimeTimelineBuilder.appendMessageDelta(
|
||||||
...lastBubbleItem,
|
bubbleItems.value,
|
||||||
content: (lastBubbleItem.content + delta).replaceAll(
|
delta,
|
||||||
'```echartsoption',
|
Date.now(),
|
||||||
'```echarts\noption',
|
);
|
||||||
),
|
|
||||||
loading: false,
|
|
||||||
typing: true,
|
|
||||||
};
|
|
||||||
stopThinking();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,39 +291,16 @@ const handleSubmit = async (refreshContent: string) => {
|
|||||||
},
|
},
|
||||||
onFinished() {
|
onFinished() {
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
finalizeLastBubbleItem();
|
finalizeTimelineTail();
|
||||||
stopThinking();
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
sending.value = false;
|
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) => {
|
const handleComplete = (_: TypewriterInstance, index: number) => {
|
||||||
if (
|
if (
|
||||||
index === bubbleItems.value.length - 1 &&
|
index === bubbleItems.value.length - 1 &&
|
||||||
@@ -421,27 +317,14 @@ const handleComplete = (_: TypewriterInstance, index: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generateMockMessages = (refreshContent: string) => {
|
const generateMockMessages = (refreshContent: string) => {
|
||||||
const userMessage: MessageItem = {
|
const userMessage: ChatTimeTimelineItem = {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
fileList: [],
|
|
||||||
content: refreshContent || senderValue.value,
|
content: refreshContent || senderValue.value,
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
updateAt: Date.now(),
|
|
||||||
placement: 'end',
|
placement: 'end',
|
||||||
};
|
};
|
||||||
|
return [userMessage];
|
||||||
const assistantMessage: MessageItem = {
|
|
||||||
role: 'assistant',
|
|
||||||
id: Date.now().toString(),
|
|
||||||
content: '',
|
|
||||||
loading: true,
|
|
||||||
created: Date.now(),
|
|
||||||
updateAt: Date.now(),
|
|
||||||
placement: 'start',
|
|
||||||
};
|
|
||||||
|
|
||||||
return [userMessage, assistantMessage];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (content: string) => {
|
const handleCopy = (content: string) => {
|
||||||
@@ -462,23 +345,17 @@ const scrollToBottom = () => {
|
|||||||
}
|
}
|
||||||
showBackToBottomButton.value = false;
|
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 attachmentsRef = ref();
|
||||||
const files = ref<any[]>([]);
|
const files = ref<any[]>([]);
|
||||||
function handlePasteFile(_: any, fileList: FileList) {
|
function handlePasteFile(_: any, fileList: FileList) {
|
||||||
showHeaderFlog.value = true;
|
|
||||||
senderRef.value?.openHeader();
|
|
||||||
files.value = [...fileList];
|
files.value = [...fileList];
|
||||||
}
|
}
|
||||||
|
function triggerFileSelect() {
|
||||||
|
attachmentsRef.value?.triggerFileSelect?.();
|
||||||
|
}
|
||||||
|
function handleDeleteAllSenderFiles() {
|
||||||
|
files.value = [];
|
||||||
|
}
|
||||||
watch(
|
watch(
|
||||||
() => [localeConversationId.value, bubbleItems.value.length],
|
() => [localeConversationId.value, bubbleItems.value.length],
|
||||||
() => {
|
() => {
|
||||||
@@ -527,78 +404,30 @@ onBeforeUnmount(() => {
|
|||||||
<span class="chat-bubble-item-time-style">
|
<span class="chat-bubble-item-time-style">
|
||||||
{{ new Date(item.created).toLocaleString() }}
|
{{ new Date(item.created).toLocaleString() }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- 自定义头像 -->
|
<!-- 自定义头像 -->
|
||||||
<template #avatar="{ item }">
|
<template #avatar="{ item }">
|
||||||
<BotAvatar
|
<BotAvatar
|
||||||
v-if="item.role === 'assistant'"
|
v-if="item.role !== 'user'"
|
||||||
:src="bot?.icon"
|
:src="bot?.icon"
|
||||||
:size="40"
|
:size="40"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #content="{ item }">
|
<template #content="{ item }">
|
||||||
<ElXMarkdown :markdown="item.content" />
|
<ChatTimeMessageContent :item="item" />
|
||||||
</template>
|
</template>
|
||||||
<!-- 自定义底部 -->
|
<!-- 自定义底部 -->
|
||||||
<template #footer="{ item }">
|
<template #footer="{ item }">
|
||||||
<ElSpace :size="10">
|
<ElSpace v-if="item.role !== 'tool'" :size="10">
|
||||||
<ElSpace>
|
<ElSpace v-if="item.role === 'assistant'">
|
||||||
<span @click="handleRefresh()" style="cursor: pointer">
|
<span @click="handleRefresh()" style="cursor: pointer">
|
||||||
<ElIcon>
|
<ElIcon>
|
||||||
<RefreshRight />
|
<RefreshRight />
|
||||||
</ElIcon>
|
</ElIcon>
|
||||||
</span>
|
</span>
|
||||||
|
</ElSpace>
|
||||||
|
<ElSpace>
|
||||||
<span @click="handleCopy(item.content)" style="cursor: pointer">
|
<span @click="handleCopy(item.content)" style="cursor: pointer">
|
||||||
<ElIcon>
|
<ElIcon>
|
||||||
<CopyDocument />
|
<CopyDocument />
|
||||||
@@ -643,6 +472,12 @@ onBeforeUnmount(() => {
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
|
<ChatFileUploader
|
||||||
|
ref="attachmentsRef"
|
||||||
|
:external-files="files"
|
||||||
|
:max-size="10"
|
||||||
|
@delete-all="handleDeleteAllSenderFiles"
|
||||||
|
/>
|
||||||
<ElSender
|
<ElSender
|
||||||
ref="senderRef"
|
ref="senderRef"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -654,18 +489,9 @@ onBeforeUnmount(() => {
|
|||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@paste-file="handlePasteFile"
|
@paste-file="handlePasteFile"
|
||||||
>
|
>
|
||||||
<!-- 自定义头部内容 -->
|
|
||||||
<template #header>
|
|
||||||
<ChatFileUploader
|
|
||||||
ref="attachmentsRef"
|
|
||||||
:external-files="files"
|
|
||||||
:max-size="10"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #action-list>
|
<template #action-list>
|
||||||
<ElSpace>
|
<ElSpace>
|
||||||
<ElButton circle @click="openCloseHeader">
|
<ElButton circle @click="triggerFileSelect">
|
||||||
<ElIcon><Paperclip /></ElIcon>
|
<ElIcon><Paperclip /></ElIcon>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<!--<ElButton circle @click="uploadRef.triggerFileSelect()">
|
<!--<ElButton circle @click="uploadRef.triggerFileSelect()">
|
||||||
@@ -770,39 +596,4 @@ onBeforeUnmount(() => {
|
|||||||
:deep(.el-bubble-end .el-bubble-header) {
|
:deep(.el-bubble-end .el-bubble-header) {
|
||||||
width: fit-content;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import { getEmptyStateImageUrl } from '#/utils/assets';
|
|||||||
|
|
||||||
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
|
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
|
||||||
|
|
||||||
defineProps({
|
withDefaults(
|
||||||
value: {
|
defineProps<{
|
||||||
required: true,
|
value: unknown;
|
||||||
|
plain?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
plain: false,
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
const themeMode = ref(preferences.theme.mode);
|
const themeMode = ref(preferences.theme.mode);
|
||||||
watch(
|
watch(
|
||||||
@@ -26,7 +30,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="res-container">
|
<div class="res-container" :class="{ 'is-plain': plain }">
|
||||||
<JsonViewer v-if="value" :value="value" copyable :theme="themeMode" />
|
<JsonViewer v-if="value" :value="value" copyable :theme="themeMode" />
|
||||||
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" v-else />
|
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" v-else />
|
||||||
</div>
|
</div>
|
||||||
@@ -38,4 +42,11 @@ watch(
|
|||||||
border: 1px solid var(--el-border-color);
|
border: 1px solid var(--el-border-color);
|
||||||
border-radius: var(--el-border-radius-base);
|
border-radius: var(--el-border-radius-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.res-container.is-plain {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type SelfFilesCardProps = {
|
|||||||
} & FilesCardProps;
|
} & FilesCardProps;
|
||||||
|
|
||||||
const files = ref<SelfFilesCardProps[]>([]);
|
const files = ref<SelfFilesCardProps[]>([]);
|
||||||
|
const fileInputRef = ref<HTMLInputElement>();
|
||||||
/**
|
/**
|
||||||
* 上传前校验
|
* 上传前校验
|
||||||
*/
|
*/
|
||||||
@@ -45,30 +46,11 @@ function handleBeforeUpload(file: File) {
|
|||||||
return true;
|
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 }) {
|
async function handleHttpRequest(options: { file: File }) {
|
||||||
const { file } = options;
|
const { file } = options;
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.upload(props.action, { file }, {});
|
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);
|
return files.value.map((file) => file.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发原生文件选择
|
||||||
|
*/
|
||||||
|
function triggerFileSelect() {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.externalFiles,
|
() => props.externalFiles,
|
||||||
async (newFiles) => {
|
async (newFiles) => {
|
||||||
@@ -135,6 +136,7 @@ watch(
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getFileList,
|
getFileList,
|
||||||
|
triggerFileSelect,
|
||||||
clearFiles() {
|
clearFiles() {
|
||||||
files.value = [];
|
files.value = [];
|
||||||
},
|
},
|
||||||
@@ -142,17 +144,20 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<Attachments
|
||||||
:http-request="handleHttpRequest"
|
|
||||||
:items="files"
|
:items="files"
|
||||||
drag
|
:hide-upload="true"
|
||||||
:before-upload="handleBeforeUpload"
|
|
||||||
:hide-upload="false"
|
|
||||||
@upload-drop="handleUploadDrop"
|
|
||||||
@delete-card="handleDeleteCard"
|
@delete-card="handleDeleteCard"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileInputChange"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less"></style>
|
<style scoped lang="less"></style>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { useEasyFlowDrawer } from '@easyflow/common-ui';
|
import { useEasyFlowDrawer } from '@easyflow/common-ui';
|
||||||
|
import { ChatTimeHistoryMapper } from '@easyflow/utils';
|
||||||
|
|
||||||
import { Search } from '@element-plus/icons-vue';
|
import { Search } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +46,8 @@ const quickRangeOptions = [
|
|||||||
|
|
||||||
const drawerLoading = ref(false);
|
const drawerLoading = ref(false);
|
||||||
const currentSession = ref<any>();
|
const currentSession = ref<any>();
|
||||||
const messageList = ref<any[]>([]);
|
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||||||
|
const loadedMessageRecordCount = ref(0);
|
||||||
const messagePage = ref({
|
const messagePage = ref({
|
||||||
total: 0,
|
total: 0,
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
@@ -63,7 +67,7 @@ const [Drawer, drawerApi] = useEasyFlowDrawer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hasMoreMessages = computed(
|
const hasMoreMessages = computed(
|
||||||
() => messageList.value.length < messagePage.value.total,
|
() => loadedMessageRecordCount.value < messagePage.value.total,
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSessionId = computed(() =>
|
const selectedSessionId = computed(() =>
|
||||||
@@ -111,6 +115,7 @@ async function openSession(sessionId: number | string) {
|
|||||||
sessions.value.find((item: any) => String(item.id) === String(sessionId)) ||
|
sessions.value.find((item: any) => String(item.id) === String(sessionId)) ||
|
||||||
undefined;
|
undefined;
|
||||||
messageList.value = [];
|
messageList.value = [];
|
||||||
|
loadedMessageRecordCount.value = 0;
|
||||||
messagePage.value = {
|
messagePage.value = {
|
||||||
total: 0,
|
total: 0,
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
@@ -154,31 +159,15 @@ async function loadMessages(reset = false) {
|
|||||||
messageList.value = reset
|
messageList.value = reset
|
||||||
? normalized
|
? normalized
|
||||||
: [...normalized, ...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.total = res.data?.total || 0;
|
||||||
messagePage.value.pageNumber = nextPageNumber;
|
messagePage.value.pageNumber = nextPageNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessages(records: any[]) {
|
function normalizeMessages(records: any[]) {
|
||||||
return [...records].reverse().map((item: any) => ({
|
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
|
||||||
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePage(pageNumber: number) {
|
function changePage(pageNumber: number) {
|
||||||
|
|||||||
96
easyflow-ui-admin/packages/types/src/chat-time.ts
Normal file
96
easyflow-ui-admin/packages/types/src/chat-time.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export type * from './api';
|
export type * from './api';
|
||||||
export type * from './bot';
|
export type * from './bot';
|
||||||
|
export type * from './chat-time';
|
||||||
export type * from './user';
|
export type * from './user';
|
||||||
export type * from '@easyflow-core/typings';
|
export type * from '@easyflow-core/typings';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@easyflow-core/shared": "workspace:*",
|
"@easyflow-core/shared": "workspace:*",
|
||||||
"@easyflow-core/typings": "workspace:*",
|
"@easyflow-core/typings": "workspace:*",
|
||||||
|
"@easyflow/types": "workspace:*",
|
||||||
"vue-router": "catalog:"
|
"vue-router": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
666
easyflow-ui-admin/packages/utils/src/helpers/chat-time.ts
Normal file
666
easyflow-ui-admin/packages/utils/src/helpers/chat-time.ts
Normal 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 };
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './chat-time';
|
||||||
export * from './find-menu-by-path';
|
export * from './find-menu-by-path';
|
||||||
export * from './generate-menus';
|
export * from './generate-menus';
|
||||||
export * from './generate-routes-backend';
|
export * from './generate-routes-backend';
|
||||||
|
|||||||
3
easyflow-ui-admin/pnpm-lock.yaml
generated
3
easyflow-ui-admin/pnpm-lock.yaml
generated
@@ -1718,6 +1718,9 @@ importers:
|
|||||||
'@easyflow-core/typings':
|
'@easyflow-core/typings':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../@core/base/typings
|
version: link:../@core/base/typings
|
||||||
|
'@easyflow/types':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../types
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.6.3(vue@3.5.24(typescript@5.9.3))
|
version: 4.6.3(vue@3.5.24(typescript@5.9.3))
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<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 { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { useUserStore } from '@easyflow/stores';
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import { CircleCheck } from '@element-plus/icons-vue';
|
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 defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
|
||||||
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
|
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
|
||||||
@@ -12,10 +18,11 @@ import ShowJson from '#/components/json/ShowJson.vue';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bot: any;
|
bot: any;
|
||||||
messages: any[];
|
messages: ChatTimeTimelineItem[];
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
const store = useUserStore();
|
const store = useUserStore();
|
||||||
|
const expandedToolState = ref<Record<string, boolean>>({});
|
||||||
|
|
||||||
function getAssistantAvatar() {
|
function getAssistantAvatar() {
|
||||||
return props.bot.icon || defaultAssistantAvatar;
|
return props.bot.icon || defaultAssistantAvatar;
|
||||||
@@ -23,6 +30,35 @@ function getAssistantAvatar() {
|
|||||||
function getUserAvatar() {
|
function getUserAvatar() {
|
||||||
return store.userInfo?.avatar || defaultUserAvatar;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,9 +66,7 @@ function getUserAvatar() {
|
|||||||
<!-- 自定义头像 -->
|
<!-- 自定义头像 -->
|
||||||
<template #avatar="{ item }">
|
<template #avatar="{ item }">
|
||||||
<ElAvatar
|
<ElAvatar
|
||||||
:src="
|
:src="item.role === 'user' ? getUserAvatar() : getAssistantAvatar()"
|
||||||
item.role === 'assistant' ? getAssistantAvatar() : getUserAvatar()
|
|
||||||
"
|
|
||||||
:size="40"
|
:size="40"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -41,63 +75,80 @@ function getUserAvatar() {
|
|||||||
<template #header="{ item }">
|
<template #header="{ item }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-foreground/50 text-xs">
|
<span class="text-foreground/50 text-xs">
|
||||||
{{ item.created }}
|
{{ formatTime(item.created) }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 自定义气泡内容 -->
|
<!-- 自定义气泡内容 -->
|
||||||
<template #content="{ item }">
|
<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>
|
</template>
|
||||||
|
|
||||||
<!-- 自定义底部 -->
|
<!-- 自定义底部 -->
|
||||||
@@ -130,29 +181,94 @@ function getUserAvatar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-thinking-block-item {
|
.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;
|
overflow: hidden;
|
||||||
border: 1px solid hsl(var(--divider-faint) / 0.26);
|
font-size: 12px;
|
||||||
border-radius: 14px;
|
font-weight: 600;
|
||||||
background: hsl(var(--surface-panel) / 0.7);
|
text-overflow: ellipsis;
|
||||||
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 0.2);
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.chat-tool-panel .el-collapse-item__wrap) {
|
.chat-tool-meta {
|
||||||
background: transparent;
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.chat-tool-panel .el-collapse-item__header) {
|
.chat-tool-status {
|
||||||
min-height: 44px;
|
display: inline-flex;
|
||||||
padding-right: 14px;
|
flex-shrink: 0;
|
||||||
background: transparent;
|
gap: 4px;
|
||||||
border-bottom-color: hsl(var(--divider-faint) / 0.16);
|
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) {
|
.chat-tool-status.is-calling {
|
||||||
padding-bottom: 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import { nextTick, provide, ref, watch } from 'vue';
|
import { nextTick, provide, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
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 { Delete, Edit, MoreFilled } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
@@ -29,7 +31,7 @@ import { $t } from '#/locales';
|
|||||||
interface Props {
|
interface Props {
|
||||||
bot: any;
|
bot: any;
|
||||||
isFold: boolean;
|
isFold: boolean;
|
||||||
onMessageList?: (list: any[]) => void;
|
onMessageList?: (list: ChatTimeTimelineItem[]) => void;
|
||||||
toggleFold: () => void;
|
toggleFold: () => void;
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -101,19 +103,7 @@ function getMessageList() {
|
|||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
const records = Array.isArray(res.data?.records) ? [...res.data.records] : [];
|
const records = Array.isArray(res.data?.records) ? [...res.data.records] : [];
|
||||||
props.onMessageList?.(
|
props.onMessageList?.(
|
||||||
records.reverse().map((item: any) => ({
|
ChatTimeHistoryMapper.fromHistoryRecords(records.reverse()),
|
||||||
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,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
|
|
||||||
|
|
||||||
import { inject, ref } from 'vue';
|
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 { Paperclip, Promotion } from '@element-plus/icons-vue';
|
||||||
import { ElButton, ElIcon } from 'element-plus';
|
import { ElButton, ElIcon } from 'element-plus';
|
||||||
@@ -12,33 +11,10 @@ import { ElButton, ElIcon } from 'element-plus';
|
|||||||
import { sseClient } from '#/api/request';
|
import { sseClient } from '#/api/request';
|
||||||
import SendingIcon from '#/components/icons/SendingIcon.vue';
|
import SendingIcon from '#/components/icons/SendingIcon.vue';
|
||||||
import ChatFileUploader from '#/components/upload/ChatFileUploader.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 {
|
interface Props {
|
||||||
conversationId: string | undefined;
|
conversationId: string | undefined;
|
||||||
bot: any;
|
bot: any;
|
||||||
addMessage: (message: MessageItem) => void;
|
mutateMessages: (mutator: (messages: ChatTimeTimelineItem[]) => void) => void;
|
||||||
updateLastMessage: (item: any) => void;
|
|
||||||
stopThinking: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -48,7 +24,6 @@ const getSessionList = inject<any>('getSessionList');
|
|||||||
const clearSenderFiles = () => {
|
const clearSenderFiles = () => {
|
||||||
files.value = [];
|
files.value = [];
|
||||||
attachmentsRef.value?.clearFiles();
|
attachmentsRef.value?.clearFiles();
|
||||||
openCloseHeader();
|
|
||||||
};
|
};
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
if (getDisabled()) {
|
if (getDisabled()) {
|
||||||
@@ -62,25 +37,15 @@ function sendMessage() {
|
|||||||
};
|
};
|
||||||
clearSenderFiles();
|
clearSenderFiles();
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
props.addMessage({
|
props.mutateMessages((messages) => {
|
||||||
key: uuid(),
|
ChatTimeTimelineBuilder.appendUserMessage(messages, {
|
||||||
role: 'user',
|
|
||||||
placement: 'end',
|
|
||||||
content: senderValue.value,
|
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 = '';
|
senderValue.value = '';
|
||||||
|
|
||||||
let content = '';
|
|
||||||
|
|
||||||
sseClient.post('/userCenter/bot/chat', data, {
|
sseClient.post('/userCenter/bot/chat', data, {
|
||||||
onMessage(res) {
|
onMessage(res) {
|
||||||
if (!res.data) {
|
if (!res.data) {
|
||||||
@@ -100,127 +65,90 @@ function sendMessage() {
|
|||||||
sseData.payload?.code === 'SYSTEM_ERROR'
|
sseData.payload?.code === 'SYSTEM_ERROR'
|
||||||
) {
|
) {
|
||||||
const errorMessage = sseData.payload.message;
|
const errorMessage = sseData.payload.message;
|
||||||
props.updateLastMessage({
|
props.mutateMessages((messages) => {
|
||||||
content: errorMessage,
|
ChatTimeTimelineBuilder.applySystemError(messages, errorMessage);
|
||||||
loading: false,
|
|
||||||
typing: false,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sseData?.domain === 'TOOL') {
|
if (sseData?.domain === 'TOOL') {
|
||||||
props.updateLastMessage((message: MessageItem) => {
|
props.mutateMessages((messages) => {
|
||||||
const chains = cloneDeep(message.chains ?? []);
|
if (sseData?.type === 'TOOL_CALL') {
|
||||||
const index = chains.findIndex(
|
ChatTimeTimelineBuilder.upsertToolCall(messages, {
|
||||||
(chain) =>
|
created: Date.now(),
|
||||||
isTool(chain) && chain.id === sseData?.payload?.tool_call_id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
chains.push({
|
|
||||||
id: sseData?.payload?.tool_call_id,
|
|
||||||
name: sseData?.payload?.name,
|
name: sseData?.payload?.name,
|
||||||
status: sseData?.type,
|
toolCallId: sseData?.payload?.tool_call_id,
|
||||||
result:
|
value: sseData?.payload?.arguments,
|
||||||
sseData?.type === 'TOOL_CALL'
|
|
||||||
? sseData?.payload?.arguments
|
|
||||||
: sseData?.payload?.result,
|
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
chains[index] = {
|
|
||||||
...chains[index]!,
|
|
||||||
status: sseData?.type,
|
|
||||||
result:
|
|
||||||
sseData?.type === 'TOOL_CALL'
|
|
||||||
? sseData?.payload?.arguments
|
|
||||||
: sseData?.payload?.result,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sseData.type === 'THINKING') {
|
if (sseData.type === 'THINKING') {
|
||||||
props.updateLastMessage((message: MessageItem) => {
|
props.mutateMessages((messages) => {
|
||||||
const chains = cloneDeep(message.chains ?? []);
|
ChatTimeTimelineBuilder.appendThinkingDelta(messages, delta, Date.now());
|
||||||
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 };
|
|
||||||
});
|
});
|
||||||
} else if (sseData.type === 'MESSAGE') {
|
} else if (sseData.type === 'MESSAGE') {
|
||||||
props.updateLastMessage({
|
props.mutateMessages((messages) => {
|
||||||
thinkingStatus: 'end',
|
ChatTimeTimelineBuilder.appendMessageDelta(messages, delta, Date.now());
|
||||||
loading: false,
|
|
||||||
content: (content += delta),
|
|
||||||
});
|
});
|
||||||
props.stopThinking();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
|
props.mutateMessages((messages) => {
|
||||||
|
ChatTimeTimelineBuilder.finalize(messages);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onFinished() {
|
onFinished() {
|
||||||
senderValue.value = '';
|
senderValue.value = '';
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
props.updateLastMessage({ loading: false });
|
props.mutateMessages((messages) => {
|
||||||
props.stopThinking();
|
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() {
|
function getDisabled() {
|
||||||
return !senderValue.value || !props.conversationId;
|
return !senderValue.value || !props.conversationId;
|
||||||
}
|
}
|
||||||
const stopSse = () => {
|
const stopSse = () => {
|
||||||
sseClient.abort();
|
sseClient.abort();
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
|
props.mutateMessages((messages) => {
|
||||||
|
ChatTimeTimelineBuilder.finalize(messages);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const showHeaderFlog = ref(false);
|
|
||||||
const attachmentsRef = ref();
|
const attachmentsRef = ref();
|
||||||
const senderRef = ref();
|
|
||||||
const files = ref<any[]>([]);
|
const files = ref<any[]>([]);
|
||||||
function handlePasteFile(_: any, fileList: FileList) {
|
function handlePasteFile(_: any, fileList: FileList) {
|
||||||
showHeaderFlog.value = true;
|
|
||||||
senderRef.value?.openHeader();
|
|
||||||
files.value = [...fileList];
|
files.value = [...fileList];
|
||||||
}
|
}
|
||||||
function openCloseHeader() {
|
function triggerFileSelect() {
|
||||||
if (showHeaderFlog.value) {
|
attachmentsRef.value?.triggerFileSelect?.();
|
||||||
senderRef.value?.closeHeader();
|
|
||||||
files.value = [];
|
|
||||||
} else {
|
|
||||||
senderRef.value?.openHeader();
|
|
||||||
}
|
}
|
||||||
showHeaderFlog.value = !showHeaderFlog.value;
|
function handleDeleteAllSenderFiles() {
|
||||||
|
files.value = [];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ChatFileUploader
|
||||||
|
ref="attachmentsRef"
|
||||||
|
:external-files="files"
|
||||||
|
@delete-all="handleDeleteAllSenderFiles"
|
||||||
|
:max-size="10"
|
||||||
|
/>
|
||||||
<ElSender
|
<ElSender
|
||||||
ref="senderRef"
|
|
||||||
v-model="senderValue"
|
v-model="senderValue"
|
||||||
variant="updown"
|
variant="updown"
|
||||||
:auto-size="{ minRows: 2, maxRows: 5 }"
|
:auto-size="{ minRows: 2, maxRows: 5 }"
|
||||||
@@ -230,21 +158,9 @@ function openCloseHeader() {
|
|||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
@paste-file="handlePasteFile"
|
@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>
|
<template #action-list>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ElButton circle @click="openCloseHeader">
|
<ElButton circle @click="triggerFileSelect">
|
||||||
<ElIcon><Paperclip /></ElIcon>
|
<ElIcon><Paperclip /></ElIcon>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<!-- <ElButton :icon="PaperclipIcon" link /> -->
|
<!-- <ElButton :icon="PaperclipIcon" link /> -->
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { JsonViewer } from 'vue3-json-viewer';
|
|||||||
|
|
||||||
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
|
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
|
||||||
|
|
||||||
defineProps({
|
withDefaults(
|
||||||
value: {
|
defineProps<{
|
||||||
required: true,
|
value: unknown;
|
||||||
|
plain?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
plain: false,
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
const emptyImageUrl = `${import.meta.env.BASE_URL || '/'}empty.png`;
|
const emptyImageUrl = `${import.meta.env.BASE_URL || '/'}empty.png`;
|
||||||
const themeMode = ref(preferences.theme.mode);
|
const themeMode = ref(preferences.theme.mode);
|
||||||
@@ -25,7 +29,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="res-container">
|
<div class="res-container" :class="{ 'is-plain': plain }">
|
||||||
<JsonViewer v-if="value" :value="value" copyable :theme="themeMode" />
|
<JsonViewer v-if="value" :value="value" copyable :theme="themeMode" />
|
||||||
<ElEmpty :image="emptyImageUrl" v-else />
|
<ElEmpty :image="emptyImageUrl" v-else />
|
||||||
</div>
|
</div>
|
||||||
@@ -37,4 +41,11 @@ watch(
|
|||||||
border-radius: var(--el-border-radius-base);
|
border-radius: var(--el-border-radius-base);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.res-container.is-plain {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type SelfFilesCardProps = {
|
|||||||
} & FilesCardProps;
|
} & FilesCardProps;
|
||||||
|
|
||||||
const files = ref<SelfFilesCardProps[]>([]);
|
const files = ref<SelfFilesCardProps[]>([]);
|
||||||
|
const fileInputRef = ref<HTMLInputElement>();
|
||||||
/**
|
/**
|
||||||
* 上传前校验
|
* 上传前校验
|
||||||
*/
|
*/
|
||||||
@@ -45,30 +46,11 @@ function handleBeforeUpload(file: File) {
|
|||||||
return true;
|
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 }) {
|
async function handleHttpRequest(options: { file: File }) {
|
||||||
const { file } = options;
|
const { file } = options;
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.upload(props.action, { file }, {});
|
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);
|
return files.value.map((file) => file.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发原生文件选择
|
||||||
|
*/
|
||||||
|
function triggerFileSelect() {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.externalFiles,
|
() => props.externalFiles,
|
||||||
async (newFiles) => {
|
async (newFiles) => {
|
||||||
@@ -135,6 +136,7 @@ watch(
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getFileList,
|
getFileList,
|
||||||
|
triggerFileSelect,
|
||||||
clearFiles() {
|
clearFiles() {
|
||||||
files.value = [];
|
files.value = [];
|
||||||
},
|
},
|
||||||
@@ -142,17 +144,20 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<Attachments
|
||||||
:http-request="handleHttpRequest"
|
|
||||||
:items="files"
|
:items="files"
|
||||||
drag
|
:hide-upload="true"
|
||||||
:before-upload="handleBeforeUpload"
|
|
||||||
:hide-upload="false"
|
|
||||||
@upload-drop="handleUploadDrop"
|
|
||||||
@delete-card="handleDeleteCard"
|
@delete-card="handleDeleteCard"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileInputChange"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less"></style>
|
<style scoped lang="less"></style>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { cloneDeep } from '@easyflow/utils';
|
|
||||||
|
|
||||||
import { ArrowLeft, Minus, Plus } from '@element-plus/icons-vue';
|
import { ArrowLeft, Minus, Plus } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElAside,
|
ElAside,
|
||||||
@@ -81,40 +81,12 @@ function removeBotFromRecentlyUsed(botId: any) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const messageList = ref<any>([]);
|
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||||||
function addMessage(message: any) {
|
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
|
||||||
messageList.value.push(message);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -145,11 +117,9 @@ const stopThinking = () => {
|
|||||||
<ChatBubbleList v-else :bot="botInfo" :messages="messageList" />
|
<ChatBubbleList v-else :bot="botInfo" :messages="messageList" />
|
||||||
<ChatSender
|
<ChatSender
|
||||||
class="absolute bottom-5 left-0 w-full"
|
class="absolute bottom-5 left-0 w-full"
|
||||||
:add-message="addMessage"
|
|
||||||
:update-last-message="updateLastMessage"
|
|
||||||
:stop-thinking="stopThinking"
|
|
||||||
:bot="botInfo"
|
:bot="botInfo"
|
||||||
:conversation-id="conversationId"
|
:conversation-id="conversationId"
|
||||||
|
:mutate-messages="mutateMessages"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ElMain>
|
</ElMain>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { cloneDeep, cn } from '@easyflow/utils';
|
import { cn } from '@easyflow/utils';
|
||||||
|
|
||||||
import { ElAside, ElContainer, ElMain } from 'element-plus';
|
import { ElAside, ElContainer, ElMain } from 'element-plus';
|
||||||
|
|
||||||
@@ -34,43 +36,15 @@ function getAssistantList() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const messageList = ref<any>([]);
|
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||||||
function addMessage(message: any) {
|
function setMessageList(messages: ChatTimeTimelineItem[]) {
|
||||||
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) {
|
|
||||||
messageList.value = messages;
|
messageList.value = messages;
|
||||||
}
|
}
|
||||||
|
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
|
||||||
|
const next = [...messageList.value];
|
||||||
|
mutator(next);
|
||||||
|
messageList.value = next;
|
||||||
|
}
|
||||||
const isFold = ref(false);
|
const isFold = ref(false);
|
||||||
const toggleFold = () => {
|
const toggleFold = () => {
|
||||||
isFold.value = !isFold.value;
|
isFold.value = !isFold.value;
|
||||||
@@ -95,11 +69,9 @@ const toggleFold = () => {
|
|||||||
<ChatBubbleList :bot="currentBot" :messages="messageList" />
|
<ChatBubbleList :bot="currentBot" :messages="messageList" />
|
||||||
<div class="mx-auto w-full max-w-[1000px]">
|
<div class="mx-auto w-full max-w-[1000px]">
|
||||||
<ChatSender
|
<ChatSender
|
||||||
:add-message="addMessage"
|
|
||||||
:update-last-message="updateLastMessage"
|
|
||||||
:stop-thinking="stopThinking"
|
|
||||||
:bot="currentBot"
|
:bot="currentBot"
|
||||||
:conversation-id="conversationId"
|
:conversation-id="conversationId"
|
||||||
|
:mutate-messages="mutateMessages"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { ChatTimeHistoryMapper } from '@easyflow/utils';
|
||||||
import { Delete, Edit, Search } from '@element-plus/icons-vue';
|
import { Delete, Edit, Search } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
@@ -43,7 +46,8 @@ const pageState = ref({
|
|||||||
const drawerVisible = ref(false);
|
const drawerVisible = ref(false);
|
||||||
const drawerLoading = ref(false);
|
const drawerLoading = ref(false);
|
||||||
const currentSession = ref<any>();
|
const currentSession = ref<any>();
|
||||||
const messageList = ref<any[]>([]);
|
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||||||
|
const loadedMessageRecordCount = ref(0);
|
||||||
const messagePage = ref({
|
const messagePage = ref({
|
||||||
total: 0,
|
total: 0,
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
@@ -123,6 +127,7 @@ async function openSession(sessionId: string | number) {
|
|||||||
}
|
}
|
||||||
currentSession.value = summaryRes.data;
|
currentSession.value = summaryRes.data;
|
||||||
messageList.value = [];
|
messageList.value = [];
|
||||||
|
loadedMessageRecordCount.value = 0;
|
||||||
messagePage.value = {
|
messagePage.value = {
|
||||||
total: 0,
|
total: 0,
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
@@ -162,32 +167,22 @@ async function loadMessages(reset = false) {
|
|||||||
} else {
|
} else {
|
||||||
messageList.value = [...normalized, ...messageList.value];
|
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.total = res.data?.total || 0;
|
||||||
messagePage.value.pageNumber = nextPageNumber;
|
messagePage.value.pageNumber = nextPageNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessages(records: any[]) {
|
function normalizeMessages(records: any[]) {
|
||||||
return [...records]
|
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
|
||||||
.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,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDrawer() {
|
function closeDrawer() {
|
||||||
drawerVisible.value = false;
|
drawerVisible.value = false;
|
||||||
currentSession.value = undefined;
|
currentSession.value = undefined;
|
||||||
messageList.value = [];
|
messageList.value = [];
|
||||||
|
loadedMessageRecordCount.value = 0;
|
||||||
router.replace({ path: '/chatHistory', query: {} });
|
router.replace({ path: '/chatHistory', query: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +344,7 @@ function formatTime(value?: string) {
|
|||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<div class="mb-4 flex justify-center">
|
<div class="mb-4 flex justify-center">
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="messageList.length < messagePage.total"
|
v-if="loadedMessageRecordCount < messagePage.total"
|
||||||
text
|
text
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="loadMessages(false)"
|
@click="loadMessages(false)"
|
||||||
|
|||||||
96
easyflow-ui-usercenter/packages/types/src/chat-time.ts
Normal file
96
easyflow-ui-usercenter/packages/types/src/chat-time.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export type * from './bot';
|
export type * from './bot';
|
||||||
|
export type * from './chat-time';
|
||||||
export type * from './user';
|
export type * from './user';
|
||||||
export type * from '@easyflow-core/typings';
|
export type * from '@easyflow-core/typings';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@easyflow-core/shared": "workspace:*",
|
"@easyflow-core/shared": "workspace:*",
|
||||||
"@easyflow-core/typings": "workspace:*",
|
"@easyflow-core/typings": "workspace:*",
|
||||||
|
"@easyflow/types": "workspace:*",
|
||||||
"vue-router": "catalog:"
|
"vue-router": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
666
easyflow-ui-usercenter/packages/utils/src/helpers/chat-time.ts
Normal file
666
easyflow-ui-usercenter/packages/utils/src/helpers/chat-time.ts
Normal 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 };
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './chat-time';
|
||||||
export * from './clipboard';
|
export * from './clipboard';
|
||||||
export * from './find-menu-by-path';
|
export * from './find-menu-by-path';
|
||||||
export * from './generate-menus';
|
export * from './generate-menus';
|
||||||
|
|||||||
Reference in New Issue
Block a user