Files
learning-system-portal/src/views/portal/case/components/sendMessage.vue
chong.yanning@ebiz-digits.com c788b49d52 feat(portal): 移除组件props中的conversationId并将其移至data中
将sendMessage.vue组件中的conversationId属性从props移动到data中,以便更好地管理组件内部状态。
2025-12-18 13:41:24 +08:00

670 lines
23 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.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>