431 lines
13 KiB
Vue
431 lines
13 KiB
Vue
<template>
|
||
<div class="chat-container" :ref="el => chatContainerRef = el">
|
||
<el-scrollbar ref="scrollbarRef" :height="'90%'" @scroll="handleScroll"
|
||
v-if="renderMessages.length > 0 || isLoading">
|
||
<div class="message-list" :ref="el => messageListRef = el">
|
||
<div v-for="(message, index) in renderMessages" :key="index"
|
||
:class="['message-item', message.role.split('/')[0], message.role.split('/')[1]]">
|
||
<div class="message-avatar" v-if="message.role === 'assistant/content'">
|
||
<span class="iconfont icon-robot"></span>
|
||
</div>
|
||
<div class="message-avatar" v-else-if="message.role === 'assistant/tool_calls'">
|
||
</div>
|
||
|
||
<!-- 用户输入的部分 -->
|
||
<div class="message-content" v-if="message.role === 'user'">
|
||
<Message.User :message="message" :tab-id="props.tabId" />
|
||
</div>
|
||
|
||
<!-- 助手返回的内容部分 -->
|
||
<div class="message-content" v-else-if="message.role === 'assistant/content'">
|
||
<Message.Assistant :message="message" :tab-id="props.tabId" />
|
||
</div>
|
||
|
||
<!-- 助手调用的工具部分 -->
|
||
<div class="message-content" v-else-if="message.role === 'assistant/tool_calls'">
|
||
<Message.Toolcall :message="message" :tab-id="props.tabId"
|
||
@update:tool-result="(value, toolIndex, index) => message.toolResults[toolIndex][index] = value" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 正在加载的部分实时解析 markdown -->
|
||
<div v-if="isLoading" class="message-item assistant">
|
||
<Message.StreamingBox :streaming-content="streamingContent" :tab-id="props.tabId" />
|
||
</div>
|
||
</div>
|
||
</el-scrollbar>
|
||
<div v-else class="chat-openmcp-icon">
|
||
<div>
|
||
<!-- <span class="iconfont icon-openmcp"></span> -->
|
||
<span>{{ t('press-and-run') }}
|
||
<span style="padding: 5px 15px; border-radius: .5em; background-color: var(--background);">
|
||
<span class="iconfont icon-send"></span>
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<ChatBox :ref="el => footerRef = el" :tab-id="props.tabId" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, defineComponent, defineProps, onUnmounted, computed, nextTick, watch, provide, watchEffect } from 'vue';
|
||
import { useI18n } from 'vue-i18n';
|
||
import { type ScrollbarInstance } from 'element-plus';
|
||
import { tabs } from '../panel';
|
||
import type { ChatMessage, ChatStorage, IRenderMessage, ToolCall } from './chat-box/chat';
|
||
import { MessageState } from './chat-box/chat';
|
||
|
||
import * as Message from './message';
|
||
import ChatBox from './chat-box/index.vue';
|
||
import { getToolCallFromXmlString, getToolResultFromXmlString, getXmlsFromString, toNormaliseToolcall } from './core/xml-wrapper';
|
||
import { getIdAsIndexAdapter } from './core/handle-tool-calls';
|
||
|
||
|
||
defineComponent({ name: 'chat' });
|
||
|
||
const { t } = useI18n();
|
||
|
||
const props = defineProps({
|
||
tabId: {
|
||
type: Number,
|
||
required: true
|
||
}
|
||
});
|
||
|
||
const tab = tabs.content[props.tabId];
|
||
const tabStorage = tab.storage as ChatStorage;
|
||
|
||
// 创建 messages
|
||
if (!tabStorage.messages) {
|
||
tabStorage.messages = [] as ChatMessage[];
|
||
}
|
||
|
||
function getXmlToolCalls(message: ChatMessage) {
|
||
if (message.role !== 'assistant' && message.role !== 'user') {
|
||
return [];
|
||
}
|
||
|
||
const enableXmlTools = message.extraInfo?.enableXmlWrapper ?? false;
|
||
|
||
if (!enableXmlTools) {
|
||
return [];
|
||
}
|
||
|
||
const xmls = getXmlsFromString(message.content);
|
||
|
||
return xmls || [];
|
||
}
|
||
|
||
const renderMessages = ref<IRenderMessage[]>([]);
|
||
|
||
watchEffect(async () => {
|
||
renderMessages.value = [];
|
||
|
||
for (const message of tabStorage.messages) {
|
||
const indexAdapter = getIdAsIndexAdapter();
|
||
const xmls = getXmlToolCalls(message);
|
||
|
||
if (message.role === 'user') {
|
||
if (xmls.length > 0 && message.extraInfo.enableXmlWrapper) {
|
||
// 判断是否是 xml 模式,如果是 xml 模式且存在有效的 xml,则按照工具来判定
|
||
// 往前寻找 assistant/tool_calls 并自动加入其中
|
||
const lastAssistantMessage = renderMessages.value[renderMessages.value.length - 1];
|
||
if (lastAssistantMessage.role === 'assistant/tool_calls') {
|
||
|
||
const toolCallResultXmls = getXmlsFromString(message.content);
|
||
|
||
for (const xml of toolCallResultXmls) {
|
||
const toolResult = await getToolResultFromXmlString(xml);
|
||
if (toolResult) {
|
||
const index = indexAdapter(toolResult.callId);
|
||
|
||
lastAssistantMessage.toolResults[index] = toolResult.toolcallContent;
|
||
|
||
if (lastAssistantMessage.extraInfo.state === MessageState.Unknown) {
|
||
lastAssistantMessage.extraInfo.state = message.extraInfo.state;
|
||
} else if (lastAssistantMessage.extraInfo.state === MessageState.Success
|
||
|| message.extraInfo.state !== MessageState.Success
|
||
) {
|
||
lastAssistantMessage.extraInfo.state = message.extraInfo.state;
|
||
}
|
||
|
||
lastAssistantMessage.extraInfo.usage = lastAssistantMessage.extraInfo.usage || message.extraInfo.usage;
|
||
}
|
||
}
|
||
}
|
||
|
||
} else {
|
||
renderMessages.value.push({
|
||
role: 'user',
|
||
content: message.content,
|
||
extraInfo: message.extraInfo
|
||
});
|
||
}
|
||
|
||
} else if (message.role === 'assistant') {
|
||
if (message.tool_calls) {
|
||
renderMessages.value.push({
|
||
role: 'assistant/tool_calls',
|
||
content: message.content,
|
||
toolResults: Array(message.tool_calls.length).fill([]),
|
||
tool_calls: message.tool_calls,
|
||
showJson: ref(false),
|
||
extraInfo: {
|
||
...message.extraInfo,
|
||
state: MessageState.Unknown
|
||
}
|
||
});
|
||
} else {
|
||
if (xmls.length > 0 && message.extraInfo.enableXmlWrapper) {
|
||
// 判断是否是 xml 模式,如果是 xml 模式且存在有效的 xml,则按照工具来判定
|
||
const toolCalls = [];
|
||
for (const xml of xmls) {
|
||
const xmlToolCall = await getToolCallFromXmlString(xml);
|
||
if (xmlToolCall) {
|
||
toolCalls.push(
|
||
toNormaliseToolcall(xmlToolCall, indexAdapter)
|
||
);
|
||
}
|
||
}
|
||
const renderAssistantMessage = message.content.replace(/```xml[\s\S]*?```/g, '');
|
||
|
||
console.log(toolCalls);
|
||
|
||
|
||
renderMessages.value.push({
|
||
role: 'assistant/tool_calls',
|
||
content: renderAssistantMessage,
|
||
toolResults: Array(toolCalls.length).fill([]),
|
||
tool_calls: toolCalls,
|
||
showJson: ref(false),
|
||
extraInfo: {
|
||
...message.extraInfo,
|
||
state: MessageState.Unknown
|
||
}
|
||
});
|
||
} else {
|
||
renderMessages.value.push({
|
||
role: 'assistant/content',
|
||
content: message.content,
|
||
extraInfo: message.extraInfo
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
} else if (message.role === 'tool') {
|
||
// 如果是工具,则合并进入 之前 assistant 一起渲染
|
||
const lastAssistantMessage = renderMessages.value[renderMessages.value.length - 1];
|
||
if (lastAssistantMessage.role === 'assistant/tool_calls') {
|
||
lastAssistantMessage.toolResults[message.index] = message.content;
|
||
|
||
if (lastAssistantMessage.extraInfo.state === MessageState.Unknown) {
|
||
lastAssistantMessage.extraInfo.state = message.extraInfo.state;
|
||
} else if (lastAssistantMessage.extraInfo.state === MessageState.Success
|
||
|| message.extraInfo.state !== MessageState.Success
|
||
) {
|
||
lastAssistantMessage.extraInfo.state = message.extraInfo.state;
|
||
}
|
||
|
||
lastAssistantMessage.extraInfo.usage = lastAssistantMessage.extraInfo.usage || message.extraInfo.usage;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
|
||
const isLoading = ref(false);
|
||
|
||
const streamingContent = ref('');
|
||
const streamingToolCalls = ref<ToolCall[]>([]);
|
||
|
||
const chatContainerRef = ref<any>(null);
|
||
const messageListRef = ref<any>(null);
|
||
const footerRef = ref<any>(null);
|
||
|
||
const scrollHeight = ref('500px');
|
||
|
||
function updateScrollHeight() {
|
||
if (chatContainerRef.value && footerRef.value) {
|
||
const containerHeight = chatContainerRef.value.clientHeight;
|
||
const footerHeight = footerRef.value.clientHeight;
|
||
scrollHeight.value = `${containerHeight - footerHeight}px`;
|
||
}
|
||
}
|
||
|
||
provide('updateScrollHeight', updateScrollHeight);
|
||
|
||
const autoScroll = ref(true);
|
||
const scrollbarRef = ref<ScrollbarInstance>();
|
||
|
||
// 修改后的 handleScroll 方法
|
||
const handleScroll = ({ scrollTop, scrollHeight, clientHeight }: {
|
||
scrollTop: number,
|
||
scrollHeight: number,
|
||
clientHeight: number
|
||
}) => {
|
||
// 如果用户滚动到接近底部(留10px缓冲),则恢复自动滚动
|
||
autoScroll.value = scrollTop + clientHeight >= scrollHeight - 10;
|
||
};
|
||
|
||
provide('streamingContent', streamingContent);
|
||
provide('streamingToolCalls', streamingToolCalls);
|
||
provide('isLoading', isLoading);
|
||
provide('autoScroll', autoScroll);
|
||
|
||
const chatContext = {
|
||
handleSend: undefined
|
||
};
|
||
provide('chatContext', chatContext);
|
||
|
||
// 修改 scrollToBottom 方法
|
||
async function scrollToBottom() {
|
||
if (!scrollbarRef.value || !messageListRef.value) return;
|
||
|
||
await nextTick(); // 等待 DOM 更新
|
||
|
||
try {
|
||
const container = scrollbarRef.value.wrapRef;
|
||
if (container) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
} catch (error) {
|
||
console.error('Scroll to bottom failed:', error);
|
||
}
|
||
}
|
||
|
||
provide('scrollToBottom', scrollToBottom);
|
||
|
||
// 添加对 streamingContent 的监听
|
||
watch(streamingContent, () => {
|
||
if (autoScroll.value) {
|
||
scrollToBottom();
|
||
}
|
||
}, { deep: true });
|
||
|
||
watch(streamingToolCalls, () => {
|
||
if (autoScroll.value) {
|
||
scrollToBottom();
|
||
}
|
||
}, { deep: true });
|
||
|
||
|
||
</script>
|
||
|
||
<style>
|
||
.chat-container {
|
||
height: 100%;
|
||
display: flex;
|
||
position: relative;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.chat-openmcp-icon {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: center;
|
||
height: 100%;
|
||
opacity: 0.75;
|
||
padding-top: 70px;
|
||
}
|
||
|
||
.chat-openmcp-icon>div {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: left;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.chat-openmcp-icon>div>span {
|
||
margin-bottom: 23px;
|
||
}
|
||
|
||
.chat-openmcp-icon .iconfont {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.message-list {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
padding: 16px;
|
||
padding-bottom: 100px;
|
||
}
|
||
|
||
.message-item {
|
||
display: flex;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.message-avatar {
|
||
margin-right: 12px;
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.message-content {
|
||
flex: 1;
|
||
width: 100%;
|
||
}
|
||
|
||
.message-role {
|
||
font-weight: bold;
|
||
margin-bottom: 4px;
|
||
color: var(--el-text-color-regular);
|
||
}
|
||
|
||
.message-text {
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.user .message-text {
|
||
margin-top: 10px;
|
||
margin-bottom: 10px;
|
||
width: 100%;
|
||
}
|
||
|
||
.user .message-text>span {
|
||
border-radius: .9em;
|
||
background-color: var(--main-light-color);
|
||
padding: 10px 15px;
|
||
}
|
||
|
||
.user {
|
||
flex-direction: row-reverse;
|
||
text-align: right;
|
||
}
|
||
|
||
.user .message-avatar {
|
||
margin-right: 0;
|
||
margin-left: 12px;
|
||
}
|
||
|
||
.user .message-content {
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.assistant {
|
||
text-align: left;
|
||
margin-top: 30px;
|
||
}
|
||
|
||
.assistant.tool_calls {
|
||
margin-top: 5px;
|
||
}
|
||
|
||
.message-text p,
|
||
.message-text h3,
|
||
.message-text ol,
|
||
.message-text ul {
|
||
margin-top: 0.5em;
|
||
margin-bottom: 0.5em;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.message-text ol li,
|
||
.message-text ul li {
|
||
margin-top: 0.2em;
|
||
margin-bottom: 0.2em;
|
||
}
|
||
|
||
/* 新增旋转标记样式 */
|
||
.tool-loading {
|
||
display: inline-block;
|
||
margin-left: 8px;
|
||
animation: spin 1s linear infinite;
|
||
color: var(--main-color);
|
||
font-size: 20px;
|
||
}
|
||
|
||
|
||
@keyframes spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style>
|