|
@@ -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 审查)→ 处理
|
|
@@ -85,6 +85,41 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
|
|
|
|
|
const getSessionRefIconUrl = () => getCssUrl('--img-chat-popup-session-icon');
|
|
const getSessionRefIconUrl = () => getCssUrl('--img-chat-popup-session-icon');
|
|
|
|
|
|
|
|
|
|
+ // 把「刚开出来的空白任务」迁移到后端会话:写 sessionId、任务 id 换成会话 id、AbortController 与
|
|
|
|
|
+ // 审批状态跟着搬、列表去重,并把该会话标记为已加载。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 正常结束与「停止生成」共用这一份:停止生成时后端会话同样已经建好(事件里带着 session_id),
|
|
|
|
|
+ // 不迁移的话任务拿不到 sessionId,下一次提问会再开一个新会话 —— 前面那条提问留在旧会话里,
|
|
|
|
|
+ // 上下文就断了(现象:侧边栏多出一个只有提问、没有回答的会话)。
|
|
|
|
|
+ //
|
|
|
|
|
+ // fallbackName 只在没有拿到后端标题时用来命名(传空串表示不命名)。
|
|
|
|
|
+ const migrateTaskToSession = (originTaskId: string, sessionId: string, fallbackName: string) => {
|
|
|
|
|
+ const task = getTaskList().find((t) => t.id === originTaskId);
|
|
|
|
|
+ if (!task || task.sessionId || !sessionId) return;
|
|
|
|
|
+ task.sessionId = sessionId;
|
|
|
|
|
+ // 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
|
|
|
|
|
+ if (task.pendingSessionId !== sessionId && fallbackName) {
|
|
|
|
|
+ task.name = fallbackName.substring(0, 20) + (fallbackName.length > 20 ? '...' : '');
|
|
|
|
|
+ }
|
|
|
|
|
+ task.pendingSessionId = undefined;
|
|
|
|
|
+ if (task.id !== sessionId) {
|
|
|
|
|
+ // 将 AbortController 从旧 ID 迁移到新 ID
|
|
|
|
|
+ const controller = taskAbortControllers.get(originTaskId);
|
|
|
|
|
+ if (controller) {
|
|
|
|
|
+ setTaskAbortController(originTaskId, null);
|
|
|
|
|
+ setTaskAbortController(sessionId, controller);
|
|
|
|
|
+ }
|
|
|
|
|
+ setTaskList(getTaskList().filter((t) => t.id !== sessionId));
|
|
|
|
|
+ task.id = sessionId;
|
|
|
|
|
+ historyLoadedTasks.add(sessionId);
|
|
|
|
|
+ // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
|
|
|
|
|
+ migrateTaskApproval(originTaskId, sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (getCurrentTaskId() === originTaskId) {
|
|
|
|
|
+ setCurrentTaskId(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
// targetTaskId:回答写进哪个任务。默认当前任务;重新生成与编辑重发是在若干次 await 之后才调到这里,
|
|
// targetTaskId:回答写进哪个任务。默认当前任务;重新生成与编辑重发是在若干次 await 之后才调到这里,
|
|
|
// 必须显式传入发起任务,否则请求期间切走后新回答会落到别的会话上。
|
|
// 必须显式传入发起任务,否则请求期间切走后新回答会落到别的会话上。
|
|
|
const streamAiAnswer = async (userInput: string, file?: AttachedFile, targetTaskId?: string) => {
|
|
const streamAiAnswer = async (userInput: string, file?: AttachedFile, targetTaskId?: string) => {
|
|
@@ -92,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: '',
|
|
@@ -100,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 进去的原始对象由代理包裹,
|
|
@@ -114,7 +154,13 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
const controller = new AbortController();
|
|
const controller = new AbortController();
|
|
|
setTaskAbortController(originTaskId, controller);
|
|
setTaskAbortController(originTaskId, controller);
|
|
|
|
|
|
|
|
|
|
+ // 服务端每条事件都带 session_id(连第一条 agent_start 都有)。正常结束时下面用返回值里的,
|
|
|
|
|
+ // 但用户中途「停止生成」时流是被 abort 的、拿不到返回值,先把事件里出现的 id 记下来,
|
|
|
|
|
+ // 中断后才能把任务锚定到后端已建好的这个会话上(否则下一次提问会另起会话,上下文断掉)。
|
|
|
|
|
+ let eventSessionId = '';
|
|
|
|
|
+
|
|
|
const onChunk = (data: SseEvent) => {
|
|
const onChunk = (data: SseEvent) => {
|
|
|
|
|
+ if (!eventSessionId && data.session_id) eventSessionId = data.session_id;
|
|
|
handleStreamEvent(data, aiMsg, originTaskId);
|
|
handleStreamEvent(data, aiMsg, originTaskId);
|
|
|
if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
|
|
if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
|
|
|
};
|
|
};
|
|
@@ -142,34 +188,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
onChunk
|
|
onChunk
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
- if (result.session_id) {
|
|
|
|
|
- const originTask = getTaskList().find((t) => t.id === originTaskId);
|
|
|
|
|
- if (originTask && !originTask.sessionId) {
|
|
|
|
|
- originTask.sessionId = result.session_id;
|
|
|
|
|
- // 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
|
|
|
|
|
- if (originTask.pendingSessionId !== result.session_id) {
|
|
|
|
|
- const taskName = userInput || (file ? `文件:${file.name}` : '');
|
|
|
|
|
- originTask.name = taskName.substring(0, 20) + (taskName.length > 20 ? '...' : '');
|
|
|
|
|
- }
|
|
|
|
|
- originTask.pendingSessionId = undefined;
|
|
|
|
|
- if (originTask.id !== result.session_id) {
|
|
|
|
|
- // 将 AbortController 从旧 ID 迁移到新 ID
|
|
|
|
|
- const controller = taskAbortControllers.get(originTaskId);
|
|
|
|
|
- if (controller) {
|
|
|
|
|
- setTaskAbortController(originTaskId, null);
|
|
|
|
|
- setTaskAbortController(result.session_id, controller);
|
|
|
|
|
- }
|
|
|
|
|
- setTaskList(getTaskList().filter((t) => t.id !== result.session_id));
|
|
|
|
|
- originTask.id = result.session_id;
|
|
|
|
|
- historyLoadedTasks.add(result.session_id);
|
|
|
|
|
- // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
|
|
|
|
|
- migrateTaskApproval(originTaskId, result.session_id);
|
|
|
|
|
- }
|
|
|
|
|
- if (getCurrentTaskId() === originTaskId) {
|
|
|
|
|
- setCurrentTaskId(result.session_id);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 结果里的 session_id 与事件里捕获的是同一个,前者缺失时用后者兜底
|
|
|
|
|
+ migrateTaskToSession(originTaskId, result.session_id || eventSessionId, userInput || (file ? `文件:${file.name}` : ''));
|
|
|
|
|
+
|
|
|
|
|
+ // 本轮问答刚由后端持久化,补一次数据库主键:删除按钮依赖它,不补的话刚发出的消息没有删除
|
|
|
|
|
+ // 入口(旧实现只在重新生成/编辑重发里补,正常发送出来的消息要刷新页面才补上)。
|
|
|
|
|
+ await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
// 用户主动停止生成
|
|
// 用户主动停止生成
|
|
|
if ((error as Error)?.name === 'AbortError') {
|
|
if ((error as Error)?.name === 'AbortError') {
|
|
@@ -180,6 +204,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
if (!aiMsg.content) {
|
|
if (!aiMsg.content) {
|
|
|
aiMsg.content = '已停止生成。';
|
|
aiMsg.content = '已停止生成。';
|
|
|
}
|
|
}
|
|
|
|
|
+ // 这一轮被中断,后端只落了用户消息(回答在正常结束时才落库),本地这条气泡没有对应记录
|
|
|
|
|
+ aiMsg.localOnly = true;
|
|
|
|
|
+ // 中断同样要把任务锚定到后端已建好的会话上,否则下一次提问会另起一个会话、上下文断掉
|
|
|
|
|
+ migrateTaskToSession(originTaskId, eventSessionId, userInput || (file ? `文件:${file.name}` : ''));
|
|
|
|
|
+ // 停下的这一轮里用户消息已经落库,补主键以便删除/编辑;占位气泡是 localOnly,配对时会被跳过
|
|
|
|
|
+ await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
throw error;
|
|
throw error;
|
|
@@ -244,9 +274,24 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
// 「当前会话 / 当前任务」,请求期间用户切走就会把别的会话的消息当成目标,回填出的 id 会让
|
|
// 「当前会话 / 当前任务」,请求期间用户切走就会把别的会话的消息当成目标,回填出的 id 会让
|
|
|
// 后续的删除打到另一条会话上。
|
|
// 后续的删除打到另一条会话上。
|
|
|
//
|
|
//
|
|
|
- // 两边条数必须严格一致才回填。早期实现用「长度差」做尾部对齐,一旦本地存在仅前端的消息
|
|
|
|
|
- // (例如发送失败的报错气泡,后端没有对应行),长度差为负,之后每条未回填的消息都会拿到
|
|
|
|
|
- // 前一条的 id —— 删除按钮据此调 DELETE /messages/{id},删掉的是用户根本没想删的那一行。
|
|
|
|
|
|
|
+ // 两步配对,原则都是「宁可少补(那一条暂时没有删除按钮),不能补错(删除会删到别的行)」:
|
|
|
|
|
+ //
|
|
|
|
|
+ // 一、按「角色 + 文本」精确配对。条数不一致(本地多出仅前端的报错气泡,或后端还留着重发
|
|
|
|
|
+ // 产生的重复行)也能把对得上的逐条填上;只认文本完全一致的记录,同文本多条按先后顺序
|
|
|
|
|
+ // 一一消费。
|
|
|
|
|
+ // 二、剩下的没配上,且两边条数一致、角色顺序完全一致时,按位置补。
|
|
|
|
|
+ // 文本对不上是常态而不是异常:子智能体的输出在流式时被路由进思考卡片,主回答才进气泡,
|
|
|
|
|
+ // 而后端落库时把两段拼成同一条记录 —— 气泡文本只是整条记录的后半截,按文本永远配不上,
|
|
|
|
|
+ // 表现为「刚发出的 AI 消息没有删除按钮,刷新页面才出现」。条数与角色序列一致时,两条序列
|
|
|
|
|
+ // 就是同一串问答,正文差异不影响一一对应关系。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 两步都跳过 localOnly 的消息(停止生成留下的占位气泡、发送失败的报错气泡):后端没有对应
|
|
|
|
|
+ // 记录,留在序列里会把后面的消息顶偏一位,让原本配得上的也整批配不上。被中断拆开的那一轮
|
|
|
|
|
+ // (见 lib/turnRows.ts)在 transformHistoryToMessages 里已合成一条记录,这里只需处理主键升级:
|
|
|
|
|
+ // 消息若还拿着「前半段」的主键,换成整轮回答那一行的,前半段转进 absorbedRowIds 备删除。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 早期实现要么按条数严格相等整批回填(长度差为负时会整体放弃),要么用长度差做尾部对齐
|
|
|
|
|
+ // (每条未回填的消息都拿到前一条的 id,删除会删掉用户根本没想删的那一行),都已被这两种取代。
|
|
|
const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
|
|
const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
|
|
|
if (!sessionId || !msgs.length) return;
|
|
if (!sessionId || !msgs.length) return;
|
|
|
try {
|
|
try {
|
|
@@ -254,38 +299,82 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
// getDetail 已按真实契约标注 { messages, ... },不再猜 messages / data / 裸数组三种外壳
|
|
// getDetail 已按真实契约标注 { messages, ... },不再猜 messages / data / 裸数组三种外壳
|
|
|
const raw = res?.messages;
|
|
const raw = res?.messages;
|
|
|
if (!Array.isArray(raw)) return;
|
|
if (!Array.isArray(raw)) return;
|
|
|
- // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
|
|
|
|
|
|
|
+ // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录影响配对
|
|
|
const history = transformHistoryToMessages(raw);
|
|
const history = transformHistoryToMessages(raw);
|
|
|
- const ids = history.map((m) => m.id).filter((id): id is number => id != null);
|
|
|
|
|
- if (ids.length !== msgs.length) return;
|
|
|
|
|
- for (let i = 0; i < msgs.length; i++) {
|
|
|
|
|
- if (msgs[i].id == null) msgs[i].id = ids[i];
|
|
|
|
|
|
|
+
|
|
|
|
|
+ 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;
|
|
|
|
|
+ const key = keyOf(item.type, item.content);
|
|
|
|
|
+ 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);
|
|
|
}
|
|
}
|
|
|
- } 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;
|
|
|
|
|
|
|
+ // 已发出去的主键:消息上本来就有的(历史加载回填的)与已并入的前半段都算,
|
|
|
|
|
+ // 同一条后端记录不能既当消息主键又被领走两次
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ // localOnly(停止生成的占位气泡、发送失败的报错气泡)后端没有记录,不参与配对
|
|
|
|
|
+ if (msg.id != null || msg.localOnly) continue;
|
|
|
|
|
+ // 用户消息要拿「真正发出去的那段文本」比对:气泡里的 content 含注入的引用图标 HTML,
|
|
|
|
|
+ // 后端存的是纯文本(与编辑重发、重新生成取原始输入用的是同一个函数)
|
|
|
|
|
+ const text = msg.type === 'user' ? getUserInputText(msg) : msg.content;
|
|
|
|
|
+ const bucket = pool.get(keyOf(msg.type, text));
|
|
|
|
|
+ if (!bucket) continue;
|
|
|
|
|
+ // 桶内已被占用的主键(同文本的另一条已经被前面的消息领走)直接跳过
|
|
|
|
|
+ while (bucket.length && assigned.has(bucket[0])) bucket.shift();
|
|
|
|
|
+ if (!bucket.length) continue;
|
|
|
|
|
+ const id = bucket.shift() as number;
|
|
|
|
|
+ msg.id = id;
|
|
|
|
|
+ assigned.add(id);
|
|
|
|
|
+ assignAbsorbedIds(msg, absorbedIdsById.get(id) || []);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 保留第一条 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);
|
|
|
|
|
|
|
+ // 位置配对同样剔除 localOnly:它们会把后面的消息整体顶偏一位,导致本可配上的也配不上
|
|
|
|
|
+ const pairable = msgs.filter((m) => !m.localOnly);
|
|
|
|
|
+ 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 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('删除重发产生的重复用户消息失败:', e);
|
|
|
|
|
|
|
+ console.error('补齐消息 id 失败:', e);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -317,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);
|
|
|
};
|
|
};
|
|
@@ -354,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);
|
|
@@ -383,7 +483,6 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
sendWithAttachment,
|
|
sendWithAttachment,
|
|
|
stripInjectedRefs,
|
|
stripInjectedRefs,
|
|
|
hydrateMessageIds,
|
|
hydrateMessageIds,
|
|
|
- removeDuplicateRegenerateUserMessage,
|
|
|
|
|
handleRegenerate,
|
|
handleRegenerate,
|
|
|
handleEditResend,
|
|
handleEditResend,
|
|
|
};
|
|
};
|