Files
learning-system-portal/src/views/portal/case/components/sendMessage.vue
陈昱达 01e4c676fc feat(portal/case): 增强AI对话窗口交互功能
-为sendMessage组件添加textarea输入框,支持多行输入
- 为AI对话窗口添加拖拽和调整大小功能
- 在最小化窗口中添加关闭按钮- 优化窗口样式和布局,提升用户体验
- 添加拖拽手柄和窗口控制按钮
- 实现窗口位置和大小的动态调整
- 引入open.png图标用于最小化窗口操作
2025-11-04 14:54:51 +08:00

392 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="input-area">
<el-input
v-model="inputContent"
type="textarea"
class="input-placeholder"
placeholder="有问题,尽管问"
@keyup.enter.native="handleSend"
:disabled="disabled"
:autosize="{ minRows: 2, maxRows: 4}"
resize="none"
></el-input>
<div class="action-buttons">
<el-button type="primary" size="small" class="start-btn" @click="handleNewConversation">
+ 开启新对话
</el-button>
<el-button type="text" class="send-btn" @click="handleSend" :disabled="disabled">
<i class="el-icon-s-promotion"></i>
</el-button>
</div>
</div>
</template>
<script>
import { aiChat } from '@/api/boe/aiChat.js'
export default {
name: 'SendMessage',
props: {
value: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
messageList: {
type: Array,
default: () => []
},
suggestions: {
type: Array,
default: () => []
}
},
data() {
return {
inputContent: this.value,
conversationId: '' // 会话ID
}
},
watch: {
value(newVal) {
this.inputContent = newVal
}
},
methods: {
handleSend() {
if (!this.inputContent.trim() || this.disabled) return
// 添加用户消息到列表
const userMessage = {
isBot: false,
text: this.inputContent
};
this.messageList.push(userMessage);
// 显示加载状态
this.$emit('loading', true);
// 调用AI聊天接口 (暂时注释掉SSE使用模拟数据)
this.callAIChat(this.inputContent);
// 清空输入框
this.inputContent = ''
},
// 真实的SSE实现暂时注释掉
callAIChat(question) {
// 创建新的AI消息对象
const aiMessage = {
isBot: true,
text: '',
status:null,
thinkText: '',
caseRefers: [], // 添加caseRefers字段
textCompleted: false // 添加文字处理完成状态默认为false
};
this.messageList.push(aiMessage);
// 构造请求参数
const requestData = {
conversationId: this.conversationId,
query: question
};
// 创建POST请求
fetch('/systemapi/xboe/m/boe/case/ai/chat',{
method: 'POST',
headers: {
'Content-Type': 'application/json',
"accept": "text/event-stream",
},
body: JSON.stringify(requestData)
}).then(r=>{
return r
}).then(response => {
// 处理流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let accumulatedContent = ''; // 累积的内容用于打字机效果
let accumulatedThinkContent = ''; // 累积的思考内容
let inThinkSection = false; // 是否在思考部分
let typingTimer = null; // 打字机定时器
let thinkTypingTimer = null; // 思考内容打字机定时器
// 逐字显示文本的函数
const typeText = (message, fullContent) => {
// 如果已有定时器在运行,先清除它
if (typingTimer) {
clearInterval(typingTimer);
}
// 获取当前已显示的文本长度
const currentLength = message.text.length;
// 获取完整文本
const targetLength = fullContent.length;
// 如果已经显示完整文本,不需要继续
if (currentLength >= targetLength) {
return;
}
const typingSpeed = 30; // 每个字符的间隔时间(毫秒)
typingTimer = setInterval(() => {
// 计算下一个要显示的字符索引
const nextIndex = message.text.length + 1;
if (nextIndex <= targetLength) {
message.text = fullContent.substring(0, nextIndex);
this.$emit('update-message', message);
} else {
clearInterval(typingTimer);
typingTimer = null;
// 当打字机效果完成时检查是否应该设置textCompleted为true
// 这应该在status 4交互完成时才设置
if (message.status === 4) {
if (nextIndex >= targetLength) {
message.textCompleted = true;
}
}
}
}, typingSpeed);
};
// 逐字显示思考内容的函数
const typeThinkText = (message, fullThinkContent) => {
// 如果已有定时器在运行,先清除它
if (thinkTypingTimer) {
clearInterval(thinkTypingTimer);
}
// 获取当前已显示的文本长度
const currentLength = message.thinkText.length;
// 获取完整文本
const targetLength = fullThinkContent.length;
// 如果已经显示完整文本,不需要继续
if (currentLength >= targetLength) {
return;
}
// 从当前显示位置开始继续显示(避免清空重显)
const startIndex = currentLength;
const typingSpeed = 20; // 每个字符的间隔时间(毫秒)
thinkTypingTimer = setInterval(() => {
// 计算下一个要显示的字符索引
const nextIndex = message.thinkText.length + 1;
if (nextIndex <= targetLength) {
message.thinkText = fullThinkContent.substring(0, nextIndex);
this.$emit('update-message', message);
} else {
clearInterval(thinkTypingTimer);
thinkTypingTimer = null;
}
}, typingSpeed);
};
// 添加一个检查是否所有文本都已完成显示的函数
const isTextDisplayCompleted = (message, fullContent) => {
return message.text.length >= fullContent.length;
};
// 读取流数据
const read = () => {
reader.read().then(({ done, value }) => {
if (done) {
// 当流结束时,等待打字机效果完成
const waitForTyping = () => {
if (!typingTimer) {
this.$emit('loading', false);
} else {
setTimeout(waitForTyping, 100);
}
};
waitForTyping();
return;
}
// 解码数据
buffer += decoder.decode(value, { stream: true });
// 按行分割数据
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留不完整的行
for (const line of lines) {
if (line.startsWith('data:')) {
try {
const jsonData = JSON.parse(line.substring(5));
// 根据status处理不同类型的数据
switch (jsonData.status) {
case 0: // 引用文件
// 处理引用文件信息
if (jsonData.fileRefer && jsonData.fileRefer.caseRefers) {
aiMessage.caseRefers = jsonData.fileRefer.caseRefers;
// 更新父组件的messageList
this.$emit('update-message', aiMessage);
}
// 从响应中获取并保存conversationId
if (jsonData.conversationId) {
this.conversationId = jsonData.conversationId;
sessionStorage.setItem('conversationId', jsonData.conversationId);
}
break;
case 1: // 流式对话内容
// 处理
const content = jsonData.content;
aiMessage.hasThink = false;
if (content.startsWith('<think>')) {
aiMessage.hasThink = true
inThinkSection = true;
accumulatedThinkContent = content.replace('<think>', '');
// 使用打字机效果显示think内容
typeThinkText(aiMessage, accumulatedThinkContent);
} else if (content.startsWith('</think>')) {
inThinkSection = false;
accumulatedThinkContent += content.replace('</think>', '');
// 使用打字机效果显示think内容
typeThinkText(aiMessage, accumulatedThinkContent);
} else if (inThinkSection) {
accumulatedThinkContent += content;
// 使用打字机效果显示think内容
typeThinkText(aiMessage, accumulatedThinkContent);
} else {
// 累积内容并使用打字机效果更新显示
accumulatedContent += content;
// 如果thinkText已经显示完整则继续使用打字机效果显示内容
if( aiMessage.hasThink){
if(aiMessage.thinkText.length >=accumulatedThinkContent.length){
typeText(aiMessage, accumulatedContent);
}
} else {
typeText(aiMessage, accumulatedContent);
}
}
// 不在这里直接更新,让打字机效果处理更新
break;
case 2: // 回答完成
// 不再在这里设置textCompleted状态
// 更新父组件的messageList
this.$emit('update-message', aiMessage);
// 从响应中获取并保存conversationId
break;
case 3: // 返回建议
// 这里可以处理建议问题
this.$emit('update-suggestions', jsonData.suggestions);
break;
case 4: // 交互完成
aiMessage.status = 4
// 从响应中获取并保存conversationId
this.$emit('loading', false);
// 检查文本是否已经完全显示如果是则设置textCompleted为true
if (isTextDisplayCompleted(aiMessage, accumulatedContent)) {
// aiMessage.textCompleted = true;
this.$emit('update-message', aiMessage);
}
break;
}
} catch (error) {
console.error('解析SSE数据错误:', error);
}
}
}
// 继续读取
read();
}).catch(error => {
console.error('SSE连接错误:', error);
// 出错时也设置文字处理完成状态
if (typingTimer) {
clearInterval(typingTimer);
typingTimer = null;
}
aiMessage.textCompleted = true;
this.$emit('loading', false);
aiMessage.text = '当前无法获取回答,请稍后重试';
// 更新父组件的messageList
this.$emit('update-message', aiMessage);
});
};
// 开始读取数据
read();
}).catch(error => {
console.error('请求失败:', error);
// 出错时也设置文字处理完成状态
aiMessage.textCompleted = true;
this.$emit('loading', false);
aiMessage.text = '当前无法获取回答,请稍后重试';
// 更新父组件的messageList
this.$emit('update-message', aiMessage);
});
},
handleNewConversation() {
this.conversationId = ''
this.$emit('new-conversation')
}
}
}
</script>
<style scoped lang="scss">
.input-area {
background-color: white;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 5px 16px 10px 16px;
display: flex;
flex-direction: column;
.input-placeholder {
color: #999;
font-size: 14px;
margin: 0;
border: none;
::v-deep .el-input__inner {
border: none;
padding: 0;
height: 30px;
}
}
.action-buttons {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 5px;
.start-btn {
padding: 6px 10px;
font-size: 12px;
border-radius: 4px;
color: #409eff;
background-color: #f5f7fa;
border: 1px solid #dcdfe6;
}
.send-btn {
font-size: 18px;
color: #409eff;
padding: 6px;
&[disabled] {
color: #c0c4cc;
}
}
}
}
</style>