Explorar o código

[Mod 0000]修改对话中出现重复多条AI消息时的错误折叠问题

wangkeyi hai 2 días
pai
achega
3ac4bf8b27

+ 12 - 0
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -314,6 +314,8 @@
       currentTodos.value = [];
     },
     setCurrentSessionId: (id) => setCurrentSessionId(id),
+    // 延迟求值:useChatSend 在下面才初始化(与其它依赖同一约定,避免 setup 期 TDZ)
+    hydrateMessageIds: (sessionId, msgs) => hydrateSendMessageIds(sessionId, msgs),
   });
 
   // —— 发送/流式管线已下沉到 useChatSend(handleSendMessage 作为输入框编排留在组件里) ——
@@ -328,6 +330,7 @@
     sendWithAttachment,
     handleRegenerate,
     handleEditResend,
+    hydrateMessageIds: hydrateSendMessageIds,
   } = useChatSend({
     getTaskList: () => taskList.value,
     getCurrentTaskId: () => currentTaskId.value,
@@ -389,6 +392,15 @@
       onOk: async () => {
         try {
           await deleteMessage(msg.id!);
+          // 被中断拆成两段的回答里,前一段也是后端单独一条记录,一并删除;失败只记日志不阻断本地移除
+          // ——主行已删,界面上这条消息本就该消失(残留的前段刷新后会单独出现,属可接受的退化)
+          for (const absorbedId of msg.absorbedRowIds ?? []) {
+            try {
+              await deleteMessage(absorbedId);
+            } catch (e) {
+              console.error('删除被中断段落失败:', e);
+            }
+          }
           message.success('消息已删除');
           if (ownerTask) {
             ownerTask.messages = ownerTask.messages.filter((m) => m !== msg);

+ 17 - 18
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -207,13 +207,16 @@
     indices: number[];
   }
 
-  // 将连续的 AI 消息划分为若干“回答组”
+  // 将「同一次提问的多个回答」划分为若干回答组:一条回答连同它后面**由重新生成追加的**回答算一组
+  // (见 Message.isRegeneratedAnswer)。不能按「连续 AI 消息」推断分组 —— 删掉中间的提问后,上下两条
+  // 互不相干的回答会变成连续消息,那样会被错误归成一组、用切换器藏起一条。
   const answerGroups = computed<AnswerGroup[]>(() => {
     const groups: AnswerGroup[] = [];
     let current: AnswerGroup | null = null;
     props.messages.forEach((msg, index) => {
       if (msg.type === 'ai') {
-        if (!current) {
+        // 组内除首条外都必须带「重新生成」标记,否则另起一组
+        if (!current || !msg.isRegeneratedAnswer) {
           current = { indices: [] };
           groups.push(current);
         }
@@ -247,25 +250,21 @@
     return Math.min(Math.max(val, 0), groupLen - 1);
   };
 
-  // 末尾回答组内是否存在正在生成(isLoading)的消息
+  // 末尾回答组内是否存在正在生成(isLoading)的消息(按回答组判定,与分组规则保持一致)
   const lastAnswerGroupHasLoading = computed(() => {
     const msgs = props.messages;
     if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
-    let start = msgs.length - 1;
-    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-    for (let i = start; i < msgs.length; i++) {
-      if (msgs[i].isLoading) return true;
-    }
-    return false;
+    const lastMeta = aiGroupMeta.value.get(msgs.length - 1);
+    if (!lastMeta) return false;
+    const group = answerGroups.value.find((g) => getMessageKey(msgs[g.indices[0]]) === lastMeta.groupKey);
+    return !!group && group.indices.some((i) => msgs[i].isLoading);
   });
 
-  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息
+  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息所属的回答组
   watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
     if (loading && !wasLoading) {
-      const msgs = props.messages;
-      let start = msgs.length - 1;
-      while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-      delete activeAnswerOffset[getMessageKey(msgs[start])];
+      const lastMeta = aiGroupMeta.value.get(props.messages.length - 1);
+      if (lastMeta) delete activeAnswerOffset[lastMeta.groupKey];
     }
   });
 
@@ -318,14 +317,14 @@
     if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
   };
 
-  // 该消息是否属于“末尾连续 AI 组”:重新生成按钮只对最新一轮回答(含折叠组内任意一条)显示
+  // 该消息是否属于末尾回答组:重新生成按钮只对最新一轮回答(含回答组内任意一条)显示
   const isInLastAnswerGroup = (index: number): boolean => {
     const msgs = props.messages;
     if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
     if (msgs[index]?.type !== 'ai') return false;
-    let start = msgs.length - 1;
-    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-    return index >= start;
+    const meta = aiGroupMeta.value.get(index);
+    const lastMeta = aiGroupMeta.value.get(msgs.length - 1);
+    return !!meta && !!lastMeta && meta.groupKey === lastMeta.groupKey;
   };
 
   const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;

+ 7 - 0
src/views/ventAI/manageAssistent/hooks/useChatApproval.ts

@@ -20,6 +20,8 @@ export interface ChatApprovalDeps {
   refreshUsage: () => void;
   resetTodos: () => void;
   setCurrentSessionId: (sessionId: string) => void;
+  /** 补齐消息主键:续跑完成后的回答同样依赖它才有删除入口,同时把这一轮被中断的前半段挂到消息上 */
+  hydrateMessageIds: (sessionId: string, msgs: Message[]) => Promise<void>;
 }
 
 export const useChatApproval = (deps: ChatApprovalDeps) => {
@@ -34,6 +36,7 @@ export const useChatApproval = (deps: ChatApprovalDeps) => {
     refreshUsage,
     resetTodos,
     setCurrentSessionId,
+    hydrateMessageIds,
   } = deps;
   // 按任务维度的状态存储
   const taskPendingApprovals = ref<Map<string, { sessionId: string; threadId: string }>>(new Map());
@@ -140,6 +143,10 @@ export const useChatApproval = (deps: ChatApprovalDeps) => {
       if (result.session_id) {
         setCurrentSessionId(result.session_id);
       }
+
+      // 续跑完成,这一轮的回答刚由后端持久化:补主键(删除按钮依赖它),
+      // 并把本次打断落库的前半段挂到这条消息上(删除时一起删,详见 hydrateMessageIds)
+      await hydrateMessageIds(sessionId, task.messages);
     } catch (error) {
       if ((error as Error)?.name === 'AbortError') {
         lastMsg.isLoading = false;

+ 54 - 33
src/views/ventAI/manageAssistent/hooks/useChatSend.ts

@@ -4,7 +4,7 @@ import { message } from 'ant-design-vue';
 import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } from '/@/views/ventAI/manageAssistent/api';
 import type { HistoryMessage } from '/@/views/ventAI/manageAssistent/api';
 import type { AttachedFile, Message, SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
-import { getCssUrl, getUserInputText, normalizeLineBreaks, stripInjectedRefs } from '/@/views/ventAI/manageAssistent/lib/index';
+import { getCssUrl, getUserInputText, stripInjectedRefs } from '/@/views/ventAI/manageAssistent/lib/index';
 
 /**
  * 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
@@ -127,6 +127,10 @@ export const useChatSend = (deps: ChatSendDeps) => {
     const originTask = getTaskList().find((t) => t.id === originTaskId);
     if (!originTask) return;
     const now = dayjs();
+    // 追加时前一条已经是回答 → 这是「重新生成」产生的同一次提问的又一个回答(正常发送会先追加
+    // 用户消息、编辑重发会先截断再追加用户消息,都到不了这里)。打标供回答分组使用,
+    // 分组只认这个标记,不再按「连续 AI 消息」推断(见 Message.isRegeneratedAnswer 的说明)
+    const isRegeneratedAnswer = originTask.messages.length > 0 && originTask.messages[originTask.messages.length - 1].type === 'ai';
     const newMsg: Message = {
       type: 'ai',
       content: '',
@@ -135,6 +139,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
       isLoading: true,
       thinkingSteps: [],
       generateStartTime: Date.now(),
+      isRegeneratedAnswer,
     };
     originTask.messages.push(newMsg);
     // 从数组里读回这一条:任务状态是 reactive 的,push 进去的原始对象由代理包裹,
@@ -281,7 +286,9 @@ export const useChatSend = (deps: ChatSendDeps) => {
   //     就是同一串问答,正文差异不影响一一对应关系。
   //
   // 两步都跳过 localOnly 的消息(停止生成留下的占位气泡、发送失败的报错气泡):后端没有对应
-  // 记录,留在序列里会把后面的消息顶偏一位,让原本配得上的也整批配不上。
+  // 记录,留在序列里会把后面的消息顶偏一位,让原本配得上的也整批配不上。被中断拆开的那一轮
+  // (见 lib/turnRows.ts)在 transformHistoryToMessages 里已合成一条记录,这里只需处理主键升级:
+  // 消息若还拿着「前半段」的主键,换成整轮回答那一行的,前半段转进 absorbedRowIds 备删除。
   //
   // 早期实现要么按条数严格相等整批回填(长度差为负时会整体放弃),要么用长度差做尾部对齐
   // (每条未回填的消息都拿到前一条的 id,删除会删掉用户根本没想删的那一行),都已被这两种取代。
@@ -298,7 +305,18 @@ export const useChatSend = (deps: ChatSendDeps) => {
       const normalize = (text: string) => (text || '').replace(/\s+/g, ' ').trim();
       const keyOf = (type: Message['type'], text: string) => `${type}::${normalize(text)}`;
 
+      // 被并入某条回答的前半段(计划/澄清段)的主键按「归到哪条回答」记账,命中时一并挂到消息上
+      const assignAbsorbedIds = (msg: Message | undefined, ids: number[]) => {
+        if (!msg || !ids.length) return;
+        const merged = [...(msg.absorbedRowIds || [])];
+        for (const id of ids) {
+          if (!merged.includes(id)) merged.push(id);
+        }
+        if (merged.length) msg.absorbedRowIds = merged;
+      };
+
       // 后端记录按 key 分桶,桶内保持先后顺序(同文本出现多次时按顺序对应)
+      const absorbedIdsById = new Map<number, number[]>();
       const pool = new Map<string, number[]>();
       for (const item of history) {
         if (item.id == null) continue;
@@ -306,12 +324,15 @@ export const useChatSend = (deps: ChatSendDeps) => {
         const bucket = pool.get(key);
         if (bucket) bucket.push(item.id);
         else pool.set(key, [item.id]);
+        if (item.absorbedRowIds?.length) absorbedIdsById.set(item.id, item.absorbedRowIds);
       }
 
-      // 已发出去的主键:消息上本来就有的(历史加载回填的)也算,同一条后端记录不能落到两条前端消息上
+      // 已发出去的主键:消息上本来就有的(历史加载回填的)与已并入的前半段都算,
+      // 同一条后端记录不能既当消息主键又被领走两次
       const assigned = new Set<number>();
       for (const msg of msgs) {
         if (msg.id != null) assigned.add(msg.id);
+        for (const absorbedId of msg.absorbedRowIds || []) assigned.add(absorbedId);
       }
 
       for (const msg of msgs) {
@@ -328,6 +349,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
         const id = bucket.shift() as number;
         msg.id = id;
         assigned.add(id);
+        assignAbsorbedIds(msg, absorbedIdsById.get(id) || []);
       }
 
       // 位置配对同样剔除 localOnly:它们会把后面的消息整体顶偏一位,导致本可配上的也配不上
@@ -335,38 +357,27 @@ export const useChatSend = (deps: ChatSendDeps) => {
       const sameShape = history.length === pairable.length && history.every((h, i) => h.type === pairable[i].type);
       if (!sameShape) return;
       for (let i = 0; i < pairable.length; i++) {
-        const id = history[i].id;
-        if (pairable[i].id != null || id == null || assigned.has(id)) continue;
-        pairable[i].id = id;
+        const src = history[i];
+        const msg = pairable[i];
+        // 主键升级:这条消息此前拿到的是被中断那一轮的前半段(先落库时按位置配上的),现在整轮回答
+        // 也落库、两段已合成一条记录 —— 主键换成整轮的,旧主键转进 absorbedRowIds(删除时一起删)
+        if (msg.id != null && src.absorbedRowIds?.includes(msg.id) && src.id != null && !assigned.has(src.id)) {
+          msg.id = src.id;
+          assigned.add(src.id);
+          assignAbsorbedIds(msg, src.absorbedRowIds);
+          continue;
+        }
+        if (msg.id == null && src.id != null && !assigned.has(src.id)) {
+          msg.id = src.id;
+          assigned.add(src.id);
+        }
+        assignAbsorbedIds(msg, src.absorbedRowIds || []);
       }
     } catch (e) {
       console.error('补齐消息 id 失败:', e);
     }
   };
 
-  // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)。
-  // sessionId 同样由调用方锚定传入:若这里读「当前会话」,重新生成期间切走就会去别的会话里
-  // 按内容匹配并删除 user 记录,可能删掉一条与本次操作无关的正常消息。
-  const removeDuplicateRegenerateUserMessage = async (sessionId: string, expectedContent: string) => {
-    if (!sessionId) return;
-    try {
-      const res = await getDetail(sessionId);
-      const raw = res?.messages;
-      if (!Array.isArray(raw)) return;
-
-      // 保留第一条 user(原始提问),删除其余与重发内容一致的重复 user,避免多次重新生成残留
-      const userMsgs = raw.filter((m) => m?.role === 'user' || m?.type === 'user_message');
-      const duplicates = userMsgs.filter(
-        (m, idx) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
-      );
-      for (const dup of duplicates) {
-        await deleteMessage(dup.id);
-      }
-    } catch (e) {
-      console.error('删除重发产生的重复用户消息失败:', e);
-    }
-  };
-
   // 重新生成最后一条回答
   const handleRegenerate = async () => {
     const task = getTaskList().find((t) => t.id === getCurrentTaskId());
@@ -395,10 +406,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
     const attachedFile = userMsg.attachedFile;
 
     // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成。
-    // 以下三步都在 await 之后,一律显式锚定发起任务:用「当前任务/当前会话」会在切走后作用于别的会话
+    // 重发时后端会把同一句提问再收一遍、为它再写一条用户记录 —— 这条记录**不删除**:
+    // 它是「同一次提问的又一个回答」唯一可持久的依据,留着(加载时会被识别为重复提问,
+    // 不渲染成气泡,见 transformHistoryToMessages),刷新后重新生成的回答才能依旧折叠。
+    // 前端本次会话内的折叠则靠 streamAiAnswer 给新回答打的 isRegeneratedAnswer 标记。
+    // 以下两步都在 await 之后,一律显式锚定发起任务:用「当前任务/当前会话」会在切走后作用于别的会话
     await streamAiAnswer(userInput, attachedFile, task.id);
-    // 删除重发产生的重复用户消息,形成「一次提问、多次回答」
-    await removeDuplicateRegenerateUserMessage(task.sessionId, userInput);
     // 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
     await hydrateMessageIds(task.sessionId, task.messages);
   };
@@ -432,6 +445,15 @@ export const useChatSend = (deps: ChatSendDeps) => {
       } catch (e) {
         console.error('删除消息失败:', e);
       }
+      // 这一轮在被中断时拆成了两段落库,被并入的其它行也属于这条消息,一并删掉,
+      // 否则截断后残留一条孤立的前段
+      for (const absorbedId of m.absorbedRowIds ?? []) {
+        try {
+          await deleteMessage(absorbedId);
+        } catch (e) {
+          console.error('删除被中断段落失败:', e);
+        }
+      }
     }
     // 本地截断到编辑位置(原地截断,始终操作同一个任务的消息数组)
     msgs.splice(index);
@@ -461,7 +483,6 @@ export const useChatSend = (deps: ChatSendDeps) => {
     sendWithAttachment,
     stripInjectedRefs,
     hydrateMessageIds,
-    removeDuplicateRegenerateUserMessage,
     handleRegenerate,
     handleEditResend,
   };

+ 58 - 2
src/views/ventAI/manageAssistent/hooks/useChatTasks.ts

@@ -4,7 +4,7 @@ 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';
+import { normalizeLineBreaks, isSplitTurnPair } from '/@/views/ventAI/manageAssistent/lib/index';
 
 /**
  * 会话(任务)状态与列表:taskList / currentTaskId / 当前会话消息,以及列表拉取、置顶、标题、
@@ -160,11 +160,37 @@ export const useChatTasks = () => {
   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 });
@@ -182,6 +208,15 @@ export const useChatTasks = () => {
           // 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 字段
@@ -198,7 +233,28 @@ export const useChatTasks = () => {
       }
     }
 
-    return messages;
+    // 同一轮被中断拆成两段的助手行合成一条(判定与依据见 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,

+ 1 - 0
src/views/ventAI/manageAssistent/lib/index.ts

@@ -9,3 +9,4 @@ export * from './messageKey';
 export * from './clipboard';
 export * from './dxf';
 export * from './userInput';
+export * from './turnRows';

+ 47 - 0
src/views/ventAI/manageAssistent/lib/turnRows.ts

@@ -0,0 +1,47 @@
+// lib/turnRows.ts:识别「同一轮问答被中断拆成两段」的后端记录。
+//
+// 计划模式/审批(ask_user)下,一次追问会落两行助手记录:被打断时先落一行(计划或澄清前的前半段),
+// 用户批准(或补充回答)后再落一行(执行结果)。两行合起来才是用户视角的一次回答 —— 而历史接口把它们
+// 原样返回,前端不做归并就会显示成「一次提问、两个回答」,回答切换器上多出一个其实是计划前半段的项。
+//
+// 判定全部基于结构,不依赖模型措辞(实测过同一轮的正文开头写法各异:「【计划模式 · 待审批】」、
+// 「**执行计划(提交审批)**」、「我先做一点环境探查…」,靠正文特征必然漏判):
+//
+//   1) 前一行是提问:这一对是某轮问答的头两条,而不是没有问答的连排(定时任务通知就是连排);
+//   2) 两行助手行主键相邻(后 = 前 + 1):同一轮的两段是背靠背写的;「重新生成」产生的多条回答之间
+//      必然夹着后端为同一句提问再写的那条用户记录(内容与上一句提问相同,前端不再删除它,
+//      见 useChatSend 中重新生成那一段的说明),主键不可能相邻 —— 这正是把「中断两段」和
+//      「另一次回答」区分开的地方;
+//   3) 后一行不是调度器写的一轮通知:通知是定时任务单独跑的一轮,只是恰好紧跟在问答后面落库
+//      (实测 task-7 的 654/655 就是这样,中间还夹着 32 分钟)。
+//
+// 判不出来就不并(维持两条消息的旧表现):宁可少并,不能把两次不同的回答粘成一条 ——
+// 粘错会让删除一次删掉多于一条记录。更稳的做法是后端给这类记录加标记(例如中断段落库时带上
+// type='interrupted'),前端即可改成确定性判定,无需依赖主键相邻这一条。
+import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+
+/** 调度器写入的定时任务通知(模板文案,后端生成,不随模型措辞变化) */
+export const isScheduledNoticeContent = (content: string): boolean => {
+  const text = (content || '').trimStart();
+  return text.startsWith('⏰') || text.includes('定时任务完成通知');
+};
+
+/**
+ * messages 中下标 index 与 index+1 是否是同一轮被中断拆成的两段。
+ * 入参用前端消息序列(transformHistoryToMessages 的产物,type 已归一为 'user' / 'ai')。
+ */
+export const isSplitTurnPair = (messages: Message[], index: number): boolean => {
+  const first = messages[index];
+  const second = messages[index + 1];
+  if (!first || !second) return false;
+  if (first.type !== 'ai' || second.type !== 'ai') return false;
+  if (first.id == null || second.id == null) return false;
+  // 背靠背落库 → 主键相邻(这条同时排除了「重新生成的另一个回答」,见文件头说明)
+  if (Number(second.id) !== Number(first.id) + 1) return false;
+  // 这一对必须是某轮问答的头两条
+  const prev = messages[index - 1];
+  if (!prev || prev.type !== 'user') return false;
+  // 后一段不能是定时任务通知(它自成一"轮")
+  if (isScheduledNoticeContent(second.content)) return false;
+  return true;
+};

+ 10 - 0
src/views/ventAI/manageAssistent/types/chat.ts

@@ -74,6 +74,16 @@ export interface Message {
   // 仅前端存在的消息:服务端没有对应记录(停止生成留下的「已停止生成。」占位、发送失败的错误气泡)。
   // 回填主键时按形状配对必须跳过它,否则多出的一条会把位置顶偏,整批消息配不上或配错行。
   localOnly?: boolean;
+  // 被中断拆开的那一轮(或重新生成时的重复提问记录)里,除本条之外还落在后端的记录主键。
+  // 一轮问答在后端可能是多条记录(中断的前段 + 回答段;重新生成时后端为同一句提问再写的用户记录),
+  // 前端合成一条消息(见 transformHistoryToMessages)。删除这条消息时要连这些行一起删,
+  // 只删回答行的话,刷新后前段/重复提问会以一条孤立消息的形式重新出现。
+  absorbedRowIds?: number[];
+  // 「同一次提问的又一个回答」:来源有两个 —— 前端重新生成追加回答时打标(本次会话内),
+  // 或历史加载时识别到「内容与上一句提问相同的重复提问记录」(刷新后依然成立)。
+  // 回答分组(切换器)只认这个标记,不再按「连续 AI 消息」推断 —— 删掉中间的提问后,
+  // 上下两条互不相干的回答会变成连续消息,按相邻推断会把它们错误地归成一组、用切换器藏起一条。
+  isRegeneratedAnswer?: boolean;
 }
 
 export interface Task {