mirror of
https://codeup.aliyun.com/67762337eccfc218f6110e0e/vue/learning-system-portal.git
synced 2025-12-22 01:06:43 +08:00
670 lines
23 KiB
Vue
670 lines
23 KiB
Vue
<template>
|
||
<div class="input-area">
|
||
<el-input
|
||
v-model="inputContent"
|
||
type="textarea"
|
||
class="input-placeholder"
|
||
placeholder="有问题,尽管问"
|
||
@keyup.enter.native.prevent="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"
|
||
v-if="sendShow"
|
||
>
|
||
<i class="el-icon-s-promotion"></i>
|
||
</el-button>
|
||
<el-button
|
||
type="text"
|
||
class="send-btn"
|
||
@click="handleStop"
|
||
v-if="stopShow"
|
||
>
|
||
<img class="stop" src="@/assets/images/case/stop.png" alt="" />
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { aiChat } from "@/api/boe/aiChat.js";
|
||
import apiCase from "@/api/modules/cases.js";
|
||
|
||
export default {
|
||
name: "SendMessage",
|
||
props: {
|
||
value: {
|
||
type: String,
|
||
default: "",
|
||
},
|
||
disabled: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
sendShow: {
|
||
type: Boolean,
|
||
default: true,
|
||
},
|
||
stopShow: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
messageList: {
|
||
type: Array,
|
||
default: () => [],
|
||
},
|
||
suggestions: {
|
||
type: Array,
|
||
default: () => [],
|
||
},
|
||
},
|
||
data() {
|
||
return {
|
||
inputContent: this.value,
|
||
currentAbortController: null, // 添加abort controller引用
|
||
isStopped: false, // 添加停止状态
|
||
typingTimer: null, // 添加这一行
|
||
thinkTypingTimer: null, // 添加这一行
|
||
conversationId: "",
|
||
};
|
||
},
|
||
watch: {
|
||
value(newVal) {
|
||
this.inputContent = newVal;
|
||
},
|
||
},
|
||
mounted() {
|
||
this.getConversationId();
|
||
},
|
||
|
||
methods: {
|
||
getConversationId() {
|
||
apiCase.initChat().then((res) => {
|
||
if (res.status == 200) {
|
||
const { result } = res;
|
||
// console.log(result);
|
||
this.conversationId = result;
|
||
sessionStorage.setItem("conversationId", this.conversationId);
|
||
}
|
||
});
|
||
},
|
||
handleSend(event) {
|
||
// 阻止事件的默认行为和冒泡
|
||
if (event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
console.log("preventDefault");
|
||
}
|
||
console.log("handleSend");
|
||
if (!this.inputContent.trim() || this.disabled) return;
|
||
// 重置停止状态,确保可以正常发送新消息
|
||
this.isStopped = false;
|
||
|
||
// 添加用户消息到列表
|
||
const userMessage = {
|
||
isBot: false,
|
||
text: this.inputContent,
|
||
};
|
||
this.messageList.push(userMessage);
|
||
|
||
// 显示加载状态
|
||
this.$emit("loading", true);
|
||
|
||
// 调用AI聊天接口 (暂时注释掉SSE,使用模拟数据)
|
||
this.callAIChat(this.inputContent);
|
||
|
||
// 清空输入框
|
||
this.inputContent = "";
|
||
},
|
||
handleStop() {
|
||
console.log(this.accumulatedContent, "accumulatedContent");
|
||
// 设置停止状态
|
||
this.isStopped = true;
|
||
// 清除打字机定时器
|
||
if (this.typingTimer) {
|
||
clearInterval(this.typingTimer);
|
||
this.typingTimer = null;
|
||
}
|
||
|
||
if (this.thinkTypingTimer) {
|
||
clearInterval(this.thinkTypingTimer);
|
||
this.thinkTypingTimer = null;
|
||
}
|
||
if (this.conversationId) {
|
||
// const stopAbortController = new AbortController();
|
||
fetch(
|
||
`/systemapi/xboe/m/boe/case/ai/stop?conversationId=${this.conversationId}`,
|
||
{
|
||
method: "GET",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
// signal: stopAbortController.signal,
|
||
}
|
||
)
|
||
.then((response) => {
|
||
if (response.ok) {
|
||
console.log("成功发送停止请求"); // 通知父组件需要重新获取会话ID
|
||
this.$emit("need-new-conversation-id");
|
||
} else {
|
||
console.error("停止请求失败:", response.status);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error("停止请求出错:", error);
|
||
});
|
||
}
|
||
// 终止当前正在进行的聊天请求
|
||
if (this.currentAbortController) {
|
||
this.currentAbortController.abort();
|
||
}
|
||
|
||
// 立即发送加载完成事件,停止按钮状态切换
|
||
this.$emit("loading", false);
|
||
// 发送按钮状态更新事件
|
||
this.$emit("update-button-state", {
|
||
sendShow: true,
|
||
stopShow: false,
|
||
});
|
||
},
|
||
|
||
// 真实的SSE实现(暂时注释掉)
|
||
callAIChat(question) {
|
||
// 重置停止状态
|
||
this.isStopped = false;
|
||
// 开始请求时显示加载状态
|
||
this.$emit("loading", true);
|
||
// 创建AbortController实例
|
||
this.currentAbortController = new AbortController();
|
||
|
||
// 创建新的AI消息对象
|
||
const aiMessage = {
|
||
docId: "",
|
||
isBot: true,
|
||
text: "",
|
||
status: null,
|
||
thinkText: "",
|
||
caseRefers: [], // 添加caseRefers字段
|
||
textCompleted: false, // 添加文字处理完成状态,默认为false
|
||
};
|
||
this.messageList.push(aiMessage);
|
||
let hasFinished = false; // 新增
|
||
|
||
// 构造请求参数
|
||
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),
|
||
signal: this.currentAbortController.signal, // 添加signal
|
||
})
|
||
.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 (this.isStopped) {
|
||
message.text = fullContent;
|
||
this.$emit("update-message", message);
|
||
return;
|
||
}
|
||
|
||
// 如果已有定时器在运行,先清除它
|
||
if (typingTimer) {
|
||
clearInterval(typingTimer);
|
||
}
|
||
|
||
// 获取当前已显示的文本长度
|
||
const currentLength = message.text.length;
|
||
// 获取完整文本
|
||
const targetLength = fullContent.length;
|
||
|
||
// 如果已经显示完整文本,不需要继续
|
||
if (currentLength >= targetLength) {
|
||
return;
|
||
}
|
||
|
||
const typingSpeed = 30; // 每个字符的间隔时间(毫秒)
|
||
|
||
typingTimer = setInterval(() => {
|
||
// 如果已停止,立即停止渲染并保持当前内容
|
||
if (this.isStopped) {
|
||
clearInterval(typingTimer);
|
||
typingTimer = null;
|
||
// 保持当前已渲染的内容,不显示剩余内容
|
||
this.$emit("update-message", message);
|
||
return;
|
||
}
|
||
// 计算下一个要显示的字符索引
|
||
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 (this.isStopped) {
|
||
// 清理可能存在的定时器
|
||
if (thinkTypingTimer) {
|
||
clearInterval(thinkTypingTimer);
|
||
thinkTypingTimer = null;
|
||
}
|
||
// 保持当前已渲染的内容,不显示完整内容
|
||
this.$emit("update-message", message);
|
||
return;
|
||
}
|
||
// 如果已有定时器在运行,先清除它
|
||
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(() => {
|
||
// 如果已停止,立即停止渲染并保持当前内容
|
||
if (this.isStopped) {
|
||
clearInterval(thinkTypingTimer);
|
||
thinkTypingTimer = null;
|
||
// 保持当前已渲染的内容,不显示剩余内容
|
||
this.$emit("update-message", message);
|
||
return;
|
||
}
|
||
// 计算下一个要显示的字符索引
|
||
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 = () => {
|
||
// 如果已停止,直接返回
|
||
if (this.isStopped || hasFinished) {
|
||
this.$emit("loading", false);
|
||
return;
|
||
}
|
||
|
||
reader
|
||
.read()
|
||
.then(({ done, value }) => {
|
||
// 如果已停止,直接返回
|
||
if (this.isStopped || hasFinished) {
|
||
this.$emit("loading", false);
|
||
return;
|
||
}
|
||
|
||
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 (this.isStopped) {
|
||
this.$emit("loading", false);
|
||
return;
|
||
}
|
||
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: // 流式对话内容
|
||
if (!this.isStopped) {
|
||
// 处理
|
||
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);
|
||
}
|
||
}
|
||
// 发送事件更新按钮状态:显示停止按钮
|
||
this.$emit("update-button-state", {
|
||
sendShow: false,
|
||
stopShow: true,
|
||
});
|
||
}
|
||
// 不在这里直接更新,让打字机效果处理更新
|
||
break;
|
||
|
||
case 2: // 回答完成
|
||
// 不再在这里设置textCompleted状态
|
||
// 更新父组件的messageList
|
||
// 只有在未停止状态下才更新
|
||
if (!this.isStopped) {
|
||
// 更新父组件的messageList
|
||
this.$emit("update-message", aiMessage);
|
||
// 发送事件更新按钮状态:显示停止按钮
|
||
this.$emit("update-button-state", {
|
||
sendShow: false,
|
||
stopShow: true,
|
||
});
|
||
}
|
||
// 从响应中获取并保存conversationId
|
||
|
||
break;
|
||
|
||
case 3: // 返回建议
|
||
// 只有在未停止状态下才处理建议
|
||
if (!this.isStopped) {
|
||
// 这里可以处理建议问题
|
||
this.$emit(
|
||
"update-suggestions",
|
||
jsonData.suggestions
|
||
);
|
||
// 发送事件更新按钮状态:显示停止按钮
|
||
this.$emit("update-button-state", {
|
||
sendShow: false,
|
||
stopShow: true,
|
||
});
|
||
}
|
||
break;
|
||
|
||
case 4: // 交互完成
|
||
// 只有在未停止状态下才处理完成状态
|
||
if (!this.isStopped) {
|
||
aiMessage.status = 4;
|
||
|
||
// 从响应中获取并保存conversationId
|
||
// this.$emit("loading", false);
|
||
// 检查文本是否已经完全显示,如果是则设置textCompleted为true
|
||
if (
|
||
isTextDisplayCompleted(
|
||
aiMessage,
|
||
accumulatedContent
|
||
)
|
||
) {
|
||
// aiMessage.textCompleted = true;
|
||
this.$emit("update-message", aiMessage);
|
||
// 发送事件更新按钮状态:显示发送按钮
|
||
hasFinished = true; // 标记完成
|
||
this.$emit("update-button-state", {
|
||
sendShow: true,
|
||
stopShow: false,
|
||
});
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 9:
|
||
// 只有在未停止状态下才处理docId
|
||
if (!this.isStopped && jsonData.docId) {
|
||
aiMessage.docId = jsonData.docId;
|
||
this.$emit("update-message", aiMessage);
|
||
}
|
||
// 显示发送按钮,隐藏停止按钮
|
||
// this.$emit("loading", false);
|
||
console.log(jsonData);
|
||
// 发送事件更新按钮状态:显示发送按钮
|
||
hasFinished = true; // 标记完成
|
||
this.$emit("update-button-state", {
|
||
sendShow: true,
|
||
stopShow: false,
|
||
});
|
||
break;
|
||
default:
|
||
if (!this.isStopped && jsonData.docId) {
|
||
aiMessage.docId = jsonData.docId;
|
||
this.$emit("update-message", aiMessage);
|
||
}
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
console.error("解析SSE数据错误:", error);
|
||
}
|
||
}
|
||
}
|
||
// 继续读取(除非已停止)
|
||
if (!this.isStopped) {
|
||
read();
|
||
}
|
||
// 继续读取
|
||
// read();
|
||
})
|
||
.catch((error) => {
|
||
// 处理AbortError
|
||
if (error.name === "AbortError") {
|
||
console.log("请求已被用户取消");
|
||
// aiMessage.text = "请求已被取消";
|
||
aiMessage.textCompleted = true;
|
||
this.$emit("update-message", aiMessage);
|
||
} else {
|
||
console.error("SSE连接错误:", error);
|
||
// 出错时也设置文字处理完成状态
|
||
if (typingTimer) {
|
||
clearInterval(typingTimer);
|
||
typingTimer = null;
|
||
}
|
||
aiMessage.textCompleted = true;
|
||
aiMessage.text = "当前无法获取回答,请稍后重试";
|
||
// 更新父组件的messageList
|
||
this.$emit("update-message", aiMessage);
|
||
}
|
||
this.$emit("loading", false);
|
||
});
|
||
};
|
||
// 继续读取(除非已停止)
|
||
if (!this.isStopped) {
|
||
read();
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
// 处理AbortError
|
||
if (error.name === "AbortError") {
|
||
console.log("请求已被用户取消");
|
||
aiMessage.text = "请求已被取消";
|
||
aiMessage.textCompleted = true;
|
||
this.$emit("update-message", aiMessage);
|
||
} else {
|
||
console.error("请求失败:", error);
|
||
// 出错时也设置文字处理完成状态
|
||
aiMessage.textCompleted = true;
|
||
aiMessage.text = "当前无法获取回答,请稍后重试";
|
||
// 更新父组件的messageList
|
||
this.$emit("update-message", aiMessage);
|
||
}
|
||
// 只有在非停止状态下才发送 loading 事件
|
||
if (!this.isStopped) {
|
||
this.$emit("loading", false);
|
||
}
|
||
});
|
||
},
|
||
handleNewConversation() {
|
||
// this.conversationId = "";
|
||
this.$emit("new-conversation");
|
||
this.getConversationId();
|
||
},
|
||
},
|
||
};
|
||
</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;
|
||
}
|
||
}
|
||
}
|
||
.stop {
|
||
width: 18px;
|
||
height: 18px;
|
||
display: block;
|
||
}
|
||
}
|
||
</style>
|