| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 |
- 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, isSplitTurnPair } 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[] = [];
- // 重新生成时,后端会把同一句提问再收一遍,为它再写一条用户记录(前端不再删除它,见 useChatSend
- // 里对这段清理的说明)。加载时把它识别出来:不渲染成气泡,并把紧随其后的回答标成「同一次提问的
- // 又一个回答」、把这条记录的主键并进那条回答 —— 刷新后重新生成的多个回答依旧折叠成一条可切换的
- // 消息,删除时也能把它一起带走。
- //
- // 「内容与上一句提问完全相同」是这条重复记录唯一可持久化的特征(历史接口没有别的区分字段)。
- // 删掉中间某条提问造成的两条相邻回答不满足它(那些提问内容各不相同),因此不会被误认。
- let lastUserContent: string | null = null;
- let markNextAnswerAsFollowUp = false;
- let absorbNextAnswerRowId: number | null = null;
- 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 || '';
- const isUserRow = item.role === 'user' || item.type === 'user_message';
- const trimmed = normalizedContent.trim();
- const isDuplicateQuestion = isUserRow && lastUserContent !== null && trimmed === lastUserContent;
- if (isUserRow) lastUserContent = trimmed;
- if (isDuplicateQuestion) {
- markNextAnswerAsFollowUp = true;
- absorbNextAnswerRowId = item.id ?? null;
- continue;
- }
- // 打标只作用于紧随其后的那一条回答;中间夹了别的行就作废,避免标到别轮的回答上
- if (item.role !== 'assistant') {
- markNextAnswerAsFollowUp = false;
- absorbNextAnswerRowId = null;
- }
- // 新格式: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;
- }
- // 上一条是重新生成的重复提问:这条回答是同一次提问的又一个回答,并接管那条记录
- if (markNextAnswerAsFollowUp) {
- msg.isRegeneratedAnswer = true;
- markNextAnswerAsFollowUp = false;
- }
- if (absorbNextAnswerRowId != null) {
- msg.absorbedRowIds = [absorbNextAnswerRowId];
- absorbNextAnswerRowId = null;
- }
- 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 });
- }
- }
- }
- // 同一轮被中断拆成两段的助手行合成一条(判定与依据见 lib/turnRows.ts):一轮问答在用户视角只有
- // 一条 AI 消息,后端却是两条记录(中断时先落一段,批准/补充回答后再落一段)。合并后的正文
- // 是两段的拼接 —— 与流式过程中这条消息实际显示的内容一致;主键取后一段(整轮回答落库的那条),
- // 前一段的主键记进 absorbedRowIds,删除时一起删。判不出来就维持两条消息。
- const merged: Message[] = [];
- for (let i = 0; i < messages.length; i++) {
- const msg = messages[i];
- if (isSplitTurnPair(messages, i)) {
- const answer = messages[i + 1];
- const absorbed = [...(msg.absorbedRowIds || [])];
- if (msg.id != null) absorbed.push(msg.id);
- answer.content = msg.content + answer.content;
- answer.absorbedRowIds = [...absorbed, ...(answer.absorbedRowIds || [])];
- // 时间取前一段的:这条消息在流式里就是提问后立刻出现的,时间应对齐提问时刻
- answer.time = msg.time;
- answer.createdAt = msg.createdAt;
- continue;
- }
- merged.push(msg);
- }
- return merged;
- };
- return {
- taskList,
- currentTaskId,
- currentTask,
- messages,
- historyLoadedTasks,
- historyLoadingTaskIds,
- setHistoryLoading,
- getDefaultMessages,
- transformHistoryToMessages,
- fetchSessionList,
- handlePinTask,
- handleSaveTitle,
- setCurrentSessionId,
- };
- };
|