项目初始化
This commit is contained in:
167
ruoyi-ui/src/views/ai/chat/chat.scss
Normal file
167
ruoyi-ui/src/views/ai/chat/chat.scss
Normal file
@@ -0,0 +1,167 @@
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 84px);
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.chat-history {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
|
||||
.chat-list {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.message {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
|
||||
&.user {
|
||||
justify-content: flex-end;
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: row-reverse;
|
||||
max-width: 70%;
|
||||
|
||||
.text {
|
||||
padding: 12px 16px;
|
||||
background-color: #409eff;
|
||||
color: white;
|
||||
border-radius: 18px;
|
||||
word-break: break-word;
|
||||
border-bottom-right-radius: 4px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 8px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.ai {
|
||||
justify-content: flex-start;
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
max-width: 70%;
|
||||
|
||||
.text {
|
||||
padding: 12px 16px;
|
||||
background-color: #f4f4f5;
|
||||
border-radius: 18px;
|
||||
word-break: break-word;
|
||||
border-bottom-left-radius: 4px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.markdown-body {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 8px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background-color: #f7f7f7;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
i {
|
||||
margin-right: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #909399;
|
||||
|
||||
i {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
border-top: 1px solid #e6e6e6;
|
||||
padding: 16px;
|
||||
|
||||
.file-upload {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-textarea {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
386
ruoyi-ui/src/views/ai/chat/index.vue
Normal file
386
ruoyi-ui/src/views/ai/chat/index.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 左侧会话列表 -->
|
||||
<div class="chat-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<el-button type="primary" @click="createNewChat" icon="el-icon-plus">新建会话</el-button>
|
||||
</div>
|
||||
<div class="chat-history">
|
||||
<el-menu
|
||||
:default-active="activeChat"
|
||||
class="chat-list"
|
||||
@select="handleChatSelect">
|
||||
<el-menu-item
|
||||
v-for="chat in chatHistory"
|
||||
:key="chat.id"
|
||||
:index="String(chat.id)">
|
||||
<i class="el-icon-chat-line-round"></i>
|
||||
<span>{{ chat.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧对话内容区 -->
|
||||
<div class="chat-main">
|
||||
<div class="chat-messages" ref="messageContainer">
|
||||
<!-- 加载动画 -->
|
||||
<div v-if="isLoadingMessages" class="loading-container">
|
||||
<div class="loading-content">
|
||||
<i class="el-icon-loading"></i>
|
||||
<span>正在加载消息...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="message in currentMessages"
|
||||
:key="message.id"
|
||||
:class="['message', message.role]"
|
||||
v-show="!isLoadingMessages">
|
||||
<div class="message-content">
|
||||
<div class="text" :class="{ 'markdown-body': message.role === 'assistant' }">
|
||||
<div v-if="message.role === 'user'" v-text="message.content"></div>
|
||||
<div v-else v-html="formatMessageContent(message.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 如果消息包含文件 -->
|
||||
<div class="file-list" v-if="message.files && message.files.length">
|
||||
<div v-for="file in message.files" :key="file.id" class="file-item">
|
||||
<i class="el-icon-document"></i>
|
||||
<span>{{ file.name }}</span>
|
||||
<el-button type="text" @click="downloadFile(file)">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="chat-input">
|
||||
<div class="input-controls">
|
||||
<div class="file-upload">
|
||||
<el-upload
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:show-file-list="false">
|
||||
<el-button icon="el-icon-paperclip" type="text">附件</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="useKnowledgeBase"
|
||||
active-text="知识库"
|
||||
active-color="#13ce66"
|
||||
inactive-color="#000000">
|
||||
</el-switch>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="messageInput"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入消息..."
|
||||
@keyup.enter.native.ctrl="sendMessage">
|
||||
</el-input>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!messageInput.trim()"
|
||||
@click="sendMessage">
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import request from '@/utils/request';
|
||||
import {listConversations,addConversations,delConversations} from '@/api/ai/conversations'
|
||||
import {listMessage} from '@/api/ai/message'
|
||||
import { Subject } from 'rxjs';
|
||||
import { map, tap } from 'rxjs/operators';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github.css';
|
||||
|
||||
export default {
|
||||
name: 'AiChat',
|
||||
data() {
|
||||
return {
|
||||
activeChat: '',
|
||||
chatHistory: [],
|
||||
currentMessages: [],
|
||||
messageInput: '',
|
||||
uploadFiles: [],
|
||||
streamingMessage: null,
|
||||
isStreaming: false,
|
||||
isLoadingMessages: false,
|
||||
useKnowledgeBase: false,
|
||||
messageParam:{
|
||||
conversationId: '',
|
||||
},
|
||||
messageSubject: new Subject()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.initMarkdown()
|
||||
this.getChatHistory()
|
||||
},
|
||||
methods: {
|
||||
// 初始化Markdown配置
|
||||
initMarkdown() {
|
||||
marked.setOptions({
|
||||
highlight: function(code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
} catch (err) {}
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
},
|
||||
breaks: true,
|
||||
gfm: true
|
||||
});
|
||||
},
|
||||
|
||||
// 获取会话历史
|
||||
async getChatHistory() {
|
||||
try {
|
||||
const response = await listConversations()
|
||||
if (response.code === 200) {
|
||||
this.chatHistory = response.rows
|
||||
console.log(this.chatHistory)
|
||||
|
||||
// 如果有会话,自动选择第一个
|
||||
if (this.chatHistory && this.chatHistory.length > 0) {
|
||||
this.handleChatSelect(this.chatHistory[0].id)
|
||||
} else {
|
||||
// 如果没有会话,创建一个新会话
|
||||
this.createNewChat()
|
||||
}
|
||||
} else {
|
||||
this.$message.error('获取失败')
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error('获取会话历史失败')
|
||||
}
|
||||
},
|
||||
// 创建新会话
|
||||
createNewChat() {
|
||||
// TODO: 调用后端API创建新会话
|
||||
const newChat = {
|
||||
id: Date.now().toString(),
|
||||
title: '新会话'
|
||||
}
|
||||
addConversations(newChat).then(response => {
|
||||
if (response.code === 200) {
|
||||
newChat.id=response.data
|
||||
this.chatHistory.unshift(newChat)
|
||||
this.handleChatSelect(newChat.id)
|
||||
} else {
|
||||
this.$message.error('创建失败')
|
||||
}
|
||||
})
|
||||
},
|
||||
// 选择会话
|
||||
async handleChatSelect(chatId) {
|
||||
this.activeChat = chatId
|
||||
this.messageParam.conversationId = chatId
|
||||
this.isLoadingMessages = true
|
||||
|
||||
try {
|
||||
const response = await listMessage(this.messageParam)
|
||||
if (response.code === 200){
|
||||
// 确保消息数据格式正确
|
||||
this.currentMessages = response.rows.map(msg => {
|
||||
// 处理不同的role字段格式
|
||||
let role = msg.role || msg.messageType || msg.sender || 'user'
|
||||
// 统一role值格式
|
||||
if (role.toLowerCase() === 'assistant') {
|
||||
role = 'assistant'
|
||||
} else if (role.toLowerCase() === 'user') {
|
||||
role = 'user'
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.id || msg.messageId || Date.now().toString(),
|
||||
role: role,
|
||||
content: msg.content || msg.messageContent || msg.text || '',
|
||||
files: msg.files || [],
|
||||
isStreaming: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom()
|
||||
})
|
||||
} catch (error) {
|
||||
this.$message.error('获取会话消息失败')
|
||||
} finally {
|
||||
this.isLoadingMessages = false
|
||||
}
|
||||
},
|
||||
|
||||
// 发送消息
|
||||
async sendMessage() {
|
||||
if (!this.messageInput.trim() || this.isStreaming) return
|
||||
|
||||
const message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: this.messageInput,
|
||||
files: this.uploadFiles,
|
||||
conversationId: this.messageParam.conversationId
|
||||
}
|
||||
|
||||
this.currentMessages.push(message)
|
||||
this.messageInput = ''
|
||||
this.uploadFiles = []
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom()
|
||||
})
|
||||
|
||||
try {
|
||||
// 创建AI回复的占位消息
|
||||
const aiMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true
|
||||
}
|
||||
this.currentMessages.push(aiMessage)
|
||||
this.streamingMessage = aiMessage
|
||||
this.isStreaming = true
|
||||
|
||||
// 创建新的Subject实例
|
||||
if (this.messageSubject) {
|
||||
this.messageSubject.complete()
|
||||
}
|
||||
this.messageSubject = new Subject()
|
||||
|
||||
// 设置消息订阅
|
||||
this.messageSubject.pipe(
|
||||
map(chunk => chunk.toString()),
|
||||
tap(text => {
|
||||
this.streamingMessage.content += text;
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
})
|
||||
).subscribe({
|
||||
error: (error) => {
|
||||
console.error('处理响应数据失败:', error);
|
||||
this.handleStreamError();
|
||||
},
|
||||
complete: () => {
|
||||
this.streamingMessage.isStreaming = false;
|
||||
this.isStreaming = false;
|
||||
this.streamingMessage = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 调用后端API发送消息并获取流式响应
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
let lastResponseLength = 0;
|
||||
|
||||
const response = await request({
|
||||
url: this.useKnowledgeBase ? '/api/chat/knowledgeBase' : '/api/chat',
|
||||
method: 'post',
|
||||
data: {
|
||||
message: message.content,
|
||||
files: message.files.map(f => ({
|
||||
name: f.name,
|
||||
size: f.file.size,
|
||||
type: f.file.type
|
||||
})),
|
||||
conversationId: message.conversationId
|
||||
},
|
||||
responseType: 'stream',
|
||||
timeout: 0, // 设置超时时间为0,表示无超时限制
|
||||
signal,
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
const fullResponse = progressEvent.currentTarget.response;
|
||||
if (fullResponse && fullResponse.length > lastResponseLength) {
|
||||
// 只获取新增的数据部分
|
||||
const newChunk = fullResponse.substring(lastResponseLength);
|
||||
lastResponseLength = fullResponse.length;
|
||||
this.messageSubject.next(newChunk);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.messageSubject.complete();
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
const errorMsg = error.response?.data?.message || '发送消息失败';
|
||||
this.$message.error(errorMsg);
|
||||
this.handleStreamError();
|
||||
}
|
||||
},
|
||||
// 处理文件上传
|
||||
handleFileChange(file) {
|
||||
this.uploadFiles.push({
|
||||
id: Date.now().toString(),
|
||||
name: file.name,
|
||||
file: file.raw
|
||||
})
|
||||
console.log('上传文件:', this.uploadFiles)
|
||||
},
|
||||
// 下载文件
|
||||
downloadFile(file) {
|
||||
// TODO: 实现文件下载逻辑
|
||||
console.log('下载文件:', file)
|
||||
},
|
||||
// 滚动到底部
|
||||
scrollToBottom() {
|
||||
const container = this.$refs.messageContainer;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
},
|
||||
|
||||
// 处理流式响应错误
|
||||
handleStreamError() {
|
||||
if (this.streamingMessage) {
|
||||
this.streamingMessage.isStreaming = false;
|
||||
this.isStreaming = false;
|
||||
this.streamingMessage = null;
|
||||
}
|
||||
if (this.messageSubject) {
|
||||
this.messageSubject.complete();
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化消息内容
|
||||
formatMessageContent(content) {
|
||||
if (!content) return '';
|
||||
try {
|
||||
// 使用marked解析markdown内容
|
||||
return marked(content);
|
||||
} catch (error) {
|
||||
console.error('Markdown解析错误:', error);
|
||||
// 如果解析失败,回退到简单的换行符转换
|
||||
return content.replace(/\n/g, '<br>');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/assets/styles/markdown.scss';
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './chat.scss';
|
||||
|
||||
.input-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.file-upload {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user