|
@@ -1,9 +1,9 @@
|
|
|
import { computed, nextTick } from 'vue';
|
|
import { computed, nextTick } from 'vue';
|
|
|
import dayjs from 'dayjs';
|
|
import dayjs from 'dayjs';
|
|
|
import { message } from 'ant-design-vue';
|
|
import { message } from 'ant-design-vue';
|
|
|
-import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } from '/@/views/ventAI/manageAssistent/api/client';
|
|
|
|
|
|
|
+import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } 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 { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
|
|
|
|
|
|
|
+import { getCssUrl, normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
|
|
* 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
|
|
@@ -17,13 +17,11 @@ export interface ChatSendDeps {
|
|
|
getCurrentTaskId: () => string;
|
|
getCurrentTaskId: () => string;
|
|
|
setCurrentTaskId: (taskId: string) => void;
|
|
setCurrentTaskId: (taskId: string) => void;
|
|
|
getMessages: () => Message[];
|
|
getMessages: () => Message[];
|
|
|
- setMessages: (messages: Message[]) => void;
|
|
|
|
|
setTaskList: (tasks: Task[]) => void;
|
|
setTaskList: (tasks: Task[]) => void;
|
|
|
- getCurrentSessionId: () => string;
|
|
|
|
|
getEditMode: () => string;
|
|
getEditMode: () => string;
|
|
|
getPendingApproval: () => unknown;
|
|
getPendingApproval: () => unknown;
|
|
|
getChatMessages: () => { forceScrollToBottom?: () => void } | undefined;
|
|
getChatMessages: () => { forceScrollToBottom?: () => void } | undefined;
|
|
|
- handleStreamEvent: (data: SseEvent, aiMsgIndex: number, taskId: string) => void;
|
|
|
|
|
|
|
+ handleStreamEvent: (data: SseEvent, aiMsg: Message, taskId: string) => void;
|
|
|
scrollToBottomThrottled: (force?: boolean) => void;
|
|
scrollToBottomThrottled: (force?: boolean) => void;
|
|
|
resetTodos: () => void;
|
|
resetTodos: () => void;
|
|
|
migrateTaskApproval: (fromTaskId: string, toTaskId: string) => void;
|
|
migrateTaskApproval: (fromTaskId: string, toTaskId: string) => void;
|
|
@@ -37,9 +35,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
getCurrentTaskId,
|
|
getCurrentTaskId,
|
|
|
setCurrentTaskId,
|
|
setCurrentTaskId,
|
|
|
getMessages,
|
|
getMessages,
|
|
|
- setMessages,
|
|
|
|
|
setTaskList,
|
|
setTaskList,
|
|
|
- getCurrentSessionId,
|
|
|
|
|
getEditMode,
|
|
getEditMode,
|
|
|
getPendingApproval,
|
|
getPendingApproval,
|
|
|
getChatMessages,
|
|
getChatMessages,
|
|
@@ -84,21 +80,18 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
taskAbortControllers.clear();
|
|
taskAbortControllers.clear();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const getSkillRefIconUrl = () => {
|
|
|
|
|
- const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-options-skill-icon').trim();
|
|
|
|
|
- return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ const getSkillRefIconUrl = () => getCssUrl('--img-chat-options-skill-icon');
|
|
|
|
|
|
|
|
- const getSessionRefIconUrl = () => {
|
|
|
|
|
- const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-popup-session-icon').trim();
|
|
|
|
|
- return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ const getSessionRefIconUrl = () => getCssUrl('--img-chat-popup-session-icon');
|
|
|
|
|
|
|
|
- const streamAiAnswer = async (userInput: string, file?: AttachedFile) => {
|
|
|
|
|
- const originTaskId = getCurrentTaskId();
|
|
|
|
|
|
|
+ // targetTaskId:回答写进哪个任务。默认当前任务;重新生成与编辑重发是在若干次 await 之后才调到这里,
|
|
|
|
|
+ // 必须显式传入发起任务,否则请求期间切走后新回答会落到别的会话上。
|
|
|
|
|
+ const streamAiAnswer = async (userInput: string, file?: AttachedFile, targetTaskId?: string) => {
|
|
|
|
|
+ const originTaskId = targetTaskId || getCurrentTaskId();
|
|
|
|
|
+ const originTask = getTaskList().find((t) => t.id === originTaskId);
|
|
|
|
|
+ if (!originTask) return;
|
|
|
const now = dayjs();
|
|
const now = dayjs();
|
|
|
- const aiMsgIndex = getMessages().length;
|
|
|
|
|
- const aiMsg: Message = {
|
|
|
|
|
|
|
+ const newMsg: Message = {
|
|
|
type: 'ai',
|
|
type: 'ai',
|
|
|
content: '',
|
|
content: '',
|
|
|
time: now.format('HH:mm'),
|
|
time: now.format('HH:mm'),
|
|
@@ -107,18 +100,21 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
thinkingSteps: [],
|
|
thinkingSteps: [],
|
|
|
generateStartTime: Date.now(),
|
|
generateStartTime: Date.now(),
|
|
|
};
|
|
};
|
|
|
- getMessages().push(aiMsg);
|
|
|
|
|
|
|
+ originTask.messages.push(newMsg);
|
|
|
|
|
+ // 从数组里读回这一条:任务状态是 reactive 的,push 进去的原始对象由代理包裹,
|
|
|
|
|
+ // 之后必须通过代理读写才会触发视图更新——直接改上面的 newMsg,界面会一直停在“正在输入”。
|
|
|
|
|
+ const aiMsg = originTask.messages[originTask.messages.length - 1];
|
|
|
|
|
|
|
|
- resetTodos();
|
|
|
|
|
|
|
+ // 进度条属于「正在看的任务」,不要为别的任务清空它
|
|
|
|
|
+ if (getCurrentTaskId() === originTaskId) resetTodos();
|
|
|
|
|
|
|
|
- const originTask = getTaskList().find((t) => t.id === originTaskId);
|
|
|
|
|
- if (originTask) originTask.isStreaming = true;
|
|
|
|
|
|
|
+ originTask.isStreaming = true;
|
|
|
|
|
|
|
|
const controller = new AbortController();
|
|
const controller = new AbortController();
|
|
|
setTaskAbortController(originTaskId, controller);
|
|
setTaskAbortController(originTaskId, controller);
|
|
|
|
|
|
|
|
const onChunk = (data: SseEvent) => {
|
|
const onChunk = (data: SseEvent) => {
|
|
|
- handleStreamEvent(data, aiMsgIndex, originTaskId);
|
|
|
|
|
|
|
+ handleStreamEvent(data, aiMsg, originTaskId);
|
|
|
if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
|
|
if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -128,7 +124,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
? await reviewPdfStream(
|
|
? await reviewPdfStream(
|
|
|
{
|
|
{
|
|
|
file: originalFile,
|
|
file: originalFile,
|
|
|
- session_id: getCurrentSessionId() || undefined,
|
|
|
|
|
|
|
+ session_id: originTask.sessionId || undefined,
|
|
|
message: userInput || undefined,
|
|
message: userInput || undefined,
|
|
|
mode: getEditMode(),
|
|
mode: getEditMode(),
|
|
|
signal: controller.signal,
|
|
signal: controller.signal,
|
|
@@ -138,7 +134,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
: await unifiedStream(
|
|
: await unifiedStream(
|
|
|
{
|
|
{
|
|
|
message: userInput,
|
|
message: userInput,
|
|
|
- session_id: getCurrentSessionId() || undefined,
|
|
|
|
|
|
|
+ session_id: originTask.sessionId || undefined,
|
|
|
mode: getEditMode(),
|
|
mode: getEditMode(),
|
|
|
signal: controller.signal,
|
|
signal: controller.signal,
|
|
|
},
|
|
},
|
|
@@ -176,17 +172,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
// 用户主动停止生成
|
|
// 用户主动停止生成
|
|
|
if ((error as Error)?.name === 'AbortError') {
|
|
if ((error as Error)?.name === 'AbortError') {
|
|
|
- const originTask = getTaskList().find((t) => t.id === originTaskId);
|
|
|
|
|
- // 单一数据源:任务自己的数组就是视图数组,直接取即可
|
|
|
|
|
- const arr = originTask?.messages;
|
|
|
|
|
- const aiMsg = arr?.[aiMsgIndex];
|
|
|
|
|
- if (aiMsg) {
|
|
|
|
|
- aiMsg.isLoading = false;
|
|
|
|
|
- // 后端未返回耗时,冻结前端伪计时
|
|
|
|
|
- aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
|
|
|
|
|
- if (!aiMsg.content) {
|
|
|
|
|
- aiMsg.content = '已停止生成。';
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 目标消息直接用发起流式时创建的那个对象:数组可能已被删除消息重排/变短,按下标取会指错
|
|
|
|
|
+ aiMsg.isLoading = false;
|
|
|
|
|
+ // 后端未返回耗时,冻结前端伪计时
|
|
|
|
|
+ aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
|
|
|
|
|
+ if (!aiMsg.content) {
|
|
|
|
|
+ aiMsg.content = '已停止生成。';
|
|
|
}
|
|
}
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
@@ -254,10 +245,16 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
.trim();
|
|
.trim();
|
|
|
|
|
|
|
|
// 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
|
|
// 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
|
|
|
- // 用 getDetail 返回的列表按尾部对齐回填,避免编辑重发/重新生成删除时后端残留重复记录。
|
|
|
|
|
- const hydrateMessageIds = async () => {
|
|
|
|
|
- const sessionId = getCurrentSessionId();
|
|
|
|
|
- if (!sessionId) return;
|
|
|
|
|
|
|
+ //
|
|
|
|
|
+ // sessionId 与 msgs 都由调用方在流程开始时锚定后传入:本函数内部有 await,如果这里再去读
|
|
|
|
|
+ // 「当前会话 / 当前任务」,请求期间用户切走就会把别的会话的消息当成目标,回填出的 id 会让
|
|
|
|
|
+ // 后续的删除打到另一条会话上。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 两边条数必须严格一致才回填。早期实现用「长度差」做尾部对齐,一旦本地存在仅前端的消息
|
|
|
|
|
+ // (例如发送失败的报错气泡,后端没有对应行),长度差为负,之后每条未回填的消息都会拿到
|
|
|
|
|
+ // 前一条的 id —— 删除按钮据此调 DELETE /messages/{id},删掉的是用户根本没想删的那一行。
|
|
|
|
|
+ const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
|
|
|
|
|
+ if (!sessionId || !msgs.length) return;
|
|
|
try {
|
|
try {
|
|
|
const res = await getDetail(sessionId);
|
|
const res = await getDetail(sessionId);
|
|
|
const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
|
|
const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
|
|
@@ -265,23 +262,19 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
// 用 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);
|
|
const ids = history.map((m) => m.id).filter((id): id is number => id != null);
|
|
|
- if (!ids.length) return;
|
|
|
|
|
- const msgs = getMessages();
|
|
|
|
|
- const offset = ids.length - msgs.length;
|
|
|
|
|
|
|
+ if (ids.length !== msgs.length) return;
|
|
|
for (let i = 0; i < msgs.length; i++) {
|
|
for (let i = 0; i < msgs.length; i++) {
|
|
|
- const dIdx = offset + i;
|
|
|
|
|
- if (dIdx >= 0 && dIdx < ids.length && msgs[i].id == null) {
|
|
|
|
|
- msgs[i].id = ids[dIdx];
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (msgs[i].id == null) msgs[i].id = ids[i];
|
|
|
}
|
|
}
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
console.error('补齐消息 id 失败:', e);
|
|
console.error('补齐消息 id 失败:', e);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)
|
|
|
|
|
- const removeDuplicateRegenerateUserMessage = async (expectedContent: string) => {
|
|
|
|
|
- const sessionId = getCurrentSessionId();
|
|
|
|
|
|
|
+ // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)。
|
|
|
|
|
+ // sessionId 同样由调用方锚定传入:若这里读「当前会话」,重新生成期间切走就会去别的会话里
|
|
|
|
|
+ // 按内容匹配并删除 user 记录,可能删掉一条与本次操作无关的正常消息。
|
|
|
|
|
+ const removeDuplicateRegenerateUserMessage = async (sessionId: string, expectedContent: string) => {
|
|
|
if (!sessionId) return;
|
|
if (!sessionId) return;
|
|
|
try {
|
|
try {
|
|
|
const res = await getDetail(sessionId);
|
|
const res = await getDetail(sessionId);
|
|
@@ -310,7 +303,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const msgs = getMessages();
|
|
|
|
|
|
|
+ const msgs = task.messages;
|
|
|
const lastAiIdx = msgs.length - 1;
|
|
const lastAiIdx = msgs.length - 1;
|
|
|
if (lastAiIdx < 0 || msgs[lastAiIdx].type !== 'ai') return;
|
|
if (lastAiIdx < 0 || msgs[lastAiIdx].type !== 'ai') return;
|
|
|
|
|
|
|
@@ -328,12 +321,13 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
|
|
const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
|
|
|
const attachedFile = userMsg.attachedFile;
|
|
const attachedFile = userMsg.attachedFile;
|
|
|
|
|
|
|
|
- // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成
|
|
|
|
|
- await streamAiAnswer(userInput, attachedFile);
|
|
|
|
|
|
|
+ // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成。
|
|
|
|
|
+ // 以下三步都在 await 之后,一律显式锚定发起任务:用「当前任务/当前会话」会在切走后作用于别的会话
|
|
|
|
|
+ await streamAiAnswer(userInput, attachedFile, task.id);
|
|
|
// 删除重发产生的重复用户消息,形成「一次提问、多次回答」
|
|
// 删除重发产生的重复用户消息,形成「一次提问、多次回答」
|
|
|
- await removeDuplicateRegenerateUserMessage(userInput);
|
|
|
|
|
|
|
+ await removeDuplicateRegenerateUserMessage(task.sessionId, userInput);
|
|
|
// 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
|
|
// 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
|
|
|
- await hydrateMessageIds();
|
|
|
|
|
|
|
+ await hydrateMessageIds(task.sessionId, task.messages);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// 编辑用户消息并重发(截断该消息及其后所有消息)
|
|
// 编辑用户消息并重发(截断该消息及其后所有消息)
|
|
@@ -345,7 +339,7 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const msgs = getMessages();
|
|
|
|
|
|
|
+ const msgs = task.messages;
|
|
|
if (index < 0 || index >= msgs.length || msgs[index].type !== 'user') return;
|
|
if (index < 0 || index >= msgs.length || msgs[index].type !== 'user') return;
|
|
|
const trimmed = (newContent || '').trim();
|
|
const trimmed = (newContent || '').trim();
|
|
|
if (!trimmed) {
|
|
if (!trimmed) {
|
|
@@ -355,11 +349,10 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
|
|
|
|
|
const attachedFile = msgs[index].attachedFile;
|
|
const attachedFile = msgs[index].attachedFile;
|
|
|
|
|
|
|
|
- await hydrateMessageIds();
|
|
|
|
|
|
|
+ // 全程锚定发起任务:这几步都在 await 之后,用「当前任务」会在切走后把别的会话的记录删掉
|
|
|
|
|
+ await hydrateMessageIds(task.sessionId, msgs);
|
|
|
// 从后往前删除后端记录(有 id 的才调接口)
|
|
// 从后往前删除后端记录(有 id 的才调接口)
|
|
|
- const toDelete = getMessages()
|
|
|
|
|
- .slice(index)
|
|
|
|
|
- .filter((m) => m.id != null);
|
|
|
|
|
|
|
+ const toDelete = msgs.slice(index).filter((m) => m.id != null);
|
|
|
for (const m of toDelete) {
|
|
for (const m of toDelete) {
|
|
|
try {
|
|
try {
|
|
|
await deleteMessage(m.id!);
|
|
await deleteMessage(m.id!);
|
|
@@ -367,8 +360,8 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
console.error('删除消息失败:', e);
|
|
console.error('删除消息失败:', e);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- // 本地截断到编辑位置
|
|
|
|
|
- setMessages(getMessages().slice(0, index));
|
|
|
|
|
|
|
+ // 本地截断到编辑位置(原地截断,始终操作同一个任务的消息数组)
|
|
|
|
|
+ msgs.splice(index);
|
|
|
const now = dayjs();
|
|
const now = dayjs();
|
|
|
const userMsg: Message = {
|
|
const userMsg: Message = {
|
|
|
type: 'user',
|
|
type: 'user',
|
|
@@ -378,10 +371,10 @@ export const useChatSend = (deps: ChatSendDeps) => {
|
|
|
createdAt: now.toISOString(),
|
|
createdAt: now.toISOString(),
|
|
|
attachedFile,
|
|
attachedFile,
|
|
|
};
|
|
};
|
|
|
- getMessages().push(userMsg);
|
|
|
|
|
|
|
+ msgs.push(userMsg);
|
|
|
await nextTick();
|
|
await nextTick();
|
|
|
- getChatMessages()?.forceScrollToBottom?.();
|
|
|
|
|
- await streamAiAnswer(trimmed, attachedFile);
|
|
|
|
|
|
|
+ if (getCurrentTaskId() === task.id) getChatMessages()?.forceScrollToBottom?.();
|
|
|
|
|
+ await streamAiAnswer(trimmed, attachedFile, task.id);
|
|
|
};
|
|
};
|
|
|
return {
|
|
return {
|
|
|
loading,
|
|
loading,
|