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:
duanshuwen
2026-04-06 14:39:06 +08:00
parent e76b034d50
commit 6615d11dd6
311 changed files with 823682 additions and 4460 deletions

882
src/pages/home/ChatBox.vue Normal file
View 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>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<div class="mt-2 text-xs text-gray-400 ">
本回答由 AI 生成
</div>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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();
}
}