1091 lines
39 KiB
Vue
1091 lines
39 KiB
Vue
<template>
|
|
<div style="flex:1; background: #f3f4f6;overflow: auto;">
|
|
<el-container>
|
|
<!-- 侧边栏 -->
|
|
<el-aside v-show="!isFold" :class="isFold ? 'sidebar-fold' : 'expand-sidebar'"
|
|
style="background: #f3f4f6; border-right: 1px solid #f0f0f0;display: flex;flex-direction: column;">
|
|
<div class="chat-action">
|
|
<el-tooltip :content="$t('message.chat.newChat')" placement="top">
|
|
<div class="chat-action-item" @click.stop="handleNewChat">
|
|
<Edit style="width: 1.5em; height: 1.5em;color: #333;" />
|
|
</div>
|
|
</el-tooltip>
|
|
<el-tooltip :content="$t('message.chat.foldChat')" placement="top">
|
|
<div class="chat-action-item" @click.stop="handleFoldChat">
|
|
<Fold style="width: 1.5em; height: 1.5em;color: #333;" />
|
|
</div>
|
|
</el-tooltip>
|
|
</div>
|
|
<div
|
|
style="display: flex; flex-direction: column; align-items: center;padding-bottom:8px;padding-top:0px;margin-top:0px;">
|
|
<el-avatar :size="60" style="background-color: #f3f4f6;" src="/chat.png" fit="fill"
|
|
class="avatar-with-shadow" />
|
|
<div style="margin-top: 10px; font-weight: bold; font-size: 18px;color: #333;">{{
|
|
$t('message.chat.title') }}
|
|
</div>
|
|
</div>
|
|
<div style="flex:1;overflow-y: auto;width:100%;">
|
|
<div style="display: flex; flex-direction: column; gap: 12px; height: 100%;">
|
|
<Conversations style="border-radius: 0%;width:255px;" v-model:active="activeHistoryKey"
|
|
:items="sideBarHistoryList" row-key="key" :show-tooltip="true" showToTopBtn :label-max-width="210"
|
|
:load-more="loadMoreHistoryItems" :load-more-loading="isHistoryListLoading"
|
|
showBuiltInMenu @change="handleChange">
|
|
<template #menu="{ item }">
|
|
<div class="menu-buttons">
|
|
<el-button v-for="menuItem in actionMenuItems" :key="menuItem.key" link
|
|
size="default" @click="handleMenuClick(menuItem.key, item)">
|
|
<el-icon v-if="menuItem.icon">
|
|
<component :is="menuItem.icon" />
|
|
</el-icon>
|
|
<span v-if="menuItem.label">{{ menuItem.label }}</span>
|
|
</el-button>
|
|
</div>
|
|
</template>
|
|
</Conversations>
|
|
</div>
|
|
</div>
|
|
</el-aside>
|
|
|
|
<!-- 主体内容 -->
|
|
<el-container>
|
|
<el-main
|
|
style="flex:1;width:100%;height:100%;margin:0;padding:0;overflow-y:auto;display:flex;position: relative;flex-direction: column; align-items: center; justify-content: flex-start;background:#fff;">
|
|
<div class="main_action_toolbar" style="position: absolute; top: 0px; width: 500px;left:20px;">
|
|
<div v-if="isFold" class="chat-action-item main_action_item">
|
|
<el-tooltip :content="$t('message.chat.expandChat')" placement="top">
|
|
<Expand style="width: 1.5em; height: 1.5em;color: #333;"
|
|
@click.stop="handleExpandChat" />
|
|
</el-tooltip>
|
|
</div>
|
|
<div v-if="isFold" class="chat-action-item main_action_item" @click="handleNewChat">
|
|
<el-tooltip :content="$t('message.chat.newChat')" placement="top">
|
|
<Edit style="width: 1.5em; height: 1.5em;color: #333;" />
|
|
</el-tooltip>
|
|
</div>
|
|
<div v-if="canSwitchModel" class="model-select">
|
|
<el-dropdown size="large">
|
|
<span class="el-dropdown-link">
|
|
{{ modellList.currentModel }}
|
|
<el-icon class="el-icon--right">
|
|
<arrow-down />
|
|
</el-icon>
|
|
</span>
|
|
<template #dropdown>
|
|
<el-dropdown-menu>
|
|
<el-dropdown-item v-for="item in modellList.models" :key="item.modelName"
|
|
@click="handleChangeModel(item)">
|
|
<div class="model-item">
|
|
<span>{{ item.providerName }}/{{ item.modelName }}</span>
|
|
<el-icon v-if="item.modelName == modellList.currentModel"
|
|
style="color: #409EFF;">
|
|
<Select />
|
|
</el-icon>
|
|
</div>
|
|
</el-dropdown-item>
|
|
</el-dropdown-menu>
|
|
</template>
|
|
</el-dropdown>
|
|
</div>
|
|
</div>
|
|
<div v-if="isNew" class="new_chat_title">
|
|
<div style="margin-top: 60px; font-size: 36px; font-weight: bold; letter-spacing: 2px;">
|
|
Hello, {{ userName }}
|
|
</div>
|
|
<div
|
|
style="margin-top:20px;width: 100%;text-align: center;color: #999; font-size: 16px; font-weight: bold; letter-spacing: 2px;">
|
|
{{ $t('message.chat.subTitle') }}
|
|
</div>
|
|
</div>
|
|
<div v-else class="chat_content">
|
|
<BubbleList class="chat_content_list" ref="chatRef" :list="chatList" maxHeight="100%"
|
|
style="padding:0 60px 100px 60px;" @complete="handleBubbleComplete"
|
|
:triggerIndices="triggerIndices">
|
|
<template #header="{ item }">
|
|
<div v-if="item.role == 'assistant' && deepThinkingVisible && item.key == chatList[chatList.length - 1].key"
|
|
class="header-wrapper">
|
|
<Thinking max-width="100%" buttonWidth="250px" autoCollapse
|
|
:content="deepThinkingMessage" :status="deepThinkingStatus"
|
|
backgroundColor="#fff9e6" color="#000">
|
|
<template #label="{ status }">
|
|
<span v-if="status === 'start'">{{
|
|
$t('message.chat.startThinking') }}</span>
|
|
<span v-else-if="status === 'thinking'">{{
|
|
$t('message.chat.thinking') }}</span>
|
|
<span v-else-if="status === 'end'">{{
|
|
$t('message.chat.thinkingDone') }}</span>
|
|
<span v-else-if="status === 'error'">{{
|
|
$t('message.chat.thinkingFailed') }}</span>
|
|
</template>
|
|
<template #content="{ content }">
|
|
<span>
|
|
<VueMarkdownIt class="deep-thinking-content" :content="content" />
|
|
</span>
|
|
</template>
|
|
</Thinking>
|
|
</div>
|
|
</template>
|
|
<template #footer="{ item }">
|
|
<div v-if="item.role == 'assistant'" class="footer-container">
|
|
<el-button type="info" text :icon="CopyDocument" size="small"
|
|
@click="handleCopy(item)" />
|
|
<el-button type="info" text size="small">
|
|
<el-icon v-if="!isPlaying || playAudioKey != item.key"
|
|
@click="handlePlay(item)">
|
|
<VideoPlay />
|
|
</el-icon>
|
|
<el-icon v-if="isPlaying && playAudioKey == item.key"
|
|
@click="handlePause(item)">
|
|
<VideoPause />
|
|
</el-icon>
|
|
</el-button>
|
|
</div>
|
|
</template>
|
|
</BubbleList>
|
|
</div>
|
|
<div :class="isNew ? 'chat_new_input_style' : 'chat_edit_input_style'">
|
|
<sender ref="senderRef" variant="updown" clearable allow-speech :loading="isSenderLoading"
|
|
:read-only="isSenderLoading" :auto-size="{ minRows: 1, maxRows: 5 }" v-model="senderInput"
|
|
@submit="handleSend" :placeholder="$t('message.chat.inputPlaceholder')">
|
|
<template #prefix>
|
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
|
<el-button round plain>
|
|
<el-icon>
|
|
<Paperclip />
|
|
</el-icon>
|
|
</el-button>
|
|
|
|
<div :class="{ isDeepThinking }"
|
|
style="display: flex; align-items: center; gap: 4px; padding: 4px 12px; border: 1px solid silver; border-radius: 15px; cursor: pointer; font-size: 12px;"
|
|
@click="handleDeepThinking">
|
|
<el-icon>
|
|
<ElementPlus />
|
|
</el-icon>
|
|
<span>{{ $t('message.chat.deepThinking') }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</sender>
|
|
</div>
|
|
</el-main>
|
|
</el-container>
|
|
</el-container>
|
|
<!-- 自定义弹窗,添加动画和拖拽 -->
|
|
<transition name="ai-modal-fade">
|
|
<div v-if="renameDialogVisible" class="ai-rename-modal-mask" @mousedown.self="cancelRename">
|
|
<div class="ai-rename-modal" ref="renameModalRef" @mousedown.stop>
|
|
<div class="ai-rename-content">
|
|
<div class="ai-rename-icon ai-rename-drag" @mousedown="startDrag">
|
|
<el-avatar :size="48" src="/chat.png" />
|
|
</div>
|
|
<el-input v-model="renameInput" @keyup.enter="confirmRename" ref="renameInputRef"
|
|
class="ai-rename-input" />
|
|
</div>
|
|
<div class="ai-dialog-footer">
|
|
<el-button @click="cancelRename" class="ai-btn-cancel">{{ $t('message.chat.cancel')
|
|
}}</el-button>
|
|
<el-button type="primary" @click="confirmRename" class="ai-btn-confirm">
|
|
{{ $t('message.chat.confirm') }}
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, markRaw, nextTick, watch } from 'vue'
|
|
import { Edit, Fold, Expand, ArrowDown, Select, Delete, CopyDocument, Share, Paperclip, ElementPlus, VideoPlay, VideoPause } from '@element-plus/icons-vue'
|
|
import { LLMChatApi } from '/@/api-services/api';
|
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { getAPI } from '/@/utils/axios-utils';
|
|
import {
|
|
ModelListOutput, ModelListOutputItem, LLMChatHistory, LLMChatSummaryHistory, ChatInput, ChatListInput,
|
|
ChatOutput, ChatListOutput
|
|
} from '/@/api-services/models';
|
|
import { userId } from '/@/utils/useInfo';
|
|
import { userFriendName } from '/@/utils/useInfo';
|
|
import type { BubbleListItemProps, BubbleListProps } from 'vue-element-plus-x/types/components/BubbleList/types'
|
|
import type { ConversationItem } from 'vue-element-plus-x/types/components/Conversations/types'
|
|
import type { TypewriterInstance } from 'vue-element-plus-x/types/components/Typewriter/types'
|
|
import { franc } from 'franc';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import 'vue-element-plus-x/styles/prism.min.css'
|
|
import 'vue-element-plus-x/styles/prism-solarizedlight.min.css'
|
|
import { VueMarkdownIt } from 'vue-markdown-shiki'
|
|
|
|
const { t } = useI18n()
|
|
const canSwitchModel = ref(false)
|
|
const userName = userFriendName()
|
|
const isNew = ref(true)
|
|
const senderRef = ref<any>(null)
|
|
const isDeepThinking = ref(false)
|
|
const isFold = ref(false);
|
|
const sidebarWidth = ref(260);
|
|
const isPlaying = ref(false)
|
|
const playAudioKey = ref('')
|
|
const chatRef = ref<any>(null)
|
|
const sideBarHistoryList = ref<ConversationItem<{ key: string; label: string }>[]>([])
|
|
type listType = BubbleListItemProps & {
|
|
key: string
|
|
role: 'user' | 'assistant'
|
|
}
|
|
const state = ref<ChatListInput & { totalPages: number }>({
|
|
page: 1,
|
|
pageSize: 10,
|
|
totalPages: 0,
|
|
})
|
|
|
|
const isHistoryListLoading = ref(false) // 历史记录加载更多处理
|
|
|
|
const activeHistoryKey = ref('')
|
|
const chatList = ref<BubbleListProps<listType>['list']>([])
|
|
const currentChatItemMessage = ref(""); //临时存储当前聊天内容
|
|
const currentChatItem = ref<LLMChatHistory>({} as LLMChatHistory)
|
|
const isSenderLoading = ref(false)
|
|
|
|
const deepThinkingMessage = ref("");
|
|
const deepThinkingStatus = ref("start");
|
|
const deepThinkingVisible = ref(false);
|
|
|
|
let historyListSource: ChatListOutput[] = [] //从后台获取的原始历史记录
|
|
// 添加重连相关配置
|
|
const reconnectInterval = 5000; // 重连间隔时间(毫秒)
|
|
const maxRetries = 1000; // 最大重试次数
|
|
let retryCount = 0;
|
|
let reconnectTimer: number | null = null;
|
|
let utterance: SpeechSynthesisUtterance | null = null;
|
|
const triggerIndices = ref<BubbleListProps['triggerIndices']>('only-last');
|
|
|
|
const senderInput = ref('')
|
|
const modellList = ref<ModelListOutput>(
|
|
{
|
|
models: [],
|
|
providerName: '',
|
|
currentModel: '',
|
|
}
|
|
);
|
|
const actionMenuItems = [ // 侧边栏历史记录操作菜单
|
|
{
|
|
key: 'rename',
|
|
label: t('message.chat.rename'),
|
|
icon: Edit,
|
|
},
|
|
{
|
|
key: 'delete',
|
|
label: t('message.chat.delete'),
|
|
icon: Delete,
|
|
}
|
|
]
|
|
|
|
//#region sse客户端
|
|
let eventSource: EventSource | null = null;
|
|
|
|
|
|
const initSSEConnection = () => {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
|
|
eventSource = new EventSource("/sse/chat/" + userId());
|
|
|
|
// 收到deepThinking消息
|
|
eventSource.addEventListener('deepThinking', (event) => {
|
|
let data = event.data?.replace(/\\x0A/g, '\n ');
|
|
if (data?.includes("[BEGIN]")) {
|
|
deepThinkingMessage.value = "";
|
|
deepThinkingStatus.value = "thinking";
|
|
return;
|
|
}
|
|
if (data?.includes("[DONE]")) {
|
|
deepThinkingMessage.value = deepThinkingMessage.value;
|
|
deepThinkingStatus.value = "end";
|
|
return;
|
|
}
|
|
deepThinkingMessage.value = deepThinkingMessage.value + data;
|
|
});
|
|
|
|
// 收到chat消息
|
|
eventSource.addEventListener('chat', (event) => {
|
|
let data = event.data?.replace(/\\x0A/g, '\n ');
|
|
if (data?.includes("[BEGIN]")) {
|
|
currentChatItemMessage.value = "";
|
|
currentChatItem.value.content = "";
|
|
chatList.value[chatList.value.length - 1].content = "";
|
|
chatList.value[chatList.value.length - 1].loading = true;
|
|
isSenderLoading.value = true;
|
|
return;
|
|
}
|
|
if (data?.includes("[DONE]")) {
|
|
currentChatItem.value.content = currentChatItemMessage.value;
|
|
chatList.value[chatList.value.length - 1].loading = false;
|
|
chatList.value[chatList.value.length - 1].isMarkdown = true;
|
|
chatList.value[chatList.value.length - 1].content = currentChatItemMessage.value;
|
|
return;
|
|
}
|
|
currentChatItemMessage.value = currentChatItemMessage.value + data; //先接收流式数据
|
|
|
|
// 收到消息后重置重试计数
|
|
retryCount = 0;
|
|
reconnectTimer = null;
|
|
});
|
|
|
|
// 收到ping消息, 用于心跳检测
|
|
eventSource.addEventListener('ping', (event) => {
|
|
console.log('heat beat:', event.data);
|
|
// 收到ping后重置重试计数
|
|
retryCount = 0;
|
|
reconnectTimer = null;
|
|
});
|
|
|
|
// 检查连接状态
|
|
const checkConnection = () => {
|
|
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
|
|
console.log('Connection closed, attempting to reconnect...');
|
|
if (retryCount < maxRetries) {
|
|
retryCount++;
|
|
console.log(`Attempting to reconnect (${retryCount}/${maxRetries})...`);
|
|
reconnectTimer = window.setTimeout(() => {
|
|
initSSEConnection();
|
|
}, reconnectInterval);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 定期检查连接状态
|
|
const connectionCheckInterval = setInterval(checkConnection, 3000);
|
|
|
|
eventSource.onerror = () => {
|
|
console.log('SSE connection error');
|
|
if (eventSource?.readyState === EventSource.CLOSED) {
|
|
if (connectionCheckInterval) {
|
|
clearInterval(connectionCheckInterval);
|
|
}
|
|
if (retryCount < maxRetries) {
|
|
retryCount++;
|
|
console.log(`Attempting to reconnect (${retryCount}/${maxRetries})...`);
|
|
reconnectTimer = window.setTimeout(() => {
|
|
initSSEConnection();
|
|
}, reconnectInterval);
|
|
}
|
|
}
|
|
};
|
|
|
|
eventSource.onopen = (event) => {
|
|
console.log('SSE connection opened:', event);
|
|
retryCount = 0; // 连接成功后重置重试计数
|
|
reconnectTimer = null;
|
|
}
|
|
}
|
|
|
|
|
|
const closeSSEConnection = () => {
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
}
|
|
const handleBubbleComplete = (instance: TypewriterInstance, index: number) => {
|
|
isSenderLoading.value = false;
|
|
}
|
|
//#endregion sse客户端
|
|
const handleSend = async () => {
|
|
if (!senderInput.value.trim()) return
|
|
isSenderLoading.value = true;
|
|
currentChatItemMessage.value = "";
|
|
if (isNew.value) {
|
|
//新建聊天
|
|
isNew.value = false;
|
|
nextTick(async () => {
|
|
await newChat();
|
|
})
|
|
} else {
|
|
//续聊
|
|
await continueChat();
|
|
}
|
|
}
|
|
//新建聊天
|
|
const newChat = async () => {
|
|
let inputStr = senderInput.value;
|
|
senderInput.value = "";
|
|
currentChatItemMessage.value = "";
|
|
let maxId = historyListSource.reduce((max, item) => Math.max(max, item.id || 0), 0);
|
|
maxId++;
|
|
let chatSummary: LLMChatSummaryHistory = {
|
|
id: maxId,
|
|
userId: userId(),
|
|
summary: "New Chat",
|
|
uniqueToken: "add_new_chat",
|
|
utcCreateTime: (new Date()).getTime(),
|
|
histories: [],
|
|
} as LLMChatSummaryHistory
|
|
let userChat: LLMChatHistory = {
|
|
id: 0,
|
|
summaryId: chatSummary.id,
|
|
role: 'user',
|
|
content: inputStr,
|
|
userId: userId(),
|
|
utcCreateTime: (new Date()).getTime(),
|
|
} as LLMChatHistory
|
|
chatSummary.histories?.push(userChat)
|
|
chatList.value = [] as BubbleListProps<listType>['list'];
|
|
addChatItem(userChat, false)
|
|
let assistantChat: LLMChatHistory = {
|
|
id: 0,
|
|
summaryId: chatSummary.id,
|
|
role: 'assistant',
|
|
content: '',
|
|
userId: userId(),
|
|
utcCreateTime: (new Date()).getTime(),
|
|
} as LLMChatHistory
|
|
chatSummary.histories?.push(assistantChat)
|
|
addChatItem(assistantChat, true)
|
|
currentChatItem.value = assistantChat;
|
|
if (isDeepThinking.value) {
|
|
deepThinkingVisible.value = true;
|
|
deepThinkingStatus.value = "start";
|
|
deepThinkingMessage.value = t('message.chat.thinkingPrepare');
|
|
}
|
|
chatRef.value.scrollToBottom();
|
|
// 添加到历史记录列表
|
|
historyListSource.push(chatSummary)
|
|
sideBarHistoryList.value.unshift({
|
|
key: chatSummary.uniqueToken || '',
|
|
label: chatSummary.summary || '',
|
|
})
|
|
activeHistoryKey.value = chatSummary.uniqueToken || '';
|
|
let resultData = await getAPI(LLMChatApi).apiLLMChatChatPost({
|
|
uniqueToken: chatSummary.uniqueToken || '',
|
|
message: inputStr,
|
|
providerName: modellList.value.providerName,
|
|
modelId: modellList.value.currentModel,
|
|
summaryId: chatSummary.id,
|
|
summary: chatSummary.summary || '',
|
|
deepThinking: isDeepThinking.value,
|
|
});
|
|
let result = resultData.data?.result || {} as ChatOutput;
|
|
//更新摘要与侧边栏聊天数据
|
|
chatSummary.summary = result.summary;
|
|
chatSummary.uniqueToken = result.uniqueToken;
|
|
let sideBarItem = sideBarHistoryList.value.find(u => u.key == 'add_new_chat');
|
|
if (sideBarItem) {
|
|
sideBarItem.label = result.summary || '';
|
|
sideBarItem.key = result.uniqueToken || '';
|
|
}
|
|
activeHistoryKey.value = result.uniqueToken || '';
|
|
}
|
|
//续聊:已经有自己的摘要与聊天记录
|
|
const continueChat = async () => {
|
|
if (!activeHistoryKey.value) return;
|
|
isSenderLoading.value = true;
|
|
let inputStr = senderInput.value;
|
|
senderInput.value = "";
|
|
let currentHistoryItem = historyListSource.find(u => u.uniqueToken == activeHistoryKey.value); //获取当前聊天的记录与摘要
|
|
if (!currentHistoryItem) return;
|
|
let list = currentHistoryItem.histories;
|
|
let userChat: LLMChatHistory = {
|
|
id: 0,
|
|
summaryId: currentHistoryItem.id,
|
|
role: 'user',
|
|
content: inputStr,
|
|
summary: currentHistoryItem as LLMChatSummaryHistory,
|
|
userId: currentHistoryItem.userId,
|
|
utcCreateTime: (new Date()).getTime(),
|
|
} as LLMChatHistory
|
|
list?.push(userChat)
|
|
addChatItem(userChat, false);
|
|
let assistantChat: LLMChatHistory = {
|
|
id: 0,
|
|
summaryId: currentHistoryItem.id,
|
|
role: 'assistant',
|
|
content: '',
|
|
summary: currentHistoryItem as LLMChatSummaryHistory,
|
|
userId: currentHistoryItem.userId,
|
|
utcCreateTime: (new Date()).getTime(),
|
|
} as LLMChatHistory
|
|
list?.push(assistantChat)
|
|
currentChatItem.value = assistantChat;
|
|
addChatItem(assistantChat, true);
|
|
if (isDeepThinking.value) {
|
|
deepThinkingVisible.value = true;
|
|
deepThinkingStatus.value = "start";
|
|
deepThinkingMessage.value = t('message.chat.thinkingPrepare');
|
|
}
|
|
|
|
chatRef.value.scrollToBottom();
|
|
let resultData = await getAPI(LLMChatApi).apiLLMChatChatPost({
|
|
uniqueToken: currentHistoryItem.uniqueToken,
|
|
message: inputStr,
|
|
providerName: modellList.value.providerName,
|
|
modelId: modellList.value.currentModel,
|
|
summaryId: currentHistoryItem.id,
|
|
summary: currentHistoryItem.summary,
|
|
deepThinking: isDeepThinking.value,
|
|
});
|
|
let result = resultData.data?.result || {} as ChatOutput;
|
|
currentHistoryItem.summary = result.summary;
|
|
if (currentHistoryItem.id != result.summaryId) {
|
|
currentHistoryItem.id = result.summaryId || currentHistoryItem.id;
|
|
}
|
|
let sideBarItem = sideBarHistoryList.value.find(u => u.key == currentHistoryItem.uniqueToken);
|
|
if (sideBarItem) {
|
|
sideBarItem.label = result.summary || '';
|
|
}
|
|
senderInput.value = '';
|
|
}
|
|
|
|
// 深度思考
|
|
const handleDeepThinking = () => {
|
|
isDeepThinking.value = !isDeepThinking.value;
|
|
if (!isDeepThinking.value) {
|
|
deepThinkingVisible.value = false;
|
|
}
|
|
}
|
|
// 新建聊天
|
|
const handleNewChat = () => {
|
|
if (isSenderLoading.value) return;
|
|
isNew.value = true;
|
|
chatList.value.length = 0;
|
|
}
|
|
// 折叠侧边栏
|
|
const handleFoldChat = () => {
|
|
isFold.value = true;
|
|
sidebarWidth.value = isFold.value ? 0 : 260;
|
|
}
|
|
// 展开侧边栏
|
|
const handleExpandChat = () => {
|
|
isFold.value = false;
|
|
sidebarWidth.value = isFold.value ? 0 : 260;
|
|
}
|
|
// 切换模型
|
|
const handleChangeModel = async (item: ModelListOutputItem) => {
|
|
let res = await getAPI(LLMChatApi).apiLLMChatChangeModelPost({
|
|
modelName: item.modelName,
|
|
providerName: item.providerName,
|
|
});
|
|
if (res.data?.result) {
|
|
modellList.value.currentModel = item.modelName;
|
|
} else {
|
|
ElMessage.error(t('message.chat.changeModelError'));
|
|
}
|
|
}
|
|
// 切换历史记录
|
|
const handleChange = (item: ConversationItem<{ key: string, label: string }>) => {
|
|
if (isSenderLoading.value) return;
|
|
activeHistoryKey.value = item.key;
|
|
isNew.value = false;
|
|
chatList.value.length = 0;
|
|
let currentHistoryItem = historyListSource.find(u => u.uniqueToken == item.key);
|
|
if (currentHistoryItem) {
|
|
let list = currentHistoryItem.histories;
|
|
list = list?.filter((u: LLMChatHistory) => u.role == 'user' || u.role == 'assistant');
|
|
list?.forEach((u: LLMChatHistory) => addChatItem(u))
|
|
}
|
|
}
|
|
// 添加聊天记录
|
|
const addChatItem = (chatItem: LLMChatHistory, typing: boolean = false) => {
|
|
const placement: 'end' | 'start' = chatItem.role == 'user' ? 'end' : 'start';
|
|
const isMarkdown = chatItem.role == 'user' ? false : true;
|
|
const maxWidth = chatItem.role == 'user' ? '500px' : '100%';
|
|
const noStyle = chatItem.role == 'user' ? false : true;
|
|
let addRow = {
|
|
role: chatItem.role as 'user' | 'assistant',
|
|
content: chatItem.content || '',
|
|
key: uuidv4(),
|
|
placement: placement,
|
|
isMarkdown: isMarkdown,
|
|
maxWidth: maxWidth,
|
|
noStyle: noStyle,
|
|
loading: typing,
|
|
typing,
|
|
}
|
|
chatList.value.push(addRow);
|
|
}
|
|
|
|
// 复制
|
|
const handleCopy = (item: BubbleListItemProps) => {
|
|
const textToCopy = item.content || '';
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(textToCopy).then(() => {
|
|
ElMessage.success(t('message.chat.copySuccess'));
|
|
}).catch(err => {
|
|
ElMessage.error(t('message.chat.copyError'));
|
|
});
|
|
} else {
|
|
ElMessage.error(t('message.chat.browerNotSupport'));
|
|
}
|
|
}
|
|
// 播放语音
|
|
const handlePlay = (item: any) => {
|
|
console.log('handlePlay:', item.key);
|
|
isPlaying.value = true;
|
|
playAudioKey.value = item.key;
|
|
if (utterance) {
|
|
window.speechSynthesis.cancel();
|
|
}
|
|
utterance = new SpeechSynthesisUtterance(item.content);
|
|
const lang = franc(item.content || '');
|
|
utterance.lang = lang;
|
|
utterance.onend = () => {
|
|
isPlaying.value = false;
|
|
};
|
|
window.speechSynthesis.speak(utterance);
|
|
}
|
|
// 暂停语音
|
|
const handlePause = (item: BubbleListItemProps) => {
|
|
isPlaying.value = false;
|
|
if (utterance) {
|
|
window.speechSynthesis.cancel();
|
|
}
|
|
}
|
|
//侧边栏历史记录加载更多
|
|
const loadMoreHistoryItems = async () => {
|
|
if (isHistoryListLoading.value)
|
|
return
|
|
state.value.page = (state.value.page || 0) + 1;
|
|
if (state.value.page > state.value.totalPages) {
|
|
state.value.page = state.value.totalPages;
|
|
return;
|
|
}
|
|
isHistoryListLoading.value = true
|
|
await getHistoryList();
|
|
isHistoryListLoading.value = false
|
|
}
|
|
|
|
// 处理菜单点击
|
|
const renameDialogVisible = ref(false);
|
|
const currentEditItem = ref<any>(null);
|
|
const renameInput = ref('');
|
|
const renameModalRef = ref<HTMLElement | null>(null);
|
|
let dragData = { dragging: false, offsetX: 0, offsetY: 0 };
|
|
|
|
const handleMenuClick = (menuKey: string, item: any) => {
|
|
switch (menuKey) {
|
|
case 'rename':
|
|
currentEditItem.value = item;
|
|
renameInput.value = item.label;
|
|
renameDialogVisible.value = true;
|
|
break;
|
|
case 'delete':
|
|
deleteHistoryItem(item);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
const deleteHistoryItem = async (item: any) => {
|
|
await ElMessageBox.confirm(t('message.chat.confirmDeleteHistoryItem', { historyItem: item.label }), 'warning', {
|
|
confirmButtonText: t('message.chat.confirm'),
|
|
cancelButtonText: t('message.chat.cancel'),
|
|
});
|
|
let currentItem = historyListSource.find(u => u.uniqueToken == item.key);
|
|
if (currentItem) {
|
|
let res = await getAPI(LLMChatApi).apiLLMChatDeleteSummaryAllPost({
|
|
uniqueToken: currentItem.uniqueToken,
|
|
summaryId: currentItem.id,
|
|
});
|
|
if (res.data?.result) {
|
|
historyListSource.splice(historyListSource.indexOf(currentItem), 1);
|
|
sideBarHistoryList.value = sideBarHistoryList.value.filter(u => u.key != currentItem.uniqueToken);
|
|
isNew.value = true;
|
|
}
|
|
} else {
|
|
ElMessage.error(t('message.chat.deleteHistoryItemError'));
|
|
}
|
|
}
|
|
|
|
|
|
// 重命名的方法
|
|
async function confirmRename() {
|
|
if (currentEditItem.value && renameInput.value.trim()) {
|
|
if (currentEditItem.value.label != renameInput.value.trim()) {
|
|
let currentItem = historyListSource.find(item => item.uniqueToken == currentEditItem.value.key);
|
|
if (currentItem) {
|
|
currentItem.summary = renameInput.value.trim();
|
|
await getAPI(LLMChatApi).apiLLMChatRenameSummaryLablePost({
|
|
uniqueToken: currentItem.uniqueToken,
|
|
summary: renameInput.value.trim(),
|
|
summaryId: currentItem.id,
|
|
message: '',
|
|
providerName: modellList.value.providerName,
|
|
modelId: modellList.value.currentModel,
|
|
});
|
|
}
|
|
let leftCurrentItem = sideBarHistoryList.value.find(item => item.key == currentEditItem.value.key);
|
|
if (leftCurrentItem) {
|
|
leftCurrentItem.label = renameInput.value.trim();
|
|
}
|
|
currentEditItem.value = null;
|
|
renameInput.value = '';
|
|
}
|
|
renameDialogVisible.value = false;
|
|
} else {
|
|
ElMessage.error(t('message.chat.titleCannotBeEmpty'));
|
|
}
|
|
}
|
|
|
|
// 添加取消重命名的方法
|
|
const cancelRename = () => {
|
|
renameDialogVisible.value = false;
|
|
currentEditItem.value = null;
|
|
renameInput.value = '';
|
|
}
|
|
|
|
|
|
//分页获取历史记录
|
|
async function getHistoryList() {
|
|
let res = await getAPI(LLMChatApi).apiLLMChatChatListPost(state.value);
|
|
historyListSource = [...historyListSource, ...(res.data?.result?.items || [] as ChatListOutput[])];
|
|
state.value.totalPages = res.data?.result?.totalPages || 0;
|
|
sideBarHistoryList.value = [...sideBarHistoryList.value, ...(historyListSource.map((item: ChatListOutput) => {
|
|
return {
|
|
key: item.uniqueToken || '',
|
|
label: item.summary || '',
|
|
} as { key: string; label: string };
|
|
}))];
|
|
}
|
|
//#region 自定义弹窗口
|
|
function startDrag(e: MouseEvent) {
|
|
if (!renameModalRef.value) return;
|
|
dragData.dragging = true;
|
|
const modal = renameModalRef.value;
|
|
const rect = modal.getBoundingClientRect();
|
|
dragData.offsetX = e.clientX - rect.left;
|
|
dragData.offsetY = e.clientY - rect.top;
|
|
document.addEventListener('mousemove', onDragMove);
|
|
document.addEventListener('mouseup', stopDrag);
|
|
}
|
|
function onDragMove(e: MouseEvent) {
|
|
if (!dragData.dragging || !renameModalRef.value) return;
|
|
const modal = renameModalRef.value;
|
|
let left = e.clientX - dragData.offsetX;
|
|
let top = e.clientY - dragData.offsetY;
|
|
// 限制弹窗不出屏幕
|
|
const minLeft = 0, minTop = 0;
|
|
const maxLeft = window.innerWidth - modal.offsetWidth;
|
|
const maxTop = window.innerHeight - modal.offsetHeight;
|
|
left = Math.max(minLeft, Math.min(left, maxLeft));
|
|
top = Math.max(minTop, Math.min(top, maxTop));
|
|
modal.style.left = left + 'px';
|
|
modal.style.top = top + 'px';
|
|
modal.style.margin = '0';
|
|
modal.style.position = 'fixed';
|
|
}
|
|
function stopDrag() {
|
|
dragData.dragging = false;
|
|
document.removeEventListener('mousemove', onDragMove);
|
|
document.removeEventListener('mouseup', stopDrag);
|
|
}
|
|
// 弹窗初始居中
|
|
watch(() => renameDialogVisible.value, (val) => {
|
|
if (val && renameModalRef.value) {
|
|
nextTick(() => {
|
|
const modal = renameModalRef.value;
|
|
if (modal) {
|
|
modal.style.left = '';
|
|
modal.style.top = '';
|
|
modal.style.margin = '';
|
|
modal.style.position = '';
|
|
}
|
|
});
|
|
}
|
|
});
|
|
// #endregion 自定义弹窗口
|
|
|
|
onMounted(async () => {
|
|
initSSEConnection();
|
|
let res = await getAPI(LLMChatApi).apiLLMChatModelListGet();
|
|
modellList.value = res.data?.result || {};
|
|
canSwitchModel.value = res.data?.result?.userCanSwitchLLM || false;
|
|
if (!modellList.value.models || modellList.value.models.length == 0) {
|
|
ElMessage.error(t('message.chat.fetchModelError'));
|
|
return;
|
|
}
|
|
await getHistoryList();
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
closeSSEConnection();
|
|
if (utterance) {
|
|
window.speechSynthesis.cancel();
|
|
utterance = null;
|
|
}
|
|
})
|
|
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.chat-action {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
height: 48px;
|
|
}
|
|
|
|
.el-menu-vertical-demo {
|
|
border: none;
|
|
}
|
|
|
|
.fill-parent,
|
|
.el-container,
|
|
.el-main {
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
.avatar-with-shadow {
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.avatar-with-shadow:hover {
|
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
|
transform: translateY(-2px);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chat-action-item {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: center;
|
|
align-items: center;
|
|
border-radius: 4px;
|
|
text-align: center;
|
|
height: 2em;
|
|
width: 2em;
|
|
background-color: #f3f4f6;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chat-action-item:hover {
|
|
background-color: #e5e7eb;
|
|
}
|
|
|
|
.main_action_item {
|
|
background-color: #ffffff;
|
|
}
|
|
|
|
.main_action_toolbar {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: flex-start;
|
|
align-items: center;
|
|
gap: 10px;
|
|
height: 48px;
|
|
}
|
|
|
|
.sidebar-fold {
|
|
width: 0;
|
|
}
|
|
|
|
.expand-sidebar {
|
|
width: 260px;
|
|
}
|
|
|
|
.example-showcase .el-dropdown-link {
|
|
cursor: pointer;
|
|
color: var(--el-color-primary);
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.model-select {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: center;
|
|
align-items: center;
|
|
font-size: 18px !important;
|
|
font-weight: bold;
|
|
color: #333;
|
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.model-item {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
width: 240px;
|
|
}
|
|
|
|
.menu-buttons {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
padding: 12px;
|
|
|
|
// 自定义菜单按钮-el-button样式
|
|
.el-button {
|
|
padding: 4px 8px;
|
|
margin-left: 0;
|
|
text-align: left;
|
|
|
|
.el-icon {
|
|
margin-right: 8px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.ai-modal-fade-enter-active,
|
|
.ai-modal-fade-leave-active {
|
|
transition: opacity 0.18s cubic-bezier(.55, 0, .1, 1), transform 0.18s cubic-bezier(.55, 0, .1, 1);
|
|
}
|
|
|
|
.ai-modal-fade-enter-from,
|
|
.ai-modal-fade-leave-to {
|
|
opacity: 0;
|
|
transform: scale(0.96);
|
|
}
|
|
|
|
.ai-modal-fade-enter-to,
|
|
.ai-modal-fade-leave-from {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
|
|
.ai-rename-modal-mask {
|
|
position: fixed;
|
|
z-index: 3000;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.ai-rename-modal {
|
|
background: #fff;
|
|
border-radius: 20px;
|
|
box-shadow: 0 8px 32px rgba(36, 120, 255, 0.10);
|
|
min-width: 320px;
|
|
max-width: 90vw;
|
|
padding: 32px 24px 18px 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
position: relative;
|
|
/* 拖拽后会变成fixed */
|
|
}
|
|
|
|
.ai-rename-drag {
|
|
cursor: move;
|
|
}
|
|
|
|
.ai-rename-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 18px;
|
|
width: 100%;
|
|
}
|
|
|
|
.ai-rename-icon {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.ai-rename-input {
|
|
width: 260px;
|
|
height: 44px;
|
|
|
|
:deep(.el-input__wrapper) {
|
|
border-radius: 22px;
|
|
font-size: 18px;
|
|
padding: 0 18px;
|
|
box-shadow: 0 2px 12px rgba(36, 120, 255, 0.08);
|
|
background: #f7faff;
|
|
}
|
|
}
|
|
|
|
.ai-dialog-footer {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 18px;
|
|
width: 100%;
|
|
margin-top: 15px;
|
|
|
|
.el-button {
|
|
border-radius: 20px;
|
|
font-size: 16px;
|
|
padding: 8px 28px;
|
|
font-weight: 500;
|
|
|
|
&.ai-btn-cancel {
|
|
background: #f7faff;
|
|
color: #409EFF;
|
|
border: none;
|
|
}
|
|
|
|
&.ai-btn-confirm {
|
|
background: linear-gradient(90deg, #409EFF 0%, #36D1DC 100%);
|
|
color: #fff;
|
|
border: none;
|
|
}
|
|
}
|
|
}
|
|
|
|
.new_chat_title {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 90%;
|
|
}
|
|
|
|
.chat_content {
|
|
width: 100%;
|
|
margin-top: 50px;
|
|
flex: 1;
|
|
height: 100%;
|
|
background-color: #fff;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.isDeepThinking {
|
|
color: #626aef;
|
|
border: 1px solid #626aef !important;
|
|
border-radius: 15px;
|
|
padding: 3px 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.chat_new_input_style {
|
|
width: calc(100% - 120px);
|
|
position: static;
|
|
margin-top: 30px;
|
|
padding: 0;
|
|
background-color: #fff;
|
|
}
|
|
|
|
.chat_edit_input_style {
|
|
width: calc(100% - 120px);
|
|
position: absolute;
|
|
bottom: 0;
|
|
background-color: #fff;
|
|
// display:none;
|
|
}
|
|
|
|
.chat_content_list :deep(.typer-content) {
|
|
font-size: 16px !important;
|
|
letter-spacing: 0.03em !important;
|
|
line-height: 2em !important;
|
|
font-weight: 500 !important;
|
|
}
|
|
</style>
|