|
|
@@ -1,10 +1,17 @@
|
|
|
-import { computed, provide, ref, watch } from "vue";
|
|
|
+import { computed, nextTick, provide, ref, watch } from "vue";
|
|
|
import cardAPI from '@/network/api/card/index';
|
|
|
import { isZhaoshangLLM } from '@/utils';
|
|
|
import { getChatApiBaseUrl } from '@/utils/runtime-config';
|
|
|
// [旧协议] 旧聊天流式协调器(/knowledge/chat),已由 api-chat-coordinator 取代
|
|
|
// import { StreamMessageCoordinator } from '../stream-message-coordinator';
|
|
|
-import { ApiChatCoordinator, toChatQuestionAnswer } from '../api-chat-coordinator';
|
|
|
+import { ApiChatCoordinator, toChatQuestionAnswer, type ChatInterruptPayload } from '../api-chat-coordinator';
|
|
|
+import {
|
|
|
+ ChatGenerationTask,
|
|
|
+ isSessionGenerating,
|
|
|
+ resolveSendAction,
|
|
|
+ resolveStopTargetTaskId,
|
|
|
+ shouldNormalizeSessionHistory,
|
|
|
+} from './chat-generation-task';
|
|
|
import {
|
|
|
BusinessAssistantInputFileTransmission,
|
|
|
BusinessAssistantSession,
|
|
|
@@ -44,18 +51,28 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
const currentSessionIndex = ref(-1);
|
|
|
const currentSessionVisitedBefore = ref(false);
|
|
|
const inputText = ref('');
|
|
|
- const isGenerating = ref(false);
|
|
|
+ /**
|
|
|
+ * 按会话并行的生成任务表(key = sessionId)。
|
|
|
+ *
|
|
|
+ * 用 `ref(new Map())` 而不是 `reactive(new Map())`:只需要 `.size` 这一个响应式依赖,
|
|
|
+ * reactive 会把 task(含协调器实例)整体 proxy 化,既无意义又可能出怪问题。
|
|
|
+ */
|
|
|
+ const tasks = ref(new Map<string, ChatGenerationTask>());
|
|
|
+ /** 最近一次补问数据,按会话存(补问提交发生在上一轮结束之后,协调器实例已销毁) */
|
|
|
+ const sessionInterruptPayloads = new Map<string, ChatInterruptPayload>();
|
|
|
+ /** 是否**有任何**会话在生成(mock 测试按钮等全局场景用) */
|
|
|
+ const isGenerating = computed(() => tasks.value.size > 0);
|
|
|
+ /** **当前会话**是否在生成 —— 发送按钮的语义由它决定(并行化的关键) */
|
|
|
+ const currentSessionGenerating = computed(() =>
|
|
|
+ isSessionGenerating(tasks.value, currentSession.value?.id ?? null)
|
|
|
+ );
|
|
|
+ /** 全局 toast 文案(PC/Mobile 各挂一个 <Toast :message="toastMessage" />) */
|
|
|
+ const toastMessage = ref('');
|
|
|
const isZhaoshangMode = isZhaoshangLLM();
|
|
|
const storedThinking = isZhaoshangMode ? null : localStorage.getItem('enable_thinking');
|
|
|
const isDeepThink = ref(isZhaoshangMode ? false : storedThinking !== null ? storedThinking === 'true' : true);
|
|
|
- // [旧协议] let smc: StreamMessageCoordinator | null = null;
|
|
|
- let smc: ApiChatCoordinator | null = null;
|
|
|
- let messageChunkBuffer: ReturnType<typeof createAiMessageChunkBuffer> | null = null;
|
|
|
- let activeGenerationSessionId: string | null = null;
|
|
|
let typewriterTestTimer: number | null = null;
|
|
|
let typewriterTestSession: BusinessAssistantSession | null = null;
|
|
|
- /** 已把 answer 写进 DMS 的那条 AI 消息 id(避免 totalResponse 与 close 各写一次) */
|
|
|
- let dmsAnswerWrittenFor: string | null = null;
|
|
|
|
|
|
const clearTypewriterTestTimer = () => {
|
|
|
if (typewriterTestTimer === null) {
|
|
|
@@ -192,11 +209,10 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
return chatHistory.value.find((session) => session.id === sessionId) || null;
|
|
|
};
|
|
|
|
|
|
- const getActiveGenerationSession = () => getSessionById(activeGenerationSessionId);
|
|
|
-
|
|
|
+ /** 当前会话是否在生成 —— 只影响「该会话最后一条 AI 消息保持进行中」的展示 */
|
|
|
const markSessionMessagesAsHistory = (session: BusinessAssistantSession) => {
|
|
|
- const activeLastMessageIndex =
|
|
|
- session.id === activeGenerationSessionId ? session.messages.length - 1 : -1;
|
|
|
+ const generatingHere = isSessionGenerating(tasks.value, session.id);
|
|
|
+ const activeLastMessageIndex = generatingHere ? session.messages.length - 1 : -1;
|
|
|
|
|
|
session.messages.forEach((message, index) => {
|
|
|
if (index === activeLastMessageIndex && message.role === 'ai') {
|
|
|
@@ -208,62 +224,73 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
});
|
|
|
};
|
|
|
|
|
|
- const finishActiveAiMessage = () => {
|
|
|
- const finishingSessionId = activeGenerationSessionId;
|
|
|
- isGenerating.value = false;
|
|
|
- messageChunkBuffer?.flush();
|
|
|
- const targetSession = getSessionById(finishingSessionId);
|
|
|
- activeGenerationSessionId = null;
|
|
|
- if (!targetSession) {
|
|
|
- return;
|
|
|
+ /** 全局提示(PC/Mobile 各挂一个 Toast 组件消费) */
|
|
|
+ const showToast = (message: string) => {
|
|
|
+ if (!message) return;
|
|
|
+ // Toast 靠 message 变化触发:同一条文案要能再次弹出,先清空再置回
|
|
|
+ if (toastMessage.value === message) {
|
|
|
+ toastMessage.value = '';
|
|
|
}
|
|
|
-
|
|
|
- finishAiMessage(targetSession.messages);
|
|
|
+ void nextTick(() => {
|
|
|
+ toastMessage.value = message;
|
|
|
+ });
|
|
|
};
|
|
|
|
|
|
- const buildAppendHistoryPayload = (payload: any) => {
|
|
|
- const historyPayload: any = {
|
|
|
- question: payload?.question || smc?.question,
|
|
|
- answer: payload?.answer || smc?.totalResponse,
|
|
|
- record_id: payload?.record_id || smc?.record_id,
|
|
|
- session: payload?.session || smc?.session,
|
|
|
- knowledge_ids: payload?.knowledge_ids || smc?.knowledge_ids,
|
|
|
- };
|
|
|
|
|
|
- if (isZhaoshangMode) {
|
|
|
- historyPayload.source = (globalThis as any).source || 'zhaoshang';
|
|
|
- historyPayload.credit_code = globalThis.transmission?.credit_code;
|
|
|
+ /**
|
|
|
+ * 收尾一个生成任务:落残余内容 → 释放缓冲 → 从任务表移除 → 收尾消息状态 → 存档。
|
|
|
+ *
|
|
|
+ * 会被 totalResponse / close / error / stopTask 多条路径调用,靠 `task.finished`
|
|
|
+ * 与「表里还是不是这个 task」双重防重(mitt 是**同步** emit,事件会连着来)。
|
|
|
+ */
|
|
|
+ const finishTask = (task: ChatGenerationTask) => {
|
|
|
+ if (task.finished || tasks.value.get(task.sessionId) !== task) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ task.finished = true;
|
|
|
+
|
|
|
+ task.chunkBuffer.flush();
|
|
|
+ task.chunkBuffer.cancel();
|
|
|
+ tasks.value.delete(task.sessionId);
|
|
|
+
|
|
|
+ // 补问提交发生在**下一轮**(协调器实例已销毁),所以把最近一次补问数据留在会话上
|
|
|
+ if (task.coordinator?.lastInterruptPayload) {
|
|
|
+ sessionInterruptPayloads.set(task.sessionId, task.coordinator.lastInterruptPayload);
|
|
|
}
|
|
|
|
|
|
- return historyPayload;
|
|
|
+ const session = getSessionById(task.sessionId);
|
|
|
+ if (session) {
|
|
|
+ finishAiMessage(session.messages); // 已是 Stop 态则内部跳过
|
|
|
+ }
|
|
|
+ saveHistory();
|
|
|
};
|
|
|
|
|
|
- const setupCoordinator = () => {
|
|
|
- // [旧协议] 旧后端 /knowledge/chat(或 /dialog/chat)与 StreamMessageCoordinator
|
|
|
- // const cardURL = options.apiBaseUrl ?? getApiBaseUrl();
|
|
|
- // const sseURL = cardURL + (isKnowledgeApi() ? '/knowledge/chat' : '/dialog/chat');
|
|
|
- // smc = new StreamMessageCoordinator({
|
|
|
- // card_id: globalThis.card_id,
|
|
|
- // session: currentSession.value?.id || globalThis.currentSession || createBusinessAssistantId(),
|
|
|
- // SSEURL: sseURL,
|
|
|
- // token: globalThis.token,
|
|
|
- // isStrict: false,
|
|
|
- // });
|
|
|
-
|
|
|
- // 新版协议:POST {VITE_CHAT_API}/api/chat,thread_id 即 session id
|
|
|
- smc = new ApiChatCoordinator({
|
|
|
- baseUrl: getChatApiBaseUrl(),
|
|
|
- threadId: currentSession.value?.id || globalThis.currentSession || createBusinessAssistantId(),
|
|
|
- });
|
|
|
+ /**
|
|
|
+ * 建一个生成任务,并把该会话的事件监听挂到它自己的协调器上。
|
|
|
+ *
|
|
|
+ * ⚠️ 所有监听器**闭包捕获 sessionId**,一律不读「当前会话」「当前生成会话」——
|
|
|
+ * 这正是并行化的关键:用户在 A 生成时切到 B、在 B 提问,两条流各写各的会话,
|
|
|
+ * 不会因为"谁是当前会话"而串台。
|
|
|
+ */
|
|
|
+ const createGenerationTask = (
|
|
|
+ sessionId: string,
|
|
|
+ aiMessageId: string,
|
|
|
+ coordinator: ApiChatCoordinator | null, // mock/测试流任务传 null
|
|
|
+ ): ChatGenerationTask => {
|
|
|
+ const task: ChatGenerationTask = {
|
|
|
+ sessionId,
|
|
|
+ aiMessageId,
|
|
|
+ coordinator,
|
|
|
+ chunkBuffer: null as unknown as ChatGenerationTask['chunkBuffer'],
|
|
|
+ dmsAnswerWritten: false,
|
|
|
+ finished: false,
|
|
|
+ };
|
|
|
|
|
|
- messageChunkBuffer = createAiMessageChunkBuffer({
|
|
|
+ task.chunkBuffer = createAiMessageChunkBuffer({
|
|
|
append: (content) => {
|
|
|
- const targetSession = getActiveGenerationSession();
|
|
|
- if (!targetSession) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- appendAiMessageChunk(targetSession.messages, content);
|
|
|
+ const session = getSessionById(sessionId);
|
|
|
+ if (!session) return;
|
|
|
+ appendAiMessageChunk(session.messages, content);
|
|
|
},
|
|
|
onFlush: () => {
|
|
|
void options.onMessageUpdated?.();
|
|
|
@@ -271,112 +298,93 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- smc.addEventListener('message', (msg: string) => {
|
|
|
- if (!getActiveGenerationSession()) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- messageChunkBuffer?.push(msg);
|
|
|
+ coordinator?.addEventListener('message', (msg: string) => {
|
|
|
+ if (tasks.value.get(sessionId) !== task) return;
|
|
|
+ if (!getSessionById(sessionId)) return;
|
|
|
+ task.chunkBuffer.push(msg);
|
|
|
});
|
|
|
|
|
|
- // [旧协议] 新协议没有 record_id 与 <followup> 标签,这两个监听已无对应事件;
|
|
|
- // 卡片/来源/补问等内容由协调器翻译成 <scope>/POLICY_TABLE/<ref_links>/<question-cards>
|
|
|
- // 后经上面统一的 message 通道下发,渲染层无需再感知新协议。
|
|
|
- // smc.addEventListener('recordId', (recordId: string) => {
|
|
|
- // if (!recordId) {
|
|
|
- // return;
|
|
|
- // }
|
|
|
- //
|
|
|
- // const targetSession = getActiveGenerationSession();
|
|
|
- // if (!targetSession) {
|
|
|
- // return;
|
|
|
- // }
|
|
|
- //
|
|
|
- // const lastAiMessage = getLastAiMessage(targetSession.messages);
|
|
|
- // if (!lastAiMessage || lastAiMessage.id === recordId) {
|
|
|
- // return;
|
|
|
- // }
|
|
|
- //
|
|
|
- // lastAiMessage.id = recordId;
|
|
|
- // saveHistory();
|
|
|
- // });
|
|
|
- //
|
|
|
- // smc.addEventListener('followupSuggestions', (followupSuggestions: BusinessAssistantSuggestion[]) => {
|
|
|
- // const targetSession = getActiveGenerationSession();
|
|
|
- // if (!targetSession) {
|
|
|
- // return;
|
|
|
- // }
|
|
|
- //
|
|
|
- // targetSession.followupSuggestions = Array.isArray(followupSuggestions)
|
|
|
- // ? followupSuggestions.filter((item) => item?.text)
|
|
|
- // : [];
|
|
|
- // targetSession.followupSuggestionsHidden = false;
|
|
|
- // saveHistory();
|
|
|
- // });
|
|
|
-
|
|
|
- smc.addEventListener('loadingState', (payload: any) => {
|
|
|
- if (payload && payload.state === false) {
|
|
|
- isGenerating.value = false;
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- smc.addEventListener('totalResponse', (payload: any) => {
|
|
|
- // ⚠️ 必须在 finishActiveAiMessage() 之前捕获:它会清空 activeGenerationSessionId,
|
|
|
- // 而且用户此刻可能已经切走会话,之后取 currentSession 会指错对象
|
|
|
- const targetSession = getActiveGenerationSession();
|
|
|
- const lastAiMessage = targetSession ? getLastAiMessage(targetSession.messages) : null;
|
|
|
-
|
|
|
- finishActiveAiMessage();
|
|
|
- const historyPayload = buildAppendHistoryPayload(payload);
|
|
|
- // appendHistory 已禁用:历史记录改由 chat 接口的 enable_history 控制
|
|
|
- // if (historyPayload.question) {
|
|
|
- // cardAPI.appendHistory(historyPayload);
|
|
|
- // }
|
|
|
- saveHistory();
|
|
|
+ coordinator?.addEventListener('totalResponse', (payload: any) => {
|
|
|
+ if (tasks.value.get(sessionId) !== task) return;
|
|
|
+ const session = getSessionById(sessionId);
|
|
|
+ const lastAiMessage = session ? getLastAiMessage(session.messages) : null;
|
|
|
|
|
|
// 回答完成 → 把 answer 补进这一轮的 DMS 记录(提交时已写 question)
|
|
|
- if (targetSession && lastAiMessage) {
|
|
|
- dmsAnswerWrittenFor = lastAiMessage.id; // 标记:close 时不必再补一次
|
|
|
+ if (session && lastAiMessage && lastAiMessage.id === aiMessageId) {
|
|
|
+ task.dmsAnswerWritten = true; // 标记:close 时不必再补一次
|
|
|
saveTurnToDms({
|
|
|
- sessionId: targetSession.id,
|
|
|
+ sessionId,
|
|
|
recordId: lastAiMessage.id,
|
|
|
...(payload?.question ? { question: payload.question } : {}),
|
|
|
...(payload?.answer ? { answer: payload.answer } : {}),
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- // 首轮完成时同步标题(会话行在 sendMessage 里已建,这里按 c_session_id 更新)
|
|
|
- const session = currentSession.value;
|
|
|
+ // 首轮完成时同步标题(会话行在发送时已建,这里按 c_session_id 更新)
|
|
|
if (session && session.messages.length <= 2) {
|
|
|
syncSessionTitleToServer(session.id, session.title || '');
|
|
|
}
|
|
|
+
|
|
|
+ finishTask(task);
|
|
|
});
|
|
|
|
|
|
- smc.addEventListener('close', () => {
|
|
|
- // 停止/断流走这里(没有 totalResponse):把已经生成出来的内容补写进 DMS,
|
|
|
- // 否则被中断的问答在库里会只有 question 没有 answer
|
|
|
- const targetSession = getActiveGenerationSession();
|
|
|
- const lastAiMessage = targetSession ? getLastAiMessage(targetSession.messages) : null;
|
|
|
- finishActiveAiMessage();
|
|
|
- saveHistory();
|
|
|
+ coordinator?.addEventListener('close', () => {
|
|
|
+ if (tasks.value.get(sessionId) !== task) return; // totalResponse 已收尾,这里空转
|
|
|
+ const session = getSessionById(sessionId);
|
|
|
+ const lastAiMessage = session ? getLastAiMessage(session.messages) : null;
|
|
|
|
|
|
+ // 停止/断流走这里(没有 totalResponse):把已生成的内容补写进 DMS,
|
|
|
+ // 否则被中断的问答在库里会只有 question 没有 answer
|
|
|
if (
|
|
|
- targetSession &&
|
|
|
+ session &&
|
|
|
lastAiMessage &&
|
|
|
- lastAiMessage.id !== dmsAnswerWrittenFor && // 正常完成时已写过,别重复
|
|
|
+ !task.dmsAnswerWritten &&
|
|
|
String(lastAiMessage.content || '').trim()
|
|
|
) {
|
|
|
- saveTurnToDms({
|
|
|
- sessionId: targetSession.id,
|
|
|
- recordId: lastAiMessage.id,
|
|
|
- answer: lastAiMessage.content,
|
|
|
- });
|
|
|
+ saveTurnToDms({ sessionId, recordId: lastAiMessage.id, answer: lastAiMessage.content });
|
|
|
}
|
|
|
+ finishTask(task);
|
|
|
});
|
|
|
|
|
|
- smc.addEventListener('error', () => {
|
|
|
- finishActiveAiMessage();
|
|
|
+ coordinator?.addEventListener('error', () => {
|
|
|
+ finishTask(task);
|
|
|
});
|
|
|
+
|
|
|
+ // 提示类事件不校验任务归属:出错时 emitError 会**先**发 error(本任务已被收尾、
|
|
|
+ // 从表里移除)再发 info,加了校验就会把 409 之类的提示吞掉。
|
|
|
+ coordinator?.addEventListener('info', (text: string) => {
|
|
|
+ if (text) showToast(text);
|
|
|
+ });
|
|
|
+
|
|
|
+ return task;
|
|
|
+ };
|
|
|
+
|
|
|
+ /** mock/测试流任务:没有协调器,由外部手动驱动 */
|
|
|
+ const createMockTask = (sessionId: string, aiMessageId: string): ChatGenerationTask =>
|
|
|
+ createGenerationTask(sessionId, aiMessageId, null);
|
|
|
+
|
|
|
+ /** 停止某个会话的生成(只停这一个) */
|
|
|
+ const stopTask = (sessionId: string) => {
|
|
|
+ const task = tasks.value.get(sessionId);
|
|
|
+ if (!task) return;
|
|
|
+
|
|
|
+ // 先置 Stop:晚到的 chunk 会被 appendAiMessageChunk 的 Stop 守卫丢弃
|
|
|
+ const session = getSessionById(sessionId);
|
|
|
+ if (session) {
|
|
|
+ stopAiMessage(session.messages);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (task.coordinator) {
|
|
|
+ // 同步 emit close → close 监听器负责 DMS 补写与收尾
|
|
|
+ task.coordinator.stopGenerate();
|
|
|
+ }
|
|
|
+ finishTask(task); // 兜底:收尾不依赖监听器(mock 任务没有协调器)
|
|
|
+ saveHistory();
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 停掉全部生成(退出登录等场景:必须赶在 storage key 切换之前) */
|
|
|
+ const stopAllTasks = () => {
|
|
|
+ [...tasks.value.keys()].forEach((sessionId) => stopTask(sessionId));
|
|
|
};
|
|
|
|
|
|
const ensureSession = (titleSeed: string) => {
|
|
|
@@ -396,10 +404,6 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
chatHistory.value.unshift(session);
|
|
|
currentSessionIndex.value = 0;
|
|
|
saveCurrentSessionId(sessionId);
|
|
|
- if (smc) {
|
|
|
- // [旧协议] smc.session = sessionId;
|
|
|
- smc.threadId = sessionId;
|
|
|
- }
|
|
|
return session;
|
|
|
};
|
|
|
|
|
|
@@ -410,18 +414,20 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
}
|
|
|
|
|
|
const text = inputText.value.trim();
|
|
|
- if (!text || isGenerating.value) {
|
|
|
+ if (!text) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 守卫按**会话**算:同一会话已有回答在生成中 → 拦截并提示;
|
|
|
+ // 别的会话在生成不受影响(这是按会话并行的前提)
|
|
|
+ if (resolveSendAction(tasks.value, currentSession.value?.id ?? null) === 'busy-same-session') {
|
|
|
+ showToast('该会话已有回答在生成中');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const session = ensureSession(text);
|
|
|
session.followupSuggestionsHidden = true;
|
|
|
|
|
|
- if (smc) {
|
|
|
- // [旧协议] smc.session = session.id;
|
|
|
- smc.threadId = session.id;
|
|
|
- }
|
|
|
-
|
|
|
if (!globalThis.transmission) {
|
|
|
globalThis.transmission = {};
|
|
|
}
|
|
|
@@ -437,9 +443,14 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
// 也是反馈组件用的 record.id —— 三条链路同一个值,反馈才定位得到
|
|
|
const pendingAiMessage = ensurePendingAiMessage(session.messages);
|
|
|
inputText.value = '';
|
|
|
- isGenerating.value = true;
|
|
|
- activeGenerationSessionId = session.id;
|
|
|
- dmsAnswerWrittenFor = null; // 新一轮开始,允许 close 补写
|
|
|
+
|
|
|
+ // 本轮独立的协调器:thread_id 就是这个会话 id;实例的生命周期与本轮任务一致
|
|
|
+ const coordinator = new ApiChatCoordinator({
|
|
|
+ baseUrl: getChatApiBaseUrl(),
|
|
|
+ threadId: session.id,
|
|
|
+ });
|
|
|
+ tasks.value.set(session.id, createGenerationTask(session.id, pendingAiMessage.id, coordinator));
|
|
|
+
|
|
|
saveHistory();
|
|
|
await options.afterSend?.();
|
|
|
|
|
|
@@ -448,7 +459,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
void upsertDmsSession({ sessionId: session.id, title: session.title });
|
|
|
saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: text });
|
|
|
|
|
|
- smc?.generateAnswer(text);
|
|
|
+ void coordinator.generateAnswer(text);
|
|
|
};
|
|
|
|
|
|
const playMockStreamMessage = async ({
|
|
|
@@ -461,38 +472,43 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
delayMs: number;
|
|
|
}) => {
|
|
|
const beforeResult = await options.beforeSend?.();
|
|
|
- if (beforeResult === false || isGenerating.value) {
|
|
|
+ if (beforeResult === false) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const session = ensureSession(text);
|
|
|
+ if (isSessionGenerating(tasks.value, session.id)) {
|
|
|
+ showToast('该会话已有回答在生成中');
|
|
|
+ return;
|
|
|
+ }
|
|
|
typewriterTestSession = session;
|
|
|
session.followupSuggestionsHidden = true;
|
|
|
session.messages.push(createUserMessage(text));
|
|
|
- ensurePendingAiMessage(session.messages);
|
|
|
+ const pendingAiMessage = ensurePendingAiMessage(session.messages);
|
|
|
inputText.value = '';
|
|
|
- isGenerating.value = true;
|
|
|
+ const mockTask = createMockTask(session.id, pendingAiMessage.id);
|
|
|
+ tasks.value.set(session.id, mockTask);
|
|
|
saveHistory();
|
|
|
await options.afterSend?.();
|
|
|
|
|
|
let index = 0;
|
|
|
|
|
|
const finishMockStreamMessage = () => {
|
|
|
- if (!typewriterTestSession || !isGenerating.value) {
|
|
|
+ if (!typewriterTestSession || !isSessionGenerating(tasks.value, typewriterTestSession.id)) {
|
|
|
clearTypewriterTestTimer();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- isGenerating.value = false;
|
|
|
finishAiMessage(typewriterTestSession.messages);
|
|
|
saveHistory();
|
|
|
void options.onMessageUpdated?.();
|
|
|
typewriterTestSession = null;
|
|
|
typewriterTestTimer = null;
|
|
|
+ finishTask(mockTask);
|
|
|
};
|
|
|
|
|
|
const appendNextChunk = () => {
|
|
|
- if (!typewriterTestSession || !isGenerating.value) {
|
|
|
+ if (!typewriterTestSession || !isSessionGenerating(tasks.value, typewriterTestSession.id)) {
|
|
|
clearTypewriterTestTimer();
|
|
|
return;
|
|
|
}
|
|
|
@@ -517,18 +533,23 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
|
|
|
const sendTypewriterTestMessage = async () => {
|
|
|
const beforeResult = await options.beforeSend?.();
|
|
|
- if (beforeResult === false || isGenerating.value) {
|
|
|
+ if (beforeResult === false) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const text = '测试打字机效果';
|
|
|
const session = ensureSession(text);
|
|
|
+ if (isSessionGenerating(tasks.value, session.id)) {
|
|
|
+ showToast('该会话已有回答在生成中');
|
|
|
+ return;
|
|
|
+ }
|
|
|
typewriterTestSession = session;
|
|
|
session.followupSuggestionsHidden = true;
|
|
|
session.messages.push(createUserMessage(text));
|
|
|
- ensurePendingAiMessage(session.messages);
|
|
|
+ const pendingAiMessage = ensurePendingAiMessage(session.messages);
|
|
|
inputText.value = '';
|
|
|
- isGenerating.value = true;
|
|
|
+ const mockTask = createMockTask(session.id, pendingAiMessage.id);
|
|
|
+ tasks.value.set(session.id, mockTask);
|
|
|
saveHistory();
|
|
|
await options.afterSend?.();
|
|
|
|
|
|
@@ -1214,21 +1235,21 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
);
|
|
|
|
|
|
const finishTypewriterTestMessage = () => {
|
|
|
- if (!typewriterTestSession || !isGenerating.value) {
|
|
|
+ if (!typewriterTestSession || !isSessionGenerating(tasks.value, typewriterTestSession.id)) {
|
|
|
clearTypewriterTestTimer();
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- isGenerating.value = false;
|
|
|
finishAiMessage(typewriterTestSession.messages);
|
|
|
saveHistory();
|
|
|
void options.onMessageUpdated?.();
|
|
|
typewriterTestSession = null;
|
|
|
typewriterTestTimer = null;
|
|
|
+ finishTask(mockTask);
|
|
|
};
|
|
|
|
|
|
const appendNextChunk = () => {
|
|
|
- if (!typewriterTestSession || !isGenerating.value) {
|
|
|
+ if (!typewriterTestSession || !isSessionGenerating(tasks.value, typewriterTestSession.id)) {
|
|
|
clearTypewriterTestTimer();
|
|
|
return;
|
|
|
}
|
|
|
@@ -1433,27 +1454,26 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
});
|
|
|
};
|
|
|
|
|
|
+ /**
|
|
|
+ * 停止生成 —— 只停**当前会话**自己的那一轮。
|
|
|
+ *
|
|
|
+ * ⚠️ 这里曾有一个隐蔽的 bug:旧实现先调 `stopGenerate()`(同步 emit close →
|
|
|
+ * 监听器把"正在生成的会话"清空),再去找目标,于是 `getActiveGenerationSession()
|
|
|
+ * || currentSession` 必然 fallback 到**用户当前看的会话** —— 停止打错了对象,
|
|
|
+ * 还会凭空给那个会话造出一条空的"请求已取消"消息。
|
|
|
+ * 现在目标由 `resolveStopTargetTaskId` 在动手**之前**解析,且任务的监听器只操作
|
|
|
+ * 自己闭包里的会话。
|
|
|
+ */
|
|
|
const handleStopGenerate = () => {
|
|
|
clearTypewriterTestTimer();
|
|
|
- if (typewriterTestSession) {
|
|
|
- stopAiMessage(typewriterTestSession.messages);
|
|
|
- typewriterTestSession = null;
|
|
|
- }
|
|
|
- smc?.stopGenerate();
|
|
|
- messageChunkBuffer?.cancel();
|
|
|
- isGenerating.value = false;
|
|
|
- const targetSession = getActiveGenerationSession() || currentSession.value;
|
|
|
- activeGenerationSessionId = null;
|
|
|
- if (targetSession) {
|
|
|
- stopAiMessage(targetSession.messages);
|
|
|
- saveHistory();
|
|
|
+ const targetTaskId = resolveStopTargetTaskId(tasks.value, currentSession.value?.id ?? null);
|
|
|
+ if (targetTaskId) {
|
|
|
+ stopTask(targetTaskId);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ /** 新建会话:**不再**打断正在生成的会话 —— 后台任务继续跑(与主流产品一致) */
|
|
|
const startNewChat = () => {
|
|
|
- if (isGenerating.value) {
|
|
|
- handleStopGenerate();
|
|
|
- }
|
|
|
currentSessionIndex.value = -1;
|
|
|
currentSessionVisitedBefore.value = false;
|
|
|
inputText.value = '';
|
|
|
@@ -1471,11 +1491,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
markSessionMessagesAsHistory(selectedSession);
|
|
|
selectedSession.viewed = true;
|
|
|
currentSessionVisitedBefore.value = true;
|
|
|
- if (smc && currentSession.value?.id && !activeGenerationSessionId) {
|
|
|
- // [旧协议] smc.session = currentSession.value.id;
|
|
|
- smc.threadId = currentSession.value.id;
|
|
|
- }
|
|
|
- if (currentSession.value && currentSession.value.id !== activeGenerationSessionId) {
|
|
|
+ if (currentSession.value && shouldNormalizeSessionHistory(tasks.value, currentSession.value.id)) {
|
|
|
const normalized = normalizeSessionHistory(currentSession.value);
|
|
|
if (normalized) {
|
|
|
saveHistory();
|
|
|
@@ -1515,6 +1531,16 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
}
|
|
|
|
|
|
const sessionId = chatHistory.value[index].id;
|
|
|
+
|
|
|
+ // 该会话若正在生成:先停掉它的任务(否则流会继续跑、任务残留在表里)。
|
|
|
+ // 抑制这次 DMS 补写——会话马上要被删掉,留一行孤儿问答没有意义。
|
|
|
+ const runningTask = tasks.value.get(sessionId);
|
|
|
+ if (runningTask) {
|
|
|
+ runningTask.dmsAnswerWritten = true;
|
|
|
+ stopTask(sessionId);
|
|
|
+ }
|
|
|
+ sessionInterruptPayloads.delete(sessionId);
|
|
|
+
|
|
|
chatHistory.value.splice(index, 1);
|
|
|
if (currentSessionIndex.value === index) {
|
|
|
currentSessionIndex.value = -1;
|
|
|
@@ -1601,26 +1627,31 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
|
|
|
const submitQuestionAnswers = async (answers: any[]) => {
|
|
|
const session = currentSession.value;
|
|
|
- if (!session || !smc) {
|
|
|
- console.warn('[submitQuestionAnswers] No session or smc available');
|
|
|
+ if (!session) {
|
|
|
+ console.warn('[submitQuestionAnswers] No session available');
|
|
|
return;
|
|
|
}
|
|
|
- if (isGenerating.value) {
|
|
|
+ if (resolveSendAction(tasks.value, session.id) === 'busy-same-session') {
|
|
|
+ showToast('该会话已有回答在生成中');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 原 <question-cards> 提交的是 {answers} 对象,这里翻译成协议要求的 question 字符串。
|
|
|
- // 传入最近一次补问数据:公司候选要按 label 反查、拼出选项的全部内容。
|
|
|
- const answerText = toChatQuestionAnswer(answers, smc.lastInterruptPayload);
|
|
|
+ // 传入最近一次补问数据(按会话存):公司候选要按 label 反查、拼出选项的全部内容。
|
|
|
+ const answerText = toChatQuestionAnswer(answers, sessionInterruptPayloads.get(session.id));
|
|
|
if (!answerText) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
session.messages.push(createUserMessage('已提交企业信息补充'));
|
|
|
const pendingAiMessage = ensurePendingAiMessage(session.messages);
|
|
|
- isGenerating.value = true;
|
|
|
- activeGenerationSessionId = session.id;
|
|
|
- dmsAnswerWrittenFor = null;
|
|
|
+
|
|
|
+ const coordinator = new ApiChatCoordinator({
|
|
|
+ baseUrl: getChatApiBaseUrl(),
|
|
|
+ threadId: session.id,
|
|
|
+ });
|
|
|
+ tasks.value.set(session.id, createGenerationTask(session.id, pendingAiMessage.id, coordinator));
|
|
|
+
|
|
|
saveHistory();
|
|
|
await options.afterSend?.();
|
|
|
|
|
|
@@ -1629,7 +1660,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
void upsertDmsSession({ sessionId: session.id, title: session.title });
|
|
|
saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: answerText });
|
|
|
|
|
|
- smc.generateAnswer(answerText);
|
|
|
+ void coordinator.generateAnswer(answerText);
|
|
|
};
|
|
|
|
|
|
provide('submitQuestionAnswers', submitQuestionAnswers);
|
|
|
@@ -1657,12 +1688,15 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
currentSessionTitle,
|
|
|
currentSessionSuggestions,
|
|
|
inputText,
|
|
|
+ /** 是否有**任何**会话在生成(mock 测试按钮等全局场景用) */
|
|
|
isGenerating,
|
|
|
+ /** **当前会话**是否在生成 —— 发送/停止按钮的语义由它决定 */
|
|
|
+ currentSessionGenerating,
|
|
|
+ /** 全局提示文案(壳组件挂 <Toast :message="toastMessage" />) */
|
|
|
+ toastMessage,
|
|
|
isDeepThink,
|
|
|
isChatting,
|
|
|
- smc: () => smc,
|
|
|
loadHistory,
|
|
|
- setupCoordinator,
|
|
|
saveHistory,
|
|
|
sendMessage,
|
|
|
sendChatStreamReplayTestMessage,
|
|
|
@@ -1672,6 +1706,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
|
|
|
sendRefLinksStreamTestMessage,
|
|
|
sendQuestionCardTestMessage,
|
|
|
handleStopGenerate,
|
|
|
+ /** 停止全部会话的生成(退出登录等场景用) */
|
|
|
+ stopAllTasks,
|
|
|
startNewChat,
|
|
|
switchSession,
|
|
|
switchSessionById,
|