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