|
|
@@ -170,6 +170,11 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ // 本轮问答刚由后端持久化,补一次数据库主键:删除按钮依赖它,不补的话刚发出的消息没有删除
|
|
|
+ // 入口(旧实现只在重新生成/编辑重发里补,正常发送出来的消息要刷新页面才补上)。
|
|
|
+ // 只能放在流正常结束之后——被中断的那一轮拿不到 session_id(见下面的 AbortError 分支)。
|
|
|
+ await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
|
|
|
} catch (error) {
|
|
|
// 用户主动停止生成
|
|
|
if ((error as Error)?.name === 'AbortError') {
|
|
|
@@ -244,9 +249,19 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
// 「当前会话 / 当前任务」,请求期间用户切走就会把别的会话的消息当成目标,回填出的 id 会让
|
|
|
// 后续的删除打到另一条会话上。
|
|
|
//
|
|
|
- // 两边条数必须严格一致才回填。早期实现用「长度差」做尾部对齐,一旦本地存在仅前端的消息
|
|
|
- // (例如发送失败的报错气泡,后端没有对应行),长度差为负,之后每条未回填的消息都会拿到
|
|
|
- // 前一条的 id —— 删除按钮据此调 DELETE /messages/{id},删掉的是用户根本没想删的那一行。
|
|
|
+ // 两步配对,原则都是「宁可少补(那一条暂时没有删除按钮),不能补错(删除会删到别的行)」:
|
|
|
+ //
|
|
|
+ // 一、按「角色 + 文本」精确配对。条数不一致(本地多出仅前端的报错气泡,或后端还留着重发
|
|
|
+ // 产生的重复行)也能把对得上的逐条填上;只认文本完全一致的记录,同文本多条按先后顺序
|
|
|
+ // 一一消费。
|
|
|
+ // 二、剩下的没配上,且两边条数一致、角色顺序完全一致时,按位置补。
|
|
|
+ // 文本对不上是常态而不是异常:子智能体的输出在流式时被路由进思考卡片,主回答才进气泡,
|
|
|
+ // 而后端落库时把两段拼成同一条记录 —— 气泡文本只是整条记录的后半截,按文本永远配不上,
|
|
|
+ // 表现为「刚发出的 AI 消息没有删除按钮,刷新页面才出现」。条数与角色序列一致时,两条序列
|
|
|
+ // 就是同一串问答,正文差异不影响一一对应关系。
|
|
|
+ //
|
|
|
+ // 早期实现要么按条数严格相等整批回填(长度差为负时会整体放弃),要么用长度差做尾部对齐
|
|
|
+ // (每条未回填的消息都拿到前一条的 id,删除会删掉用户根本没想删的那一行),都已被这两种取代。
|
|
|
const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
|
|
|
if (!sessionId || !msgs.length) return;
|
|
|
try {
|
|
|
@@ -254,12 +269,49 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
// getDetail 已按真实契约标注 { messages, ... },不再猜 messages / data / 裸数组三种外壳
|
|
|
const raw = res?.messages;
|
|
|
if (!Array.isArray(raw)) return;
|
|
|
- // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
|
|
|
+ // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录影响配对
|
|
|
const history = transformHistoryToMessages(raw);
|
|
|
- const ids = history.map((m) => m.id).filter((id): id is number => id != null);
|
|
|
- if (ids.length !== msgs.length) return;
|
|
|
+
|
|
|
+ const normalize = (text: string) => (text || '').replace(/\s+/g, ' ').trim();
|
|
|
+ const keyOf = (type: Message['type'], text: string) => `${type}::${normalize(text)}`;
|
|
|
+
|
|
|
+ // 后端记录按 key 分桶,桶内保持先后顺序(同文本出现多次时按顺序对应)
|
|
|
+ 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]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 已发出去的主键:消息上本来就有的(历史加载回填的)也算,同一条后端记录不能落到两条前端消息上
|
|
|
+ const assigned = new Set<number>();
|
|
|
+ for (const msg of msgs) {
|
|
|
+ if (msg.id != null) assigned.add(msg.id);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const msg of msgs) {
|
|
|
+ if (msg.id != null) 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ const sameShape = history.length === msgs.length && history.every((h, i) => h.type === msgs[i].type);
|
|
|
+ if (!sameShape) return;
|
|
|
for (let i = 0; i < msgs.length; i++) {
|
|
|
- if (msgs[i].id == null) msgs[i].id = ids[i];
|
|
|
+ const id = history[i].id;
|
|
|
+ if (msgs[i].id != null || id == null || assigned.has(id)) continue;
|
|
|
+ msgs[i].id = id;
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error('补齐消息 id 失败:', e);
|