| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- 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<Task[]>([]);
- // 已从接口拉取过的会话 id,用于识别流式期间后端新建的会话
- const knownApiSessionIds = new Set<string>();
- // 从接口获取会话列表
- 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<Message[]>({
- 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<Set>
- const historyLoadedTasks = new Set<string>();
- // 正在拉取历史的任务 id(前端任务 id)。
- //
- // 切换任务时 currentTaskId 立即切过去,而新任务的 messages 要等 getDetail 回来才有内容:
- // 这段窗口里消息区会落到 messages.length === 0,若不区分「加载中」与「确实没有消息」,
- // 用户会先看到一屏空会话缺省图再突然出现内容。消息区据此显示加载态。
- //
- // 用数组而非 Set:ref<Set> 的 mutation 不被 Vue 追踪(同 historyLoadedTasks 的注释),
- // 整体替换数组才是可靠的响应式写法。
- const historyLoadingTaskIds = ref<string[]>([]);
- 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,
- };
- };
|