All checks were successful
Lint Code / Lint Code (push) Successful in 3m55s
- 将自定义消息列表和输入区域替换为DeepChat组件 - 新增ChatGPT组件支持历史记录传递和自定义请求处理 - 重构消息处理逻辑,简化SSE连接管理 - 改进项目选择和会话管理流程
612 lines
15 KiB
Vue
612 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted } from "vue";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||
import {
|
||
getChatSessions,
|
||
getSessionMessages,
|
||
deleteChatSession,
|
||
buildSSEUrl,
|
||
type ChatSessionVO,
|
||
type ChatMessageVO,
|
||
type SSEStartData,
|
||
type SSEChunkData,
|
||
type SSECompleteData,
|
||
type SSEErrorData
|
||
} from "@/api/ai-chat";
|
||
import { getProjectList, type ProjectItem } from "@/api/project";
|
||
import { createSSEConnection } from "@/utils/sse/chatSSE";
|
||
import ChatGPT from "@/views/chatai/components/ChatGPT.vue";
|
||
import dayjs from "dayjs";
|
||
|
||
import AddIcon from "~icons/ri/add-line";
|
||
import DeleteIcon from "~icons/ep/delete";
|
||
import RefreshIcon from "~icons/ri/refresh-line";
|
||
|
||
defineOptions({
|
||
name: "AiChat"
|
||
});
|
||
|
||
// 状态
|
||
const loading = ref(false);
|
||
const sessions = ref<ChatSessionVO[]>([]);
|
||
const currentSession = ref<ChatSessionVO | null>(null);
|
||
const messages = ref<ChatMessageVO[]>([]);
|
||
const sending = ref(false);
|
||
const projects = ref<ProjectItem[]>([]);
|
||
const projectLoading = ref(false);
|
||
const showProjectSelect = ref(false);
|
||
const currentProjectId = ref<string>("");
|
||
const didInitProjectAndSessions = ref(false);
|
||
const activeSessionKey = ref<string>("");
|
||
const chatRenderKey = ref(0);
|
||
const deepChatHistory = ref<Array<{ text: string; role: "user" | "ai" }>>([]);
|
||
|
||
// SSE连接关闭函数
|
||
let abortSSE: (() => void) | null = null;
|
||
|
||
// 加载项目列表
|
||
async function loadProjects() {
|
||
projectLoading.value = true;
|
||
try {
|
||
const res = await getProjectList({ pageNum: 1, pageSize: 100 });
|
||
if (res.code === 200) {
|
||
projects.value = res.data?.rows || [];
|
||
}
|
||
} catch (error) {
|
||
console.error("加载项目列表失败:", error);
|
||
} finally {
|
||
projectLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// 加载会话列表
|
||
async function loadSessions(projectId?: string) {
|
||
const pid =
|
||
projectId ||
|
||
currentProjectId.value ||
|
||
currentSession.value?.projectId ||
|
||
"";
|
||
if (!pid) {
|
||
sessions.value = [];
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
try {
|
||
const res = await getChatSessions(pid);
|
||
if (res.code === 200) {
|
||
sessions.value = res.data || [];
|
||
}
|
||
} catch (error) {
|
||
console.error("加载会话列表失败:", error);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function setCurrentProject(project: ProjectItem) {
|
||
const pid = String(project.id || "");
|
||
if (!pid) return;
|
||
|
||
currentProjectId.value = pid;
|
||
if (!currentSession.value) {
|
||
currentSession.value = {
|
||
sessionId: "",
|
||
sessionTitle: "新对话",
|
||
projectId: pid,
|
||
projectName: project.projectName || "",
|
||
lastMessageTime: new Date().toISOString(),
|
||
messageCount: 0,
|
||
createTime: new Date().toISOString()
|
||
};
|
||
} else if (!currentSession.value.sessionId) {
|
||
currentSession.value = {
|
||
...currentSession.value,
|
||
projectId: pid,
|
||
projectName: project.projectName || ""
|
||
};
|
||
}
|
||
}
|
||
|
||
// 选择会话
|
||
async function selectSession(session: ChatSessionVO) {
|
||
currentSession.value = session;
|
||
currentProjectId.value = String(session.projectId || "");
|
||
activeSessionKey.value = String(session.sessionId || "");
|
||
deepChatHistory.value = [];
|
||
chatRenderKey.value += 1;
|
||
await loadMessages(session.sessionId);
|
||
}
|
||
|
||
// 加载会话消息
|
||
async function loadMessages(sessionId: string) {
|
||
if (!sessionId) return;
|
||
try {
|
||
const res = await getSessionMessages(sessionId);
|
||
if (res.code === 200) {
|
||
messages.value = res.data || [];
|
||
deepChatHistory.value = (messages.value || [])
|
||
.filter(m => m.role === "user" || m.role === "assistant")
|
||
.map(m => ({
|
||
text: m.content,
|
||
role: m.role === "user" ? "user" : "ai"
|
||
}));
|
||
chatRenderKey.value += 1;
|
||
}
|
||
} catch (error) {
|
||
console.error("加载消息失败:", error);
|
||
}
|
||
}
|
||
|
||
// 创建新会话
|
||
async function createNewSession() {
|
||
if (!currentSession.value?.projectId && projects.value.length === 0) {
|
||
await loadProjects();
|
||
}
|
||
showProjectSelect.value = true;
|
||
}
|
||
|
||
// 选择项目后创建会话
|
||
async function handleProjectSelect(project: ProjectItem) {
|
||
showProjectSelect.value = false;
|
||
const pid = String(project.id || "");
|
||
if (!pid) {
|
||
ElMessage.warning("项目ID缺失,无法创建会话");
|
||
return;
|
||
}
|
||
currentProjectId.value = pid;
|
||
currentSession.value = {
|
||
sessionId: "",
|
||
sessionTitle: "新对话",
|
||
projectId: pid,
|
||
projectName: project.projectName || "",
|
||
lastMessageTime: new Date().toISOString(),
|
||
messageCount: 0,
|
||
createTime: new Date().toISOString()
|
||
};
|
||
messages.value = [];
|
||
deepChatHistory.value = [];
|
||
activeSessionKey.value = `draft-${pid}-${Date.now()}`;
|
||
chatRenderKey.value += 1;
|
||
await loadSessions(currentProjectId.value);
|
||
}
|
||
|
||
async function requestAssistantReply(messageText: string): Promise<string> {
|
||
if (!messageText.trim()) return "";
|
||
if (!currentSession.value?.projectId) {
|
||
ElMessage.warning("请先选择项目");
|
||
return "请先选择项目";
|
||
}
|
||
|
||
sending.value = true;
|
||
const sseUrl = buildSSEUrl({
|
||
sessionId: currentSession.value.sessionId || undefined,
|
||
projectId: currentSession.value.projectId,
|
||
message: messageText,
|
||
useRag: true
|
||
});
|
||
|
||
if (abortSSE) {
|
||
abortSSE();
|
||
abortSSE = null;
|
||
}
|
||
|
||
let fullText = "";
|
||
const replyText = await new Promise<string>(async resolve => {
|
||
abortSSE = await createSSEConnection({
|
||
url: sseUrl,
|
||
onEvent: (eventName: string, data: any) => {
|
||
switch (eventName) {
|
||
case "start": {
|
||
const startData = data as SSEStartData;
|
||
if (startData.isNewSession || !currentSession.value?.sessionId) {
|
||
currentSession.value = {
|
||
...currentSession.value!,
|
||
sessionId: startData.sessionId,
|
||
sessionTitle: messageText.slice(0, 20)
|
||
};
|
||
loadSessions(currentSession.value.projectId);
|
||
}
|
||
break;
|
||
}
|
||
case "chunk": {
|
||
const chunkData = data as SSEChunkData;
|
||
fullText += chunkData.content;
|
||
break;
|
||
}
|
||
case "complete": {
|
||
resolve(fullText);
|
||
break;
|
||
}
|
||
case "error": {
|
||
const errorData = data as SSEErrorData;
|
||
resolve(errorData.message || "对话发生错误");
|
||
break;
|
||
}
|
||
}
|
||
},
|
||
onError: (error: Error) => {
|
||
resolve("连接服务器失败: " + error.message);
|
||
}
|
||
});
|
||
});
|
||
|
||
sending.value = false;
|
||
if (abortSSE) {
|
||
abortSSE();
|
||
abortSSE = null;
|
||
}
|
||
loadSessions(currentSession.value?.projectId);
|
||
return replyText;
|
||
}
|
||
|
||
// 删除会话
|
||
async function handleDeleteSession(session: ChatSessionVO) {
|
||
try {
|
||
await ElMessageBox.confirm("确定要删除这个会话吗?", "提示", {
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "warning"
|
||
});
|
||
|
||
const res = await deleteChatSession(session.sessionId);
|
||
if (res.code === 200) {
|
||
ElMessage.success("删除成功");
|
||
loadSessions(currentSession.value?.projectId);
|
||
|
||
if (currentSession.value?.sessionId === session.sessionId) {
|
||
messages.value = [];
|
||
deepChatHistory.value = [];
|
||
const defaultProject = projects.value[0];
|
||
if (defaultProject?.id) {
|
||
currentSession.value = null;
|
||
setCurrentProject(defaultProject);
|
||
activeSessionKey.value = `draft-${String(defaultProject.id)}-${Date.now()}`;
|
||
chatRenderKey.value += 1;
|
||
} else {
|
||
currentSession.value = null;
|
||
activeSessionKey.value = "";
|
||
chatRenderKey.value += 1;
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (error !== "cancel") {
|
||
console.error("删除会话失败:", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 格式化时间
|
||
function formatTime(time: string) {
|
||
return dayjs(time).format("MM-DD HH:mm");
|
||
}
|
||
|
||
// 组件卸载时关闭SSE连接
|
||
onUnmounted(() => {
|
||
if (abortSSE) {
|
||
abortSSE();
|
||
abortSSE = null;
|
||
}
|
||
});
|
||
|
||
async function initPage() {
|
||
await loadProjects();
|
||
if (!didInitProjectAndSessions.value && projects.value.length > 0) {
|
||
setCurrentProject(projects.value[0]);
|
||
didInitProjectAndSessions.value = true;
|
||
}
|
||
if (!activeSessionKey.value && currentProjectId.value) {
|
||
activeSessionKey.value = `draft-${currentProjectId.value}-${Date.now()}`;
|
||
}
|
||
await loadSessions();
|
||
const firstSession = sessions.value[0];
|
||
if (firstSession?.sessionId) {
|
||
await selectSession(firstSession);
|
||
} else {
|
||
deepChatHistory.value = [];
|
||
chatRenderKey.value += 1;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
initPage();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="ai-chat-container">
|
||
<!-- 左侧会话列表 -->
|
||
<div class="session-sidebar">
|
||
<div class="sidebar-header">
|
||
<h3>AI 助手</h3>
|
||
<el-button
|
||
type="primary"
|
||
:icon="useRenderIcon(AddIcon)"
|
||
@click="createNewSession"
|
||
>
|
||
新对话
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 项目选择 -->
|
||
<div v-if="showProjectSelect" class="project-select-panel">
|
||
<div class="panel-header">
|
||
<span>选择项目</span>
|
||
<el-button link @click="showProjectSelect = false">
|
||
<component :is="useRenderIcon('ri/close-line')" />
|
||
</el-button>
|
||
</div>
|
||
<el-scrollbar height="300px">
|
||
<div
|
||
v-for="project in projects"
|
||
:key="project.id"
|
||
class="project-item"
|
||
@click="handleProjectSelect(project)"
|
||
>
|
||
<component :is="useRenderIcon('ri/folder-line')" class="mr-2" />
|
||
{{ project.projectName }}
|
||
</div>
|
||
</el-scrollbar>
|
||
</div>
|
||
|
||
<!-- 会话列表 -->
|
||
<el-scrollbar class="session-list">
|
||
<div v-if="loading" class="loading-placeholder">
|
||
<el-icon class="is-loading"
|
||
><component :is="useRenderIcon('ri/loader-4-line')"
|
||
/></el-icon>
|
||
<span>加载中...</span>
|
||
</div>
|
||
<template v-else>
|
||
<div
|
||
v-for="session in sessions"
|
||
:key="session.sessionId"
|
||
:class="[
|
||
'session-item',
|
||
{ active: currentSession?.sessionId === session.sessionId }
|
||
]"
|
||
@click="selectSession(session)"
|
||
>
|
||
<div class="session-info">
|
||
<div class="session-title">{{ session.sessionTitle }}</div>
|
||
<div class="session-meta">
|
||
<span>{{ session.projectName }}</span>
|
||
<span>{{ formatTime(session.lastMessageTime) }}</span>
|
||
</div>
|
||
</div>
|
||
<el-button
|
||
link
|
||
class="delete-btn"
|
||
@click.stop="handleDeleteSession(session)"
|
||
>
|
||
<component :is="useRenderIcon(DeleteIcon)" />
|
||
</el-button>
|
||
</div>
|
||
<el-empty
|
||
v-if="sessions.length === 0"
|
||
description="暂无会话"
|
||
:image-size="80"
|
||
/>
|
||
</template>
|
||
</el-scrollbar>
|
||
</div>
|
||
|
||
<!-- 右侧聊天区域 -->
|
||
<div class="chat-main">
|
||
<template v-if="currentSession">
|
||
<!-- 会话头部 -->
|
||
<div class="chat-header">
|
||
<div class="header-info">
|
||
<h4>{{ currentSession.sessionTitle }}</h4>
|
||
<span class="project-name">{{ currentSession.projectName }}</span>
|
||
</div>
|
||
<el-button
|
||
:icon="useRenderIcon(RefreshIcon)"
|
||
:disabled="!currentSession.sessionId"
|
||
@click="loadMessages(currentSession.sessionId)"
|
||
>
|
||
刷新
|
||
</el-button>
|
||
</div>
|
||
|
||
<div class="chat-body">
|
||
<ChatGPT
|
||
:key="`${activeSessionKey}-${chatRenderKey}`"
|
||
:history="deepChatHistory"
|
||
:onRequest="requestAssistantReply"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 未选择会话时的提示 -->
|
||
<div v-else class="empty-chat">
|
||
<div class="empty-icon">
|
||
<component
|
||
:is="useRenderIcon('ri/chat-3-line')"
|
||
style="font-size: 64px; color: var(--el-color-primary)"
|
||
/>
|
||
</div>
|
||
<h3>开始新的对话</h3>
|
||
<p class="text-gray-400">选择一个项目开始与AI助手对话</p>
|
||
<el-button type="primary" class="mt-4" @click="createNewSession">
|
||
<template #icon>
|
||
<component :is="useRenderIcon(AddIcon)" />
|
||
</template>
|
||
新建对话
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.ai-chat-container {
|
||
display: flex;
|
||
height: calc(100vh - 100px);
|
||
overflow: hidden;
|
||
background: var(--el-bg-color);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.session-sidebar {
|
||
display: flex;
|
||
flex-direction: column;
|
||
width: 280px;
|
||
background: var(--el-fill-color-blank);
|
||
border-right: 1px solid var(--el-border-color-light);
|
||
|
||
.sidebar-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px;
|
||
border-bottom: 1px solid var(--el-border-color-light);
|
||
|
||
h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
|
||
.project-select-panel {
|
||
border-bottom: 1px solid var(--el-border-color-light);
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.project-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
|
||
&:hover {
|
||
background: var(--el-fill-color-light);
|
||
}
|
||
}
|
||
}
|
||
|
||
.session-list {
|
||
flex: 1;
|
||
overflow: auto;
|
||
}
|
||
|
||
.loading-placeholder {
|
||
padding: 20px;
|
||
color: var(--el-text-color-secondary);
|
||
text-align: center;
|
||
}
|
||
|
||
.session-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||
transition: background 0.2s;
|
||
|
||
&:hover {
|
||
background: var(--el-fill-color-light);
|
||
}
|
||
|
||
&.active {
|
||
background: var(--el-color-primary-light-9);
|
||
}
|
||
|
||
.session-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.session-title {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.session-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.delete-btn {
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
&:hover .delete-btn {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
.chat-main {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
|
||
.chat-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--el-border-color-light);
|
||
|
||
.header-info {
|
||
h4 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.project-name {
|
||
margin-left: 8px;
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
}
|
||
}
|
||
|
||
.chat-body {
|
||
flex: 1;
|
||
padding: 20px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chat-body :deep(deep-chat) {
|
||
display: block;
|
||
width: 100%;
|
||
max-width: none;
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.empty-chat {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
.empty-icon {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
h3 {
|
||
margin: 0 0 8px;
|
||
}
|
||
}
|
||
</style>
|