import { computed, ref } from 'vue'; import dayjs from 'dayjs'; import { message } from 'ant-design-vue'; import { buildReportDownloadUrl, editSessionTitle, getHistoryList, pinSession } from '/@/views/ventAI/manageAssistent/api'; import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index'; import type { HistoryMessage } from '/@/views/ventAI/manageAssistent/api'; import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index'; /** * 会话(任务)状态与列表:taskList / currentTaskId / 当前会话消息,以及列表拉取、置顶、标题、 * 会话 id 迁移与历史消息转换。 * * messages 是唯一数据源:直接读写 currentTask.messages,不再维护两份拷贝; * 切换任务、创建任务这类会牵动面板/额度/滚动的编排逻辑仍留在组件里,此处只提供状态与纯函数。 */ export const useChatTasks = () => { const taskList = ref([]); // 已从接口拉取过的会话 id,用于识别流式期间后端新建的会话 const knownApiSessionIds = new Set(); // 从接口获取会话列表 const fetchSessionList = async () => { try { const res = await getHistoryList({}); // getHistoryList 已按真实契约标注 { sessions, total },不再猜 sessions / data / 裸数组三种外壳 const sessions = res?.sessions ?? []; const apiTasks: Task[] = sessions.map((item) => ({ id: item.session_id, name: item.title || '未命名会话', sessionId: item.session_id, messages: [], attachedFiles: [], updatedAt: item.updated_at, sortOrder: item.sort_order, })); // Build lookup maps for O(1) access const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t])); const apiTaskIds = new Set(apiTasks.map((t) => t.id)); // 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。 // 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目 let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null; const mergedTasks: Task[] = []; for (const apiTask of apiTasks) { if (blankStreamingTask && (blankStreamingTask.pendingSessionId === apiTask.id || !knownApiSessionIds.has(apiTask.id))) { blankStreamingTask.pendingSessionId = apiTask.id; blankStreamingTask.name = apiTask.name; blankStreamingTask.updatedAt = apiTask.updatedAt; blankStreamingTask.sortOrder = apiTask.sortOrder; blankStreamingTask = null; continue; } const existing = existingTaskMap.get(apiTask.id); if (existing) { // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失) existing.name = apiTask.name; existing.updatedAt = apiTask.updatedAt; existing.sortOrder = apiTask.sortOrder; mergedTasks.push(existing); } else { mergedTasks.push(apiTask); } } apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id)); const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id)); taskList.value = [...localOnlyTasks, ...mergedTasks]; return mergedTasks; } catch (error) { console.error('获取会话列表失败:', error); } return []; }; const currentTaskId = ref(''); const currentTask = computed(() => taskList.value.find((t) => t.id === currentTaskId.value)); // 当前会话的消息。单一数据源:直接读写 currentTask.messages,不再维护 // 「视图数组 + 任务数组」两份拷贝——历史实现需要在每次流式事件后手工同步两者, // 是任务切换/流式并存时视图错乱的根源。 // fallbackMessages 仅用于「任务已全部删除」这类没有当前任务的瞬时状态,行为与旧 ref([]) 一致。 const fallbackMessages: Message[] = []; const messages = computed({ get: () => currentTask.value?.messages ?? fallbackMessages, set: (value: Message[]) => { const task = currentTask.value; if (task) task.messages = value; }, }); // Non-reactive lookup cache - Vue 3 does not track Set mutations on ref const historyLoadedTasks = new Set(); // 正在拉取历史的任务 id(前端任务 id)。 // // 切换任务时 currentTaskId 立即切过去,而新任务的 messages 要等 getDetail 回来才有内容: // 这段窗口里消息区会落到 messages.length === 0,若不区分「加载中」与「确实没有消息」, // 用户会先看到一屏空会话缺省图再突然出现内容。消息区据此显示加载态。 // // 用数组而非 Set:ref 的 mutation 不被 Vue 追踪(同 historyLoadedTasks 的注释), // 整体替换数组才是可靠的响应式写法。 const historyLoadingTaskIds = ref([]); const setHistoryLoading = (taskId: string, loading: boolean) => { const rest = historyLoadingTaskIds.value.filter((id) => id !== taskId); historyLoadingTaskIds.value = loading ? [...rest, taskId] : rest; }; const setCurrentSessionId = (sessionId: string) => { const currentTask = taskList.value.find((t) => t.id === currentTaskId.value); if (currentTask && currentTask.id !== sessionId) { taskList.value = taskList.value.filter((t) => t.id !== sessionId); currentTask.id = sessionId; currentTask.sessionId = sessionId; currentTaskId.value = sessionId; historyLoadedTasks.add(sessionId); } }; const handlePinTask = async (task: Task) => { if (!task.sessionId) { message.warning('请先发送消息后再置顶'); return; } try { await pinSession(task.sessionId); await fetchSessionList(); } catch { message.error('置顶操作失败'); } }; // 标题保存(由 ChatHeader 子组件触发,父组件负责 API 调用和状态更新) const handleSaveTitle = async (newTitle: string) => { if (!currentTask.value?.sessionId) return; try { await editSessionTitle(currentTask.value.sessionId, newTitle); currentTask.value.name = newTitle; message.success('标题修改成功'); } catch { message.error('标题修改失败'); } }; const getDefaultMessages = (): Message[] => { return []; }; /** * 历史消息 → 前端 Message[]。入参直接用接口声明的 HistoryMessage, * 不再在本文件另立一份结构副本:两套形状一旦分叉,接口字段变化就不会在这里报错。 */ const transformHistoryToMessages = (data: HistoryMessage[]): Message[] => { const messages: Message[] = []; for (const item of data) { const normalizedContent = normalizeLineBreaks(item.content); const time = item.created_at ? dayjs(item.created_at).format('HH:mm') : ''; const createdAt = item.created_at || ''; // 新格式:role 字段 (user/assistant) if (item.role === 'user') { messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt }); } else if (item.role === 'assistant') { const msg: Message = { id: item.id, type: 'ai', content: normalizedContent, time, createdAt }; if (item.report_url && item.report_filename) { // 后端返回的报告地址前缀不稳定(绝对地址 / 缺 /ventAI 的相对路径),入前端状态前统一归一 msg.downloadFiles = [{ url: buildReportDownloadUrl(item.report_url), filename: item.report_filename }]; } // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化 const rawCitations = item.citations; if (Array.isArray(rawCitations)) { msg.citations = { items: rawCitations, model_only: rawCitations.length === 0 }; } else if (rawCitations) { // null 与 undefined 对调用方等价(均按「没有引用」处理),不赋值以免把 Message 上的可选字段写成 null msg.citations = rawCitations; } messages.push(msg); } // 兼容旧格式:type 字段 else if (item.type === 'user_message') { messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt }); } else if (item.type === 'model_thinking') { const lastAiMsg = messages[messages.length - 1]; if (lastAiMsg && lastAiMsg.type === 'ai') { lastAiMsg.content += normalizedContent; lastAiMsg.content = lastAiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, ''); } else { messages.push({ type: 'ai', content: normalizedContent, time, createdAt }); } } } return messages; }; return { taskList, currentTaskId, currentTask, messages, historyLoadedTasks, historyLoadingTaskIds, setHistoryLoading, getDefaultMessages, transformHistoryToMessages, fetchSessionList, handlePinTask, handleSaveTitle, setCurrentSessionId, }; };