|
|
@@ -85,6 +85,41 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
|
|
|
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 之后才调到这里,
|
|
|
// 必须显式传入发起任务,否则请求期间切走后新回答会落到别的会话上。
|
|
|
const streamAiAnswer = async (userInput: string, file?: AttachedFile, targetTaskId?: string) => {
|
|
|
@@ -114,7 +149,13 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
const controller = new AbortController();
|
|
|
setTaskAbortController(originTaskId, controller);
|
|
|
|
|
|
+ // 服务端每条事件都带 session_id(连第一条 agent_start 都有)。正常结束时下面用返回值里的,
|
|
|
+ // 但用户中途「停止生成」时流是被 abort 的、拿不到返回值,先把事件里出现的 id 记下来,
|
|
|
+ // 中断后才能把任务锚定到后端已建好的这个会话上(否则下一次提问会另起会话,上下文断掉)。
|
|
|
+ let eventSessionId = '';
|
|
|
+
|
|
|
const onChunk = (data: SseEvent) => {
|
|
|
+ if (!eventSessionId && data.session_id) eventSessionId = data.session_id;
|
|
|
handleStreamEvent(data, aiMsg, originTaskId);
|
|
|
if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
|
|
|
};
|
|
|
@@ -142,38 +183,11 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
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}` : ''));
|
|
|
|
|
|
// 本轮问答刚由后端持久化,补一次数据库主键:删除按钮依赖它,不补的话刚发出的消息没有删除
|
|
|
// 入口(旧实现只在重新生成/编辑重发里补,正常发送出来的消息要刷新页面才补上)。
|
|
|
- // 只能放在流正常结束之后——被中断的那一轮拿不到 session_id(见下面的 AbortError 分支)。
|
|
|
await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
|
|
|
} catch (error) {
|
|
|
// 用户主动停止生成
|
|
|
@@ -185,6 +199,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
if (!aiMsg.content) {
|
|
|
aiMsg.content = '已停止生成。';
|
|
|
}
|
|
|
+ // 这一轮被中断,后端只落了用户消息(回答在正常结束时才落库),本地这条气泡没有对应记录
|
|
|
+ aiMsg.localOnly = true;
|
|
|
+ // 中断同样要把任务锚定到后端已建好的会话上,否则下一次提问会另起一个会话、上下文断掉
|
|
|
+ migrateTaskToSession(originTaskId, eventSessionId, userInput || (file ? `文件:${file.name}` : ''));
|
|
|
+ // 停下的这一轮里用户消息已经落库,补主键以便删除/编辑;占位气泡是 localOnly,配对时会被跳过
|
|
|
+ await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
|
|
|
return;
|
|
|
}
|
|
|
throw error;
|
|
|
@@ -260,6 +280,9 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
// 表现为「刚发出的 AI 消息没有删除按钮,刷新页面才出现」。条数与角色序列一致时,两条序列
|
|
|
// 就是同一串问答,正文差异不影响一一对应关系。
|
|
|
//
|
|
|
+ // 两步都跳过 localOnly 的消息(停止生成留下的占位气泡、发送失败的报错气泡):后端没有对应
|
|
|
+ // 记录,留在序列里会把后面的消息顶偏一位,让原本配得上的也整批配不上。
|
|
|
+ //
|
|
|
// 早期实现要么按条数严格相等整批回填(长度差为负时会整体放弃),要么用长度差做尾部对齐
|
|
|
// (每条未回填的消息都拿到前一条的 id,删除会删掉用户根本没想删的那一行),都已被这两种取代。
|
|
|
const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
|
|
|
@@ -292,7 +315,8 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
}
|
|
|
|
|
|
for (const msg of msgs) {
|
|
|
- if (msg.id != null) continue;
|
|
|
+ // localOnly(停止生成的占位气泡、发送失败的报错气泡)后端没有记录,不参与配对
|
|
|
+ if (msg.id != null || msg.localOnly) continue;
|
|
|
// 用户消息要拿「真正发出去的那段文本」比对:气泡里的 content 含注入的引用图标 HTML,
|
|
|
// 后端存的是纯文本(与编辑重发、重新生成取原始输入用的是同一个函数)
|
|
|
const text = msg.type === 'user' ? getUserInputText(msg) : msg.content;
|
|
|
@@ -306,12 +330,14 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
assigned.add(id);
|
|
|
}
|
|
|
|
|
|
- const sameShape = history.length === msgs.length && history.every((h, i) => h.type === msgs[i].type);
|
|
|
+ // 位置配对同样剔除 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 < msgs.length; i++) {
|
|
|
+ for (let i = 0; i < pairable.length; i++) {
|
|
|
const id = history[i].id;
|
|
|
- if (msgs[i].id != null || id == null || assigned.has(id)) continue;
|
|
|
- msgs[i].id = id;
|
|
|
+ if (pairable[i].id != null || id == null || assigned.has(id)) continue;
|
|
|
+ pairable[i].id = id;
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error('补齐消息 id 失败:', e);
|