chore: restructure project and add i18n support
- Reorganize project structure with new electron and shared directories - Add comprehensive i18n support with Chinese, English, and Japanese locales - Update build configurations and TypeScript paths for new structure - Add various UI components including chat interface and task management - Include Windows release binaries and localization files - Update dependencies and fix import paths throughout the codebase
This commit is contained in:
882
src/pages/home/ChatBox.vue
Normal file
882
src/pages/home/ChatBox.vue
Normal file
@@ -0,0 +1,882 @@
|
||||
<template>
|
||||
<!-- 页面根 -->
|
||||
<div class="flex flex-col h-full py-6 px-6" :class="isGuidePage ? 'overflow-auto' : 'overflow-hidden'">
|
||||
|
||||
<!-- 引导页顶部 welcome(仅在引导页显示) -->
|
||||
<div v-if="isGuidePage" class="border-box pt-30">
|
||||
<h1 class="text-[28px] font-bold mb-7 leading-tight">
|
||||
你好,<br />
|
||||
我今天能帮你什么?
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 主体滚动区:聊天或引导页内容 -->
|
||||
<div v-if="!isGuidePage" ref="listRef" class="flex-1 overflow-y-auto 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 -->
|
||||
<ChatAvatar v-if="msg.messageRole === MessageRole.AI" :src="aiAvatar" />
|
||||
|
||||
<!-- 自己 发的消息 -->
|
||||
<ChatRoleMe v-if="msg.messageRole === MessageRole.ME" :msg="msg">
|
||||
<template #header>
|
||||
<!-- 名字和时间 -->
|
||||
<ChatNameTime :showReverse="true" :msg="msg" />
|
||||
</template>
|
||||
</ChatRoleMe>
|
||||
|
||||
<!-- AI 发的消息 -->
|
||||
<ChatRoleAI v-if="msg.messageRole === MessageRole.AI" :msg="msg">
|
||||
<template #header>
|
||||
<!-- 名字和时间 -->
|
||||
<ChatNameTime :showReverse="false" :msg="msg" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- 问题标签 -->
|
||||
<ChatAttach v-if="msg.question && msg.question.length > 0" :question="msg.question" @select="onTagSelect" />
|
||||
|
||||
<!-- AI 标识 -->
|
||||
<ChatAIMark v-if="msg.finished" />
|
||||
|
||||
<!-- AI 操作按钮 -->
|
||||
<ChatOperation v-if="msg.finished" :msg="msg" />
|
||||
</template>
|
||||
</ChatRoleAI>
|
||||
|
||||
<!-- User avatar -->
|
||||
<ChatAvatar v-if="msg.messageRole === MessageRole.ME" :src="userAvatar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<div class="flex flex-col gap-3" :class="isGuidePage ? 'mt-16' : 'mt-4'">
|
||||
<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>
|
||||
<ChatInputArea v-model="inputMessage" :isSendingMessage="isSendingMessage" @send="onGuideSend"
|
||||
@attach="addAttachmentAction" />
|
||||
</div>
|
||||
|
||||
<!-- 任务中心(仅在引导页显示) -->
|
||||
<TaskCenter v-if="isGuidePage" />
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, defineEmits, watch, nextTick } from 'vue'
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { WebSocketManager } from "@lib/WebSocketManager";
|
||||
import { MessageRole, ChatMessage } from "./model/ChatModel";
|
||||
import { IdUtils } from "@lib/index";
|
||||
import ChatAvatar from './components/ChatAvatar.vue';
|
||||
import ChatOperation from './components/ChatOperation.vue';
|
||||
import ChatRoleAI from './components/ChatRoleAI.vue';
|
||||
import ChatRoleMe from './components/ChatRoleMe.vue';
|
||||
import ChatAIMark from './components/ChatAIMark.vue';
|
||||
import ChatNameTime from './components/ChatNameTime.vue';
|
||||
import ChatAttach from './components/ChatAttach.vue';
|
||||
import ChatInputArea from './components/ChatInputArea.vue';
|
||||
import TaskCenter from './TaskCenter.vue';
|
||||
|
||||
import { Session } from '../../utils/storage';
|
||||
|
||||
import userAvatar from '@assets/images/login/user_icon.png';
|
||||
import aiAvatar from '@assets/images/login/blue_logo.png';
|
||||
import { createSession, getSessionMessages } from '../../api/SessionsApi';
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
|
||||
// 支持外部通过 prop 控制是否为引导页
|
||||
const props = defineProps({
|
||||
guide: { type: Boolean, default: true },
|
||||
conversationId: { type: String, default: '' }
|
||||
});
|
||||
const emit = defineEmits(['update:guide']);
|
||||
|
||||
/// 是否是引导页(内部响应式)
|
||||
const isGuidePage = ref(props.guide);
|
||||
|
||||
// 同步外部变化到内部
|
||||
watch(() => props.guide, (v) => {
|
||||
isGuidePage.value = v;
|
||||
});
|
||||
// 将内部变化通知父组件
|
||||
watch(isGuidePage, (v) => {
|
||||
emit('update:guide', v);
|
||||
if (v) {
|
||||
// 当切换到引导页时,重置/清理会话状态
|
||||
conversationId.value = '';
|
||||
resetConversation();
|
||||
}
|
||||
});
|
||||
|
||||
// 列表滚动容器引用
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
|
||||
/// 会话列表
|
||||
const chatMsgList = ref<ChatMessage[]>([]);
|
||||
/// 输入口的输入消息
|
||||
const inputMessage = ref("");
|
||||
/// 发送消息中标志
|
||||
const isSendingMessage = ref(false);
|
||||
|
||||
/// agentId 首页接口中获取 1953462165250859011
|
||||
const agentId = ref("1953462165250859011");
|
||||
/// 会话ID 历史数据接口中获取
|
||||
const conversationId = ref(props.conversationId);
|
||||
// 标记 conversationId 是否来自历史消息(由 props.conversationId 提供)
|
||||
const conversationIdFromHistory = ref(!!props.conversationId);
|
||||
|
||||
// 监听 conversationId prop 变化,只有当有值时(选择历史消息)才请求消息列表
|
||||
watch(() => props.conversationId, (newId) => {
|
||||
if (newId) {
|
||||
conversationId.value = newId;
|
||||
console.log("外部 conversationId 变化,加载对应消息:", newId);
|
||||
conversationIdFromHistory.value = true;
|
||||
loadConversationMessages(newId);
|
||||
}
|
||||
});
|
||||
|
||||
// 会话进行中标志
|
||||
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;
|
||||
|
||||
// 滚动到底部 - 精确计算 last 元素位置并使用双 rAF 保证布局稳定
|
||||
const scrollToBottom = (smooth = false) => {
|
||||
nextTick(() => {
|
||||
const el = listRef.value;
|
||||
if (!el) return;
|
||||
|
||||
const doScroll = () => {
|
||||
const last = el.lastElementChild as HTMLElement | null;
|
||||
// 计算容器 style padding-bottom,保证滚动到真正可视底部
|
||||
const style = window.getComputedStyle(el);
|
||||
const paddingBottom = parseFloat(style.paddingBottom || '0') || 0;
|
||||
|
||||
if (last) {
|
||||
const lastOffset = last.offsetTop + last.offsetHeight;
|
||||
const target = lastOffset + paddingBottom - el.clientHeight;
|
||||
const top = Math.max(0, Math.ceil(target));
|
||||
if (smooth && typeof el.scrollTo === 'function') {
|
||||
el.scrollTo({ top, behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollTop = top;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:滚到底
|
||||
if (smooth && typeof el.scrollTo === 'function') {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
};
|
||||
// 使用两次 requestAnimationFrame 增强在复杂渲染/打字机更新场景的可靠性
|
||||
requestAnimationFrame(() => requestAnimationFrame(doScroll));
|
||||
});
|
||||
};
|
||||
|
||||
// 延时滚动
|
||||
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();
|
||||
};
|
||||
|
||||
/// 选择标签事件:切换到聊天页并发送
|
||||
const onTagSelect = (text: string) => {
|
||||
isGuidePage.value = false;
|
||||
nextTick(() => handleReplyText(text));
|
||||
};
|
||||
|
||||
// 在引导页中按发送:切换到聊天页再发送
|
||||
const onGuideSend = () => {
|
||||
isGuidePage.value = false;
|
||||
nextTick(() => sendMessageAction());
|
||||
};
|
||||
|
||||
/// 添加附件按钮事件
|
||||
const addAttachmentAction = () => {
|
||||
console.log("添加附件");
|
||||
};
|
||||
|
||||
// 输入区的发送消息事件
|
||||
const sendMessageAction = () => {
|
||||
if (isSendingMessage.value) {
|
||||
sendStopAction();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = inputMessage.value || '';
|
||||
// 去除尾部多余的换行,保留中间换行(例如 Shift+Enter)
|
||||
const sendText = raw.replace(/\n+$/, '');
|
||||
console.log("输入消息:", sendText);
|
||||
if (!sendText.trim()) return;
|
||||
// 重置消息状态,准备接收新的AI回复
|
||||
resetMessageState();
|
||||
sendMessage(sendText);
|
||||
setTimeoutScrollToBottom();
|
||||
};
|
||||
|
||||
// 停止发送消息事件
|
||||
const sendStopAction = () => {
|
||||
console.log("停止发送消息");
|
||||
isSendingMessage.value = false;
|
||||
stopRequest(); // 发送中断停止消息类型
|
||||
};
|
||||
|
||||
// 页面加载时初始化
|
||||
onMounted(() => {
|
||||
try {
|
||||
// 有token时,加载最近会话、最近消息、初始化socket
|
||||
initHandler();
|
||||
} catch (error) {
|
||||
console.error("初始化错误:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// token存在,初始化数据
|
||||
const initHandler = async () => {
|
||||
console.log("initHandler:检查 token 并初始化数据");
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
await initWebSocket();
|
||||
};
|
||||
|
||||
const getAccessToken = () => {
|
||||
// 从本地存储获取 token
|
||||
return Session.get('token') || '';
|
||||
};
|
||||
|
||||
const checkToken = async () => {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error("未登录或登录已过期,请重新登录");
|
||||
}
|
||||
};
|
||||
|
||||
// 调用接口创建新会话
|
||||
const createConversationRequest = async (): Promise<string | null> => {
|
||||
const res = await createSession({});
|
||||
if (res && res.session_id) {
|
||||
conversationId.value = res.session_id;
|
||||
// 新创建的 session 不是来源于历史
|
||||
conversationIdFromHistory.value = false;
|
||||
console.log("创建新会话,ID:", conversationId.value);
|
||||
return res.session_id;
|
||||
} else {
|
||||
console.log("创建会话失败,接口返回异常");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载历史会话消息
|
||||
const loadConversationMessages = async (convId: string) => {
|
||||
try {
|
||||
const res = await getSessionMessages({ session_id: convId, limit: 50, offset: 0 });
|
||||
// 将消息转换为 ChatMessage 格式
|
||||
chatMsgList.value = res.messages.map((msg: any) => ({
|
||||
messageId: msg.message_id,
|
||||
messageRole: msg.role === 'user' ? MessageRole.ME : MessageRole.AI,
|
||||
messageContent: msg.content,
|
||||
messageContentList: [msg.content],
|
||||
timestamp: msg.created_at_ts,
|
||||
finished: true, // 历史消息已完成
|
||||
}));
|
||||
console.log("加载历史消息:", chatMsgList.value);
|
||||
// 加载历史消息后滚动到底部
|
||||
nextTick(() => scrollToBottom());
|
||||
} catch (error) {
|
||||
console.error("加载历史消息失败:", 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}`;
|
||||
const wsUrl = `wss://onefeel.brother7.cn/ingress/nianxx/ws?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) => {
|
||||
console.log("收到WebSocket消息:", data);
|
||||
|
||||
if (data.type === 'notification' && data.event === 'connected') {
|
||||
console.log("WebSocket连接已建立,服务器消息:", data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'heartbeat') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证关键字段(若服务端传回 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.reply_message_id || 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].messageContentList = [data.content];
|
||||
chatMsgList.value[aiMsgIndex].isLoading = false;
|
||||
} else {
|
||||
// 后续流式内容追加
|
||||
chatMsgList.value[aiMsgIndex].messageContent += data.content;
|
||||
chatMsgList.value[aiMsgIndex].messageContentList.push(data.content);
|
||||
}
|
||||
nextTick(() => scrollToBottom());
|
||||
}
|
||||
|
||||
/// 对于通知类消息,如果没有明确的完成状态,默认视为已完成,触发后续处理逻辑(例如心跳、连接建立等事件)
|
||||
if (data.type === 'notification') {
|
||||
data.finish = data.finish || true; // 确保 finish 字段存在
|
||||
}
|
||||
|
||||
// 处理完成状态
|
||||
if (data.finish) {
|
||||
chatMsgList.value[aiMsgIndex].timestamp = Date.now();
|
||||
chatMsgList.value[aiMsgIndex].finished = 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) {
|
||||
console.log("收到问题标签:", data.question);
|
||||
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();
|
||||
setTimeoutScrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
// 重置消息状态
|
||||
const resetMessageState = () => {
|
||||
// 重置当前会话消息ID
|
||||
currentSessionMessageId = null;
|
||||
};
|
||||
|
||||
// 发送消息的参数拼接
|
||||
const sendMessage = async (message: string, isInstruct: boolean = false) => {
|
||||
console.log("发送的消息:", message);
|
||||
|
||||
await checkToken();
|
||||
|
||||
// 如果没有 conversationId(且非历史来源),在发送时按需创建会话
|
||||
if (!conversationId.value) {
|
||||
const sid = await createConversationRequest();
|
||||
if (!sid) {
|
||||
ElMessage({ message: '创建会话失败,请稍后重试', type: 'error' });
|
||||
console.error('createConversationRequest failed before send');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查WebSocket连接状态,如果未连接,尝试重新连接
|
||||
if (!isWsConnected()) {
|
||||
console.log("WebSocket未连接,尝试重新连接...");
|
||||
// 显示加载提示
|
||||
const loadingInstance = ElLoading.service({ fullscreen: true, text: '正在连接服务器...' });
|
||||
// 尝试重新初始化WebSocket连接
|
||||
try {
|
||||
await initWebSocket();
|
||||
// 等待短暂时间确保连接建立
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 检查连接是否成功建立
|
||||
if (!isWsConnected()) {
|
||||
loadingInstance.close();
|
||||
ElMessage({ message: '连接服务器失败,请稍后重试', type: 'error' })
|
||||
console.error("重新连接WebSocket后仍未连接成功");
|
||||
return;
|
||||
}
|
||||
loadingInstance.close();
|
||||
} catch (error) {
|
||||
loadingInstance.close();
|
||||
console.error("重新连接WebSocket失败:", error);
|
||||
ElMessage({ message: '连接服务器失败,请稍后重试', type: 'error' })
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSessionActive.value) {
|
||||
ElMessage({ message: '当前会话正在进行中,请等待回复完成', type: 'warning' })
|
||||
console.warn("当前会话正在进行中,请等待回复完成");
|
||||
return;
|
||||
}
|
||||
isSessionActive.value = true;
|
||||
const newMsg: ChatMessage = {
|
||||
messageId: IdUtils.generateMessageId(),
|
||||
messageRole: MessageRole.ME,
|
||||
messageContent: message,
|
||||
messageContentList: [],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
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: "加载中",
|
||||
messageContentList: [],
|
||||
timestamp: Date.now(),
|
||||
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;
|
||||
isSendingMessage.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();
|
||||
};
|
||||
|
||||
// 清空会话并停止相关活动(保留 websocket 连接以便继续使用)
|
||||
const resetConversation = () => {
|
||||
try {
|
||||
// 如果正在发送,尝试发送停止请求
|
||||
try {
|
||||
if (isSendingMessage.value) sendStopAction();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 清理 pendingTimeouts
|
||||
for (const t of pendingTimeouts.values()) {
|
||||
clearTimeout(t);
|
||||
}
|
||||
pendingTimeouts.clear();
|
||||
pendingMap.clear();
|
||||
|
||||
// 如果 conversationId 不是来自历史,重置 conversationId
|
||||
if (!conversationIdFromHistory.value) {
|
||||
conversationId.value = '';
|
||||
}
|
||||
// 清理消息与状态
|
||||
chatMsgList.value = [];
|
||||
inputMessage.value = '';
|
||||
isSendingMessage.value = false;
|
||||
isSessionActive.value = false;
|
||||
currentSessionMessageId = null;
|
||||
} catch (e) {
|
||||
console.warn('resetConversation failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
167
src/pages/home/ChatHistory.vue
Normal file
167
src/pages/home/ChatHistory.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<aside class="w-50 h-full box-border flex flex-col">
|
||||
<div class="flex items-center m-2">
|
||||
<img class="w-10 h-10 rounded-md" src="@assets/images/login/white_logo.png" />
|
||||
<div class="font-bold text-gray-80">YINIAN</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center m-2 bg-white rounded-lg p-2.5 border-[#E5E8EE] shadow-sm text-center"
|
||||
@click="addNewChat">
|
||||
<RiAddLine /> 新对话
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto p-2 ">
|
||||
<ul class="list-none">
|
||||
<li v-for="item in groups" :key="item.conversationId" @click="selectedHistoryMessage(item.conversationId)"
|
||||
:class="[
|
||||
'flex items-center gap-2 p-2 text-gray-600 rounded-lg cursor-pointer transition-colors',
|
||||
item.conversationId === selectedConversationId ? 'bg-white shadow-sm border-[#E5E8EE] py-1.5 relative z-10' : 'hover:bg-gray-200'
|
||||
]">
|
||||
<span class="w-2 h-2 rounded-full bg-[#BEDBFF] flex-none"></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate text-sm">{{ item.conversationTitle }}</div>
|
||||
</div>
|
||||
|
||||
<el-dropdown v-if="item.conversationId === selectedConversationId" placement="bottom-end">
|
||||
<el-icon class="el-icon--right">
|
||||
...
|
||||
</el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="renameHistoryMessage(item.conversationId)">重命名</el-dropdown-item>
|
||||
<el-dropdown-item @click="deleteHistoryMessage(item.conversationId)">删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 重命名对话框 -->
|
||||
<el-dialog v-model="renameDialogFormVisible" title="重命名对话" width="500">
|
||||
<el-form :model="newMessageName">
|
||||
<el-form-item label="对话名称" :label-width="formLabelWidth">
|
||||
<el-input v-model="newMessageName" autocomplete="off" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="renameDialogFormVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitNameChange">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<el-dialog v-model="deleteDialogVisible" title="温馨提示" width="500">
|
||||
<span>您确定删除该会话吗?删除后将无法恢复!</span>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="deleteDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitDelete">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, defineEmits } from 'vue'
|
||||
import { RiAddLine, RiArrowRightSLine, RiArrowDownSLine } from '@remixicon/vue'
|
||||
import { getSessionList, deleteSession, updateSession } from '../../api/SessionsApi';
|
||||
|
||||
const deleteDialogVisible = ref(false)
|
||||
const renameDialogFormVisible = ref(false)
|
||||
const newMessageName = ref('')
|
||||
const formLabelWidth = '100px'
|
||||
|
||||
interface HistoryMessage {
|
||||
conversationId: string;
|
||||
conversationTitle: string;
|
||||
}
|
||||
|
||||
/// 记录选择的历史消息ID
|
||||
const selectedConversationId = ref<string>('')
|
||||
|
||||
/// 历史消息分组数据
|
||||
const groups = ref<Array<HistoryMessage>>([])
|
||||
|
||||
/// 定义事件
|
||||
const emit = defineEmits(['new-chat', 'select-chat'])
|
||||
|
||||
/// 添加新对话
|
||||
const addNewChat = () => {
|
||||
console.log('add new chat')
|
||||
updateNewChat()
|
||||
}
|
||||
|
||||
const updateNewChat = () => {
|
||||
// 触发新对话事件
|
||||
emit('new-chat')
|
||||
// 清空选择的历史消息ID
|
||||
selectedConversationId.value = ''
|
||||
// 获取最新的历史会话列表
|
||||
getHistoryConversationList()
|
||||
}
|
||||
|
||||
/// 选择历史消息
|
||||
const selectedHistoryMessage = (conversationId: string) => {
|
||||
selectedConversationId.value = conversationId
|
||||
emit('select-chat', conversationId)
|
||||
}
|
||||
|
||||
/// 重命名历史消息
|
||||
const renameHistoryMessage = (conversationId: string) => {
|
||||
console.log('rename message', conversationId)
|
||||
renameDialogFormVisible.value = true
|
||||
}
|
||||
|
||||
/// 删除历史消息
|
||||
const deleteHistoryMessage = (conversationId: string) => {
|
||||
console.log('delete message', conversationId)
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
/// 提交重命名
|
||||
const submitNameChange = async () => {
|
||||
console.log('submit name change', newMessageName.value)
|
||||
renameDialogFormVisible.value = false
|
||||
const res = await updateSession({
|
||||
session_id: selectedConversationId.value,
|
||||
title: newMessageName.value
|
||||
})
|
||||
if (res && res.success) {
|
||||
updateNewChat()
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交删除
|
||||
const submitDelete = async () => {
|
||||
console.log('submit delete')
|
||||
deleteDialogVisible.value = false
|
||||
const res = await deleteSession({
|
||||
session_id: selectedConversationId.value
|
||||
})
|
||||
if (res && res.success) {
|
||||
updateNewChat()
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面加载时获取历史会话列表
|
||||
onMounted(() => {
|
||||
getHistoryConversationList()
|
||||
})
|
||||
|
||||
/// 获取历史会话列表
|
||||
const getHistoryConversationList = async () => {
|
||||
const list = await getSessionList({ limit: 50, offset: 0 })
|
||||
if (!list || !list.sessions) return;
|
||||
// 使用整体赋值替换 push,避免重复累加
|
||||
groups.value = list.sessions.map((item: any) => ({
|
||||
conversationId: item.session_id,
|
||||
conversationTitle: item.title
|
||||
}))
|
||||
}
|
||||
|
||||
</script>
|
||||
56
src/pages/home/TaskCenter.vue
Normal file
56
src/pages/home/TaskCenter.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex-1 pb-6">
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<h3 class="text-base font-semibold">任务中心</h3>
|
||||
<!-- <a class="text-[#3b82f6] text-[13px] cursor-pointer">
|
||||
编辑
|
||||
</a> -->
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 max-[800px]:grid-cols-1">
|
||||
<div v-for="item in taskList" :key="item.id" class="flex gap-3 items-start p-3.5
|
||||
rounded-[10px] border border-[#dfeaf6] bg-white cursor-pointer" @click="handleTaskItem(item)">
|
||||
<div class="w-11 h-11 bg-[#EFF6FF] rounded-lg
|
||||
border border-dashed border-[#9fc0e8]
|
||||
flex items-center justify-center
|
||||
text-[#3b82f6] text-[23px]">
|
||||
{{ item.icon }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-[#9aa5b1] text-[13px] mt-1.5">
|
||||
{{ item.desc }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { taskCenterList, taskCenterItem } from '@constant/taskCenterList'
|
||||
import { channels } from '@constant/channel'
|
||||
import emitter from '@utils/emitter'
|
||||
|
||||
const taskList = computed(() => taskCenterList)
|
||||
|
||||
// 点击任务项
|
||||
const handleTaskItem = (item: taskCenterItem) => {
|
||||
if (item.type === 'sale') {
|
||||
return
|
||||
}
|
||||
|
||||
// 一键打开各渠道
|
||||
if (item.type === 'channel') {
|
||||
window.api.openChannel(channels)
|
||||
return
|
||||
}
|
||||
|
||||
// 操作房型
|
||||
emitter.emit('OPERATION_CHANNEL', item)
|
||||
}
|
||||
</script>
|
||||
5
src/pages/home/components/ChatAIMark.vue
Normal file
5
src/pages/home/components/ChatAIMark.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="mt-2 text-xs text-gray-400 ">
|
||||
本回答由 AI 生成
|
||||
</div>
|
||||
</template>
|
||||
34
src/pages/home/components/ChatAttach.vue
Normal file
34
src/pages/home/components/ChatAttach.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="tag-flex flex-wrap pt-3">
|
||||
<div class="inline-flex items-center justify-center box-border border border-[#E5E8EE] rounded-lg py-0.5 px-2.5 mr-2 mb-2"
|
||||
v-for="(item, index) in questionList" :key="index" @click="handleClick(item)">
|
||||
<span class="tag-text-[#2d91ff] text-[10px]">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
question: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const questionList = ref<string[]>([]);
|
||||
|
||||
// 定义 emit 事件,向父组件发送选中的 tag
|
||||
const emit = defineEmits<{ (e: 'select', tag: string): void }>();
|
||||
|
||||
const handleClick = (item: string) => {
|
||||
emit('select', item);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
questionList.value = props.question.split(/[&|;]/).filter((tag) => tag.trim() !== "");
|
||||
});
|
||||
|
||||
</script>
|
||||
13
src/pages/home/components/ChatAvatar.vue
Normal file
13
src/pages/home/components/ChatAvatar.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<img class="w-9 h-9 rounded-full shrink-0" :src="src" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
src?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
src: '@assets/images/login/blue_logo.png'
|
||||
})
|
||||
</script>
|
||||
48
src/pages/home/components/ChatInputArea.vue
Normal file
48
src/pages/home/components/ChatInputArea.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="h-[174px] bg-white rounded-lg border border-[#eef2f6] shadow-[0_1px_0_rgba(0,0,0,0.03)] p-4 mt-2 flex flex-col justify-between">
|
||||
<textarea
|
||||
rows="2"
|
||||
placeholder="给我发布或者布置任务"
|
||||
class="flex-1 resize-none outline-none text-sm"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
@keydown.enter="onKeydownEnter"
|
||||
/>
|
||||
|
||||
<div class="flex justify-between items-end">
|
||||
<button @click="onAttach">
|
||||
<RiLink />
|
||||
</button>
|
||||
<button class="w-12 h-12 bg-[#F5F7FA] px-2.5 py-1.5 rounded-md flex items-center justify-center" @click="onSend">
|
||||
<RiStopFill v-if="isSendingMessage" />
|
||||
<RiSendPlaneFill v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { RiLink, RiSendPlaneFill, RiStopFill } from '@remixicon/vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
isSendingMessage: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'send', 'attach'])
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const v = (e.target as HTMLTextAreaElement).value
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
const onKeydownEnter = (e: KeyboardEvent) => {
|
||||
if ((e as KeyboardEvent).shiftKey) return
|
||||
e.preventDefault()
|
||||
emit('send')
|
||||
}
|
||||
|
||||
const onAttach = () => emit('attach')
|
||||
const onSend = () => emit('send')
|
||||
</script>
|
||||
50
src/pages/home/components/ChatLoading.vue
Normal file
50
src/pages/home/components/ChatLoading.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="wave">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script></script>
|
||||
|
||||
<style scoped>
|
||||
.wave {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 30px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
margin-right: 3px;
|
||||
background: #333333;
|
||||
animation: wave 1.3s linear infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: -1.1s;
|
||||
}
|
||||
|
||||
.dot:nth-child(3) {
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: initial;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
65
src/pages/home/components/ChatNameTime.vue
Normal file
65
src/pages/home/components/ChatNameTime.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="flex items-start gap-2 pt-0.5 mb-2" :class="props.showReverse ? 'flex-row-reverse' : 'flex-row'">
|
||||
<span class="text-xs text-[#4E5969]">{{ props.msg?.messageRole === MessageRole.AI ? 'NIANXX' : '我' }}</span>
|
||||
<span class="text-xs text-[#86909C]">{{ formattedTime }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { ChatMessage, MessageRole } from '../model/ChatModel'
|
||||
|
||||
interface Props {
|
||||
msg?: ChatMessage
|
||||
showReverse?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showReverse: false
|
||||
})
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const tsRaw = props.msg?.timestamp
|
||||
if (tsRaw == null) return ''
|
||||
let ts = Number(tsRaw)
|
||||
if (isNaN(ts)) return ''
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
// Heuristic:
|
||||
// - If ts < 1e9, treat as a duration in seconds and convert to dd-hh-mm (legacy)
|
||||
// - If ts looks like an epoch (seconds or ms) format to YYYY年MM月DD日 HH:mm:ss
|
||||
if (ts < 1e9) {
|
||||
const totalSeconds = Math.floor(ts)
|
||||
const days = Math.floor(totalSeconds / 86400)
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
return `${String(days).padStart(2, '0')}-${pad(hours)}-${pad(minutes)}`
|
||||
}
|
||||
|
||||
// epoch handling: convert seconds -> ms when appropriate
|
||||
if (ts < 1e12) ts = ts * 1000
|
||||
const d = new Date(ts)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const Y = d.getFullYear()
|
||||
const M = pad(d.getMonth() + 1)
|
||||
const D = pad(d.getDate())
|
||||
const h = pad(d.getHours())
|
||||
const m = pad(d.getMinutes())
|
||||
const s = pad(d.getSeconds())
|
||||
|
||||
// If the timestamp is the same calendar day as today, show only time HH:mm:ss
|
||||
const now = new Date()
|
||||
const sameDay = now.getFullYear() === d.getFullYear()
|
||||
&& now.getMonth() === d.getMonth()
|
||||
&& now.getDate() === d.getDate()
|
||||
|
||||
if (sameDay) {
|
||||
return `${h}:${m}:${s}`
|
||||
}
|
||||
|
||||
// otherwise show YYYY-MM-DD HH:mm:ss
|
||||
return `${Y}-${M}-${D} ${h}:${m}:${s}`
|
||||
})
|
||||
|
||||
</script>
|
||||
43
src/pages/home/components/ChatOperation.vue
Normal file
43
src/pages/home/components/ChatOperation.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="mt-4 text-gray-500 flex items-center justify-between gap-4 ">
|
||||
<RiFileCopyLine size="16px" @click="copyFileClick()" />
|
||||
<div class="flex items-center gap-4">
|
||||
<RiShareForwardLine size="16px" @click="shareForwardClick()" />
|
||||
<RiDownload2Line size="16px" @click="downloadClick()" />
|
||||
<RiThumbUpLine size="16px" @click="thumbUpClick()" />
|
||||
<RiThumbDownLine size="16px" @click="thumbDownClick()" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { RiFileCopyLine, RiShareForwardLine, RiDownload2Line, RiThumbUpLine, RiThumbDownLine } from '@remixicon/vue'
|
||||
import { ChatMessage } from '../model/ChatModel';
|
||||
|
||||
interface Props {
|
||||
msg: ChatMessage
|
||||
}
|
||||
|
||||
const { msg } = defineProps<Props>()
|
||||
|
||||
/// actions 实现复制、分享、下载、点赞等功能
|
||||
const copyFileClick = () => {
|
||||
console.log('copy file', msg)
|
||||
}
|
||||
|
||||
const shareForwardClick = () => {
|
||||
console.log('share forward', msg)
|
||||
}
|
||||
|
||||
const downloadClick = () => {
|
||||
console.log('download', msg)
|
||||
}
|
||||
|
||||
const thumbUpClick = () => {
|
||||
console.log('thumb up', msg)
|
||||
}
|
||||
|
||||
const thumbDownClick = () => {
|
||||
console.log('thumb down', msg)
|
||||
}
|
||||
|
||||
</script>
|
||||
60
src/pages/home/components/ChatRoleAI.vue
Normal file
60
src/pages/home/components/ChatRoleAI.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="max-w-[75%] flex flex-col">
|
||||
<slot name="header"></slot>
|
||||
<div v-if="!msg.messageContentList || msg.messageContentList.length === 0"
|
||||
class="flex flex-row text-sm text-gray-700">
|
||||
<div v-html="compiledMarkdown"></div>
|
||||
<ChatLoading v-if="msg.isLoading" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col p-2 mb-2 text-sm text-gray-700 bg-[#f7f9fc] rounded-md"
|
||||
v-for="(_, index) in msg.messageContentList" :key="index">
|
||||
<div v-html="compiledAt(index)"></div>
|
||||
</div>
|
||||
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ChatMessage } from '../model/ChatModel';
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import ChatLoading from './ChatLoading.vue';
|
||||
|
||||
interface Props {
|
||||
msg: ChatMessage
|
||||
}
|
||||
|
||||
const { msg } = defineProps<Props>()
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight: function (str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang, ignoreIllegals: true }).value;
|
||||
} catch (__) { }
|
||||
}
|
||||
// 自动检测
|
||||
return hljs.highlightAuto(str).value;
|
||||
}
|
||||
});
|
||||
|
||||
const compiledMarkdown = computed(() => md.render(msg.messageContent))
|
||||
|
||||
const compiledList = computed(() => {
|
||||
return (msg.messageContentList || []).map((m: string) => md.render(m || ''))
|
||||
})
|
||||
|
||||
const compiledAt = (index: number): string => {
|
||||
const list: string[] = (compiledList as any).value || []
|
||||
if (list[index]) return list[index]
|
||||
const raw = msg?.messageContentList?.[index] || ''
|
||||
return md.render(raw || '')
|
||||
}
|
||||
|
||||
</script>
|
||||
20
src/pages/home/components/ChatRoleMe.vue
Normal file
20
src/pages/home/components/ChatRoleMe.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="max-w-[75%]">
|
||||
<slot name="header"></slot>
|
||||
<div class="text-sm text-gray-700 bg-[#f7f9fc] rounded-md px-2 py-2">
|
||||
{{ msg.messageContent }}
|
||||
</div>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ChatMessage } from '../model/ChatModel';
|
||||
|
||||
interface Props {
|
||||
msg: ChatMessage
|
||||
}
|
||||
|
||||
const { msg } = defineProps<Props>()
|
||||
|
||||
</script>
|
||||
119
src/pages/home/components/TaskOperationDialog.vue
Normal file
119
src/pages/home/components/TaskOperationDialog.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<el-dialog v-model="isVisible" :title="title" width="480" align-center>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-position="top" class="pl-4 pr-4 pt-4">
|
||||
<el-form-item label="选择房型" prop="roomType">
|
||||
<el-select v-model="form.roomType" placeholder="请选择房型">
|
||||
<el-option v-for="item in roomList" :label="item.pmsName" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择日期" prop="range">
|
||||
<el-date-picker v-model="form.range" type="daterange" value-format="YYYY-MM-DD" placeholder="请选择日期"
|
||||
style="width: 100%">
|
||||
</el-date-picker>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="cancel">取消</el-button>
|
||||
<el-button type="primary" @click="confirm">确认</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { taskCenterItem } from '@constant/taskCenterList'
|
||||
import { hotelStaffTypeMappingListUsingPost } from '@api/index'
|
||||
|
||||
const isVisible = ref(false)
|
||||
const roomList: any = ref([])
|
||||
const title = ref('')
|
||||
const formRef = ref()
|
||||
const form = ref({
|
||||
roomType: '',
|
||||
operation: '',
|
||||
range: [],
|
||||
})
|
||||
const rules = ref({
|
||||
roomType: [
|
||||
{ required: true, message: '请选择房型', trigger: 'blur' },
|
||||
],
|
||||
range: [
|
||||
{
|
||||
required: true,
|
||||
message: '请选择日期范围',
|
||||
trigger: 'change',
|
||||
type: 'array'
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
// 打开弹窗
|
||||
const open = ({ type }: taskCenterItem) => {
|
||||
title.value = type === 'open' ? '开启渠道房型' : '关闭渠道房型'
|
||||
isVisible.value = true
|
||||
form.value.operation = type
|
||||
getRoomTypeList()
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const close = () => {
|
||||
isVisible.value = false
|
||||
}
|
||||
|
||||
// 重置form
|
||||
const reset = () => {
|
||||
form.value.roomType = ''
|
||||
form.value.range = []
|
||||
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const cancel = () => {
|
||||
close()
|
||||
reset()
|
||||
}
|
||||
|
||||
// 确认操作
|
||||
const confirm = () => {
|
||||
formRef.value.validate((valid: boolean) => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
|
||||
close()
|
||||
|
||||
// 将roomList.value数组处理成标准数组
|
||||
const newList = roomList.value.map((item: any) => ({ ...item }))
|
||||
|
||||
const options = {
|
||||
roomType: form.value.roomType,
|
||||
startTime: form.value.range[0],
|
||||
endTime: form.value.range[1],
|
||||
operation: form.value.operation,
|
||||
roomList: newList
|
||||
}
|
||||
|
||||
console.log(options)
|
||||
/**
|
||||
* 坑:传给进程的参数不能是ref包裹的reactive对象
|
||||
*/
|
||||
window.api.executeScript(options)
|
||||
|
||||
reset()
|
||||
})
|
||||
}
|
||||
|
||||
// 获取房型列表
|
||||
const getRoomTypeList = async () => {
|
||||
const res = await hotelStaffTypeMappingListUsingPost({ body: {} })
|
||||
roomList.value = res.data
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
})
|
||||
</script>
|
||||
35
src/pages/home/index.vue
Normal file
35
src/pages/home/index.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<layout>
|
||||
<div class="flex h-full w-full flex-col md:flex-row">
|
||||
<ChatHistory class="flex-none w-50" @new-chat="handleNewChat" @select-chat="handleSelectChat" />
|
||||
<div class="flex-1 mr-2 overflow-hidden bg-white rounded-xl">
|
||||
<ChatBox v-model:guide="guide" :conversationId="selectedConversationId" />
|
||||
</div>
|
||||
<TaskList />
|
||||
</div>
|
||||
</layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TaskList from '@src/components/TaskList/index.vue'
|
||||
import ChatHistory from './ChatHistory.vue'
|
||||
import ChatBox from './ChatBox.vue'
|
||||
import { ref } from 'vue'
|
||||
/// 是否显示引导页
|
||||
const guide = ref(true)
|
||||
/// 选择的历史会话ID
|
||||
const selectedConversationId = ref('')
|
||||
|
||||
/// 处理新对话事件:切换到引导页并清空选中的历史会话ID
|
||||
const handleNewChat = () => {
|
||||
guide.value = true;
|
||||
selectedConversationId.value = '';
|
||||
};
|
||||
|
||||
/// 选择历史会话
|
||||
const handleSelectChat = (conversationId: string) => {
|
||||
guide.value = false;
|
||||
selectedConversationId.value = conversationId;
|
||||
};
|
||||
|
||||
</script>
|
||||
53
src/pages/home/model/ChatModel.ts
Normal file
53
src/pages/home/model/ChatModel.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/// 消息角色枚举
|
||||
export enum MessageRole {
|
||||
// 智能体消息
|
||||
AI = "AI",
|
||||
// 我发送的消息
|
||||
ME = "ME",
|
||||
// 其他消息
|
||||
OTHER = "OTHER",
|
||||
};
|
||||
|
||||
/// Chat消息模型
|
||||
export class ChatMessage {
|
||||
// 消息唯一标识
|
||||
messageId: string;
|
||||
// 消息类型
|
||||
messageRole: MessageRole;
|
||||
// 消息内容
|
||||
messageContent: string;
|
||||
// 消息内容列表(用于流式更新)
|
||||
messageContentList: string[];
|
||||
// 是否加载中
|
||||
isLoading?: boolean;
|
||||
// 是否完成
|
||||
finished?: boolean;
|
||||
// 工具调用信息
|
||||
toolCall?: any;
|
||||
// 问题信息
|
||||
question?: string;
|
||||
// 时间戳
|
||||
timestamp?: number;
|
||||
|
||||
constructor(
|
||||
messageId: string,
|
||||
messageRole: MessageRole,
|
||||
messageContent: string,
|
||||
messageContentList: string[] = [],
|
||||
isLoading: boolean = false,
|
||||
finished: boolean = false,
|
||||
toolCall?: any,
|
||||
question?: any,
|
||||
timestamp?: number
|
||||
) {
|
||||
this.messageId = messageId;
|
||||
this.messageRole = messageRole;
|
||||
this.messageContent = messageContent;
|
||||
this.messageContentList = messageContentList;
|
||||
this.isLoading = isLoading;
|
||||
this.finished = finished;
|
||||
this.toolCall = toolCall;
|
||||
this.question = question;
|
||||
this.timestamp = timestamp || Date.now();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user