项目初始化

This commit is contained in:
liuhanqing
2025-09-11 11:19:00 +08:00
parent 13df9e0355
commit d53f41223b
1194 changed files with 245831 additions and 34 deletions

View 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;
}
}
}

View 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>