Files
zn-ai/src/renderer/views/home/ChatBox.vue
2026-01-15 16:53:48 +08:00

730 lines
24 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="h-full overflow-hidden flex flex-col">
<!-- 消息列表唯一滚动区 -->
<div ref="listRef" class="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<div v-for="msg in chatMsgList" :key="msg.messageId" class="flex items-start gap-3"
:class="msg.messageRole === MessageRole.ME ? 'justify-end' : 'justify-start'">
<!-- AI avatar -->
<img v-if="msg.messageRole === MessageRole.AI" class="w-9 h-9 rounded-full shrink-0"
src="@assets/images/login/blue_logo.png" />
<!-- 消息气泡 -->
<div class="max-w-[70%]">
<div class="flex items-start gap-2 pt-0.5 mb-2"
:class="msg.messageRole === MessageRole.ME ? 'flex-row-reverse' : 'flex-row'">
<span class="text-xs text-[#4E5969]"> ZHINIAN</span>
<span class="text-xs text-[#86909C]"> 20:30</span>
</div>
<div class="text-sm text-gray-700"
:class="msg.messageRole === MessageRole.ME ? 'bg-[#f7f9fc] rounded-md px-2 py-2' : ''">
{{ msg.messageContent }}
</div>
<!-- AI 标识 -->
<div v-if="msg.messageRole === MessageRole.AI" class="mt-2 text-xs text-gray-400 ">
本回答由 AI 生成
</div>
<!-- AI 操作按钮 -->
<div v-if="msg.messageRole === MessageRole.AI"
class="mt-4 text-gray-500 flex items-center justify-between gap-4 ">
<RiFileCopyLine size="16px" @click="copyFileClick(msg)" />
<div class="flex items-center gap-4">
<RiShareForwardLine size="16px" @click="shareForwardClick(msg)" />
<RiDownload2Line size="16px" @click="downloadClick(msg)" />
<RiThumbUpLine size="16px" @click="thumbUpClick(msg)" />
<RiThumbDownLine size="16px" @click="thumbDownClick(msg)" />
</div>
</div>
</div>
<!-- User avatar -->
<img v-if="msg.messageRole === MessageRole.ME" class="w-9 h-9 rounded-full shrink-0"
src="@assets/images/login/user_icon.png" />
</div>
</div>
<!-- 输入区固定底部不滚 -->
<div class="shrink-0 px-6 py-4 gap-3">
<div class="inline-flex items-center justify-center w-[108px]
px-3 py-1.5 rounded-2xl border border-[#E5E8EE]
text-[13px] text-[#333]">
智能问数
</div>
<div class="h-[174px] bg-white rounded-lg border border-[#eef2f6]
shadow-[0_1px_0_rgba(0,0,0,0.03)]
p-[18px] mt-[8px] flex flex-col justify-between">
<textarea rows="2" placeholder="给我发布或者布置任务" class="flex-1 resize-none outline-none text-sm"
v-model="inputMessage" @keyup.enter="sendMessageAction" />
<div class="flex justify-between items-end">
<button @click="addAttachmentAction()">
<RiLink />
</button>
<button class="w-[48px] h-[48px] bg-[#F5F7FA] px-2.5 py-1.5 rounded-md flex items-center justify-center"
@click="sendMessageAction()">
<RiStopFill v-if="isSendingMessage" />
<RiSendPlaneFill v-else />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { RiLink, RiSendPlaneFill, RiStopFill, RiFileCopyLine, RiShareForwardLine, RiDownload2Line, RiThumbUpLine, RiThumbDownLine } from '@remixicon/vue'
import { onMounted, nextTick, onUnmounted } from "vue";
import { WebSocketManager } from "@common/WebSocketManager";
import { MessageRole, ChatMessage } from "./model/ChatModel";
import { IdUtils } from "@common/index";
import { Session } from '../../utils/storage';
///(控制滚动位置)
const scrollTop = ref(99999);
/// 会话列表
const chatMsgList = ref<ChatMessage[]>([]);
/// 输入口的输入消息
const inputMessage = ref("");
/// 发送消息中标志
const isSendingMessage = ref(false);
/// agentId 首页接口中获取
const agentId = ref("1");
/// 会话ID 历史数据接口中获取
const conversationId = ref("");
// 会话进行中标志
const isSessionActive = ref(false);
/// 指令通用消息类型
let commonTypeMessage: string = "";
// WebSocket 相关
let webSocketManager: WebSocketManager | null = null;
/// 使用统一的连接状态判断函数,避免状态不同步
const isWsConnected = () => !!(webSocketManager && typeof webSocketManager.isConnected === "function" && webSocketManager.isConnected());
// pendingMap: messageId -> msgIndex
const pendingMap = new Map();
// pendingTimeouts: messageId -> timeoutId
const pendingTimeouts = new Map();
// 超时时间ms
const MESSAGE_TIMEOUT = 30000;
// 防止并发初始化 websocket
let isInitializing = false;
let pendingInitPromise: Promise<boolean> | null = null;
// sleep helper
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
// 当前会话的消息ID用于保持发送和终止的messageId一致
let currentSessionMessageId: string | null = null;
// 滚动到底部 - 优化版本,确保打字机效果始终可见
const scrollToBottom = () => {
nextTick(() => {
// 使用更大的值确保滚动到真正的底部
scrollTop.value = 99999;
// 强制触发滚动更新增加延迟确保DOM更新完成
setTimeout(() => {
scrollTop.value = scrollTop.value + Math.random();
}, 10);
});
};
// 延时滚动
const setTimeoutScrollToBottom = () => setTimeout(() => scrollToBottom(), 100);
// 发送普通消息
const handleReplyText = (text: string) => {
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(text);
setTimeoutScrollToBottom();
};
// 是发送指令消息
const handleReplyInstruct = async (message: string, type: string) => {
// await checkToken();
commonTypeMessage = type;
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(message, true);
setTimeoutScrollToBottom();
};
/// actions 实现复制、分享、下载、点赞等功能
const copyFileClick = (msg: ChatMessage) => {
console.log('copy file', msg)
}
const shareForwardClick = (msg: ChatMessage) => {
console.log('share forward', msg)
}
const downloadClick = (msg: ChatMessage) => {
console.log('download', msg)
}
const thumbUpClick = (msg: ChatMessage) => {
console.log('thumb up', msg)
}
const thumbDownClick = (msg: ChatMessage) => {
console.log('thumb down', msg)
}
/// 添加附件按钮事件
const addAttachmentAction = () => {
console.log("添加附件");
};
// 输入区的发送消息事件
const sendMessageAction = () => {
if (isSendingMessage.value) {
sendStopAction();
return;
}
console.log("输入消息:", inputMessage.value);
if (!inputMessage.value.trim()) return;
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(inputMessage.value);
setTimeoutScrollToBottom();
};
// 停止发送消息事件
const sendStopAction = () => {
console.log("停止发送消息");
isSendingMessage.value = false;
stopRequest(); // 发送中断停止消息类型
};
// 页面加载时初始化
onMounted(() => {
try {
// 有token时加载最近会话、最近消息、初始化socket
initHandler();
} catch (error) {
console.error("初始化错误:", error);
}
});
// token存在初始化数据
const initHandler = () => {
console.log("initHandler");
const token = getAccessToken();
if (!token) return;
initWebSocket();
};
const getAccessToken = () => {
// 从本地存储获取 token
return Session.get('token') || '';
};
const checkToken = async () => {
const token = getAccessToken();
if (!token) {
throw new Error("未登录或登录已过期,请重新登录");
}
};
/// =============对话↓================
// 初始化WebSocket
const initWebSocket = async () => {
// 防止并发初始化
if (isInitializing) {
return pendingInitPromise;
}
isInitializing = true;
pendingInitPromise = (async () => {
// 清理旧实例
if (webSocketManager) {
try {
webSocketManager.destroy();
} catch (e) {
console.warn("destroy old webSocketManager failed:", e);
}
webSocketManager = null;
}
// 使用配置的WebSocket服务器地址
const token = getAccessToken();
const wsUrl = `wss://onefeel.brother7.cn/ingress/agent/ws/chat?access_token=${token}`;
// 初始化WebSocket管理器
webSocketManager = new WebSocketManager({
wsUrl: wsUrl,
reconnectInterval: 3000, // 重连间隔
maxReconnectAttempts: 5, // 最大重连次数
heartbeatInterval: 30000, // 心跳间隔
// 连接成功回调
onConnect: (event) => {
console.log("WebSocket连接成功: ", event);
// 连接成功时重置会话状态,避免影响新消息发送
isSessionActive.value = false;
},
// 连接断开回调
onDisconnect: (event) => {
console.error("WebSocket连接断开: ", event);
// 停止当前会话
isSessionActive.value = false;
},
// 错误回调
onError: (error) => {
console.error("WebSocket错误:", error);
isSessionActive.value = false;
},
// 消息回调
onMessage: (data) => {
handleWebSocketMessage(data);
},
// 获取会话ID回调 (用于心跳检测)
getConversationId: () => conversationId.value,
// 获取代理ID回调 (用于心跳检测)
getAgentId: () => agentId.value,
});
try {
// 初始化连接
await webSocketManager.connect();
console.log("WebSocket连接初始化成功");
return true;
} catch (error) {
console.error("WebSocket连接失败:", error);
return false;
} finally {
isInitializing = false;
pendingInitPromise = null;
}
})();
return pendingInitPromise;
};
// 处理WebSocket消息
const handleWebSocketMessage = (data: any) => {
// 验证关键字段(若服务端传回 conversationId/agentId则校验是否属于当前会话
if (data.conversationId && data.conversationId !== conversationId.value) {
console.warn("收到不属于当前会话的消息,忽略", data.conversationId);
return;
}
if (data.agentId && data.agentId !== agentId.value) {
console.warn("收到不属于当前 agent 的消息,忽略", data.agentId);
return;
}
// 确保消息内容是字符串类型
if (data.content && typeof data.content !== "string") {
try {
data.content = JSON.stringify(data.content);
} catch (e) {
data.content = String(data.content);
}
}
// 优先使用 messageId 进行匹配
const msgId = data.messageId || data.id || data.msgId;
let aiMsgIndex = -1;
if (msgId && pendingMap.has(msgId)) {
aiMsgIndex = pendingMap.get(msgId);
} else if (!msgId && currentSessionMessageId && pendingMap.has(currentSessionMessageId)) {
// 服务端未返回 messageId 的场景:优先使用当前会话的 messageId 映射
aiMsgIndex = pendingMap.get(currentSessionMessageId);
} else {
// 向后搜索最近的 AI 消息
for (let i = chatMsgList.value.length - 1; i >= 0; i--) {
if (chatMsgList.value[i] && chatMsgList.value[i].messageRole === MessageRole.AI) {
aiMsgIndex = i;
break;
}
}
if (aiMsgIndex === -1) {
console.error("处理WebSocket消息时找不到对应的AI消息项");
return;
}
}
// 直接拼接内容到对应 AI 消息
if (data.content) {
if (chatMsgList.value[aiMsgIndex].isLoading) {
// 首次收到内容:替换“加载中”文案并取消 loading 状态(恢复原始渲染逻辑)
chatMsgList.value[aiMsgIndex].messageContent = data.content;
chatMsgList.value[aiMsgIndex].isLoading = false;
} else {
// 后续流式内容追加
chatMsgList.value[aiMsgIndex].messageContent += data.content;
}
nextTick(() => scrollToBottom());
}
// 处理完成状态
if (data.finish) {
const msg = chatMsgList.value[aiMsgIndex].messageContent;
if (!msg || chatMsgList.value[aiMsgIndex].isLoading) {
chatMsgList.value[aiMsgIndex].messageContent = "未获取到内容,请重试";
chatMsgList.value[aiMsgIndex].isLoading = false;
if (data.toolCall) {
chatMsgList.value[aiMsgIndex].messageContent = "";
}
}
// 处理toolCall
if (data.toolCall) {
chatMsgList.value[aiMsgIndex].toolCall = data.toolCall;
}
// 处理question
if (data.question && data.question.length > 0) {
chatMsgList.value[aiMsgIndex].question = data.question;
}
// 清理 pendingMap / timeout
const ownedMessageId = chatMsgList.value[aiMsgIndex].messageId || msgId;
if (ownedMessageId) {
if (pendingTimeouts.has(ownedMessageId)) {
clearTimeout(pendingTimeouts.get(ownedMessageId));
pendingTimeouts.delete(ownedMessageId);
}
pendingMap.delete(ownedMessageId);
}
// 结束发送状态
isSendingMessage.value = false;
// 重置会话状态
isSessionActive.value = false;
// 清理当前会话的 messageId避免保留陈旧 id
resetMessageState();
}
};
// 重置消息状态
const resetMessageState = () => {
// 重置当前会话消息ID
currentSessionMessageId = null;
};
// 发送消息的参数拼接
const sendMessage = async (message: string, isInstruct: boolean = false) => {
console.log("发送的消息:", message);
await checkToken();
// 检查WebSocket连接状态如果未连接尝试重新连接
if (!isWsConnected()) {
console.log("WebSocket未连接尝试重新连接...");
// 显示加载提示
// uni.showLoading({
// title: "正在连接服务器...",
// });
// 尝试重新初始化WebSocket连接
try {
await initWebSocket();
// 等待短暂时间确保连接建立
await new Promise(resolve => setTimeout(resolve, 1000));
// 检查连接是否成功建立
if (!isWsConnected()) {
// uni.hideLoading();
// uni.showToast({
// title: "连接服务器失败,请稍后重试",
// icon: "none",
// });
console.error("重新连接WebSocket后仍未连接成功");
return;
}
// uni.hideLoading();
} catch (error) {
console.error("重新连接WebSocket失败:", error);
// uni.hideLoading();
// uni.showToast({
// title: "连接服务器失败,请稍后重试",
// icon: "none",
// });
return;
}
}
if (isSessionActive.value) {
// uni.showToast({
// title: "请等待当前回复完成",
// icon: "none",
// });
console.warn("当前会话正在进行中,请等待回复完成");
return;
}
isSessionActive.value = true;
const newMsg: ChatMessage = {
messageId: IdUtils.generateMessageId(),
messageRole: MessageRole.ME,
messageContent: message,
};
chatMsgList.value.push(newMsg);
inputMessage.value = "";
// 发送消息后滚动到底部
setTimeoutScrollToBottom();
sendChat(message, isInstruct);
console.log("发送的新消息:", JSON.stringify(newMsg));
};
// 通用WebSocket消息发送函数 -> 返回 Promise<boolean>
const sendWebSocketMessage = async (messageType: number, messageContent: string, options: any = {}) => {
const args = {
conversationId: conversationId.value,
agentId: agentId.value,
messageType: String(messageType), // 消息类型 0-对话 1-指令 2-中断停止 3-心跳检测
messageContent: messageContent,
messageId: options.messageId || currentSessionMessageId,
};
/// 重试机制参数
const maxRetries = typeof options.retries === 'number' ? options.retries : 3;
const baseDelay = typeof options.baseDelay === 'number' ? options.baseDelay : 300; // ms
const maxDelay = typeof options.maxDelay === 'number' ? options.maxDelay : 5000; // ms
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// 确保连接
if (!isWsConnected()) {
if (options.tryReconnect) {
try {
await initWebSocket();
} catch (e) {
console.error('reconnect failed in sendWebSocketMessage:', e);
}
}
}
if (!isWsConnected()) {
if (!options.silent) console.warn('WebSocket 未连接,无法发送消息', args);
// 如果还有重试机会,进行等待后重试
if (attempt < maxRetries) {
const delay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt));
await sleep(delay);
continue;
}
isSessionActive.value = false;
return false;
}
try {
const raw = webSocketManager!.sendMessage(args);
// 兼容可能返回同步布尔或 Promise 的实现
const result = await Promise.resolve(raw);
if (result) {
console.log(`WebSocket消息已发送 [类型:${messageType}]:`, args);
return true;
}
// 若返回 false消息可能已经被 manager 入队并触发连接流程。
// 在这种情况下避免立即当作失败处理,而是等待短暂时间以观察连接是否建立并由 manager 发送队列。
console.warn('webSocketManager.sendMessage 返回 false等待连接或队列发送...', { attempt, args });
const waitForConnectMs = typeof options.waitForConnectMs === 'number' ? options.waitForConnectMs : 5000;
if (webSocketManager && typeof webSocketManager.isConnected === 'function' && !webSocketManager.isConnected()) {
const startTs = Date.now();
while (Date.now() - startTs < waitForConnectMs) {
await sleep(200);
if (webSocketManager.isConnected()) {
// 给 manager 一点时间处理队列并发送
await sleep(150);
console.log('检测到 manager 已连接,假定队列消息已发送', args);
return true;
}
}
console.warn('等待 manager 建连超时,进入重试逻辑', { waitForConnectMs, args });
} else {
console.warn('sendMessage 返回 false 但 manager 看起来已连接或不可用,继续重试', { args });
}
} catch (error) {
console.error('发送WebSocket消息异常:', error, args);
}
// 失败且还有重试机会,等待指数退避
if (attempt < maxRetries) {
const delay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt));
await sleep(delay + Math.floor(Math.random() * 100));
continue;
}
// 最后一次失败
isSessionActive.value = false;
return false;
}
// 不可达,但为了类型安全
isSessionActive.value = false;
return false;
};
// 发送获取AI聊天消息
const sendChat = async (message: string, isInstruct = false) => {
// 检查WebSocket管理器是否存在如果不存在尝试重新初始化
if (!webSocketManager) {
console.error("WebSocket管理器不存在尝试重新初始化...");
initWebSocket();
// 短暂延迟后再次检查连接状态
setTimeout(() => {
const connected = webSocketManager && webSocketManager.isConnected();
isSessionActive.value = connected || false;
// 更新AI消息状态
const aiMsgIndex = chatMsgList.value.length - 1;
if (aiMsgIndex >= 0 && chatMsgList.value[aiMsgIndex].messageRole === MessageRole.AI) {
chatMsgList.value[aiMsgIndex].messageContent = connected ? "" : "发送消息失败,请重试";
chatMsgList.value[aiMsgIndex].isLoading = connected || false;
}
if (connected) {
// 连接成功后重新发送消息
sendChat(message, isInstruct);
} else {
console.error("WebSocket重新初始化失败");
}
}, 1000);
return;
}
/// 发送消息类型 指令/对话文本
const messageType = isInstruct ? 1 : 0;
const messageContent = isInstruct ? commonTypeMessage : message;
// 生成 messageId 并保存到当前会话变量stopRequest 可能使用)
currentSessionMessageId = IdUtils.generateMessageId();
// 插入AI消息并在 pendingMap 中记录
const aiMsg: ChatMessage = {
messageId: currentSessionMessageId,
messageRole: MessageRole.AI,
messageContent: "加载中",
isLoading: true,
};
chatMsgList.value.push(aiMsg);
// 添加AI消息后滚动到底部
setTimeoutScrollToBottom();
const aiMsgIndex = chatMsgList.value.length - 1;
// 记录 pendingMap
pendingMap.set(currentSessionMessageId, aiMsgIndex);
// 启动超时回退
const timeoutId = setTimeout(() => {
const idx = pendingMap.get(currentSessionMessageId);
if (idx != null && chatMsgList.value[idx] && chatMsgList.value[idx].isLoading) {
chatMsgList.value[idx].messageContent = "请求超时,请重试";
chatMsgList.value[idx].isLoading = false;
pendingMap.delete(currentSessionMessageId);
pendingTimeouts.delete(currentSessionMessageId);
isSessionActive.value = false;
setTimeoutScrollToBottom();
}
}, MESSAGE_TIMEOUT);
pendingTimeouts.set(currentSessionMessageId, timeoutId);
// 发送消息,支持重连尝试
const success = await sendWebSocketMessage(messageType, messageContent, { messageId: currentSessionMessageId, tryReconnect: true });
if (!success) {
const idx = pendingMap.get(currentSessionMessageId);
if (idx != null && chatMsgList.value[idx]) {
chatMsgList.value[idx].messageContent = "发送消息失败,请重试";
chatMsgList.value[idx].isLoading = false;
}
// 清理 pending
if (pendingTimeouts.has(currentSessionMessageId)) {
clearTimeout(pendingTimeouts.get(currentSessionMessageId));
pendingTimeouts.delete(currentSessionMessageId);
}
pendingMap.delete(currentSessionMessageId);
isSessionActive.value = false;
}
// 更新发送状态
isSendingMessage.value = success;
};
// 停止请求函数
const stopRequest = async () => {
console.log("停止请求");
// 发送中断消息给服务器 (messageType=2),带上当前 messageId
try {
await sendWebSocketMessage(2, "stop_request", { messageId: currentSessionMessageId, silent: true });
} catch (e) {
console.warn("stopRequest send failed:", e);
}
// 直接将AI消息状态设为停止优先使用 pendingMap 映射)
let aiMsgIndex = -1;
if (currentSessionMessageId && pendingMap.has(currentSessionMessageId)) {
aiMsgIndex = pendingMap.get(currentSessionMessageId);
} else {
aiMsgIndex = chatMsgList.value.length - 1;
}
if (chatMsgList.value[aiMsgIndex] &&
chatMsgList.value[aiMsgIndex].messageRole === MessageRole.AI) {
chatMsgList.value[aiMsgIndex].isLoading = false;
if (chatMsgList.value[aiMsgIndex].messageContent &&
chatMsgList.value[aiMsgIndex].messageContent.trim() &&
!chatMsgList.value[aiMsgIndex].messageContent.startsWith("加载中")) {
// 保留已显示内容
} else {
chatMsgList.value[aiMsgIndex].messageContent = "请求已停止";
}
}
// 清理 pending
if (currentSessionMessageId) {
if (pendingTimeouts.has(currentSessionMessageId)) {
clearTimeout(pendingTimeouts.get(currentSessionMessageId));
pendingTimeouts.delete(currentSessionMessageId);
}
pendingMap.delete(currentSessionMessageId);
}
// 重置会话状态
isSessionActive.value = false;
setTimeoutScrollToBottom();
};
// 组件销毁时清理资源
onUnmounted(() => {
console.log("组件销毁");
resetConfig();
});
const resetConfig = () => {
// 清理WebSocket连接
if (webSocketManager) {
webSocketManager.destroy();
webSocketManager = null;
}
// 重置消息状态
resetMessageState();
isSessionActive.value = false;
// 清理 pendingMap / pendingTimeouts
try {
for (const t of pendingTimeouts.values()) {
clearTimeout(t);
}
} catch (e) {
// ignore
}
pendingTimeouts.clear();
pendingMap.clear();
};
</script>