|
@@ -157,7 +157,7 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
- import { ref, nextTick, watch, computed, onMounted, onBeforeUnmount } from 'vue';
|
|
|
|
|
|
|
+ import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
|
|
|
import dayjs from 'dayjs';
|
|
import dayjs from 'dayjs';
|
|
|
import {
|
|
import {
|
|
|
unifiedStream,
|
|
unifiedStream,
|
|
@@ -186,7 +186,7 @@
|
|
|
import SchedulePanel from './chatModal/SchedulePanel.vue';
|
|
import SchedulePanel from './chatModal/SchedulePanel.vue';
|
|
|
import ScheduleDetailPanel from './chatModal/ScheduleDetailPanel.vue';
|
|
import ScheduleDetailPanel from './chatModal/ScheduleDetailPanel.vue';
|
|
|
import { getScheduleList } from '../api';
|
|
import { getScheduleList } from '../api';
|
|
|
- import type { AttachedFile, Message, Task } from './chatModal/types';
|
|
|
|
|
|
|
+ import type { AttachedFile, Message, Task, SseEvent } from './chatModal/types';
|
|
|
import { isTextFile, isVideoFile, isAudioFile, isWordFile, isExcelFile, isCsvFile } from './chatModal/utils';
|
|
import { isTextFile, isVideoFile, isAudioFile, isWordFile, isExcelFile, isCsvFile } from './chatModal/utils';
|
|
|
|
|
|
|
|
interface Props {
|
|
interface Props {
|
|
@@ -221,6 +221,10 @@
|
|
|
sortOrder: item.sort_order,
|
|
sortOrder: item.sort_order,
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
|
|
+ // Build lookup maps for O(1) access
|
|
|
|
|
+ const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t]));
|
|
|
|
|
+ const apiTaskIds = new Set(apiTasks.map((t) => t.id));
|
|
|
|
|
+
|
|
|
// 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
|
|
// 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
|
|
|
// 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
|
|
// 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
|
|
|
let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
|
|
let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
|
|
@@ -236,7 +240,7 @@
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const existing = taskList.value.find((t) => t.id === apiTask.id);
|
|
|
|
|
|
|
+ const existing = existingTaskMap.get(apiTask.id);
|
|
|
if (existing) {
|
|
if (existing) {
|
|
|
// 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
|
|
// 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
|
|
|
existing.name = apiTask.name;
|
|
existing.name = apiTask.name;
|
|
@@ -250,7 +254,7 @@
|
|
|
|
|
|
|
|
apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
|
|
apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
|
|
|
|
|
|
|
|
- const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTasks.find((a) => a.id === t.id));
|
|
|
|
|
|
|
+ const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
|
|
|
|
|
|
|
|
taskList.value = [...localOnlyTasks, ...mergedTasks];
|
|
taskList.value = [...localOnlyTasks, ...mergedTasks];
|
|
|
return mergedTasks;
|
|
return mergedTasks;
|
|
@@ -288,6 +292,7 @@
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
const pendingFile = ref<AttachedFile | null>(null);
|
|
const pendingFile = ref<AttachedFile | null>(null);
|
|
|
|
|
+ const activeFileReader = ref<FileReader | null>(null);
|
|
|
|
|
|
|
|
const messages = ref<Message[]>([]);
|
|
const messages = ref<Message[]>([]);
|
|
|
|
|
|
|
@@ -324,32 +329,19 @@
|
|
|
panelCollapsed.value = !panelCollapsed.value;
|
|
panelCollapsed.value = !panelCollapsed.value;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const toggleSkillPanel = () => {
|
|
|
|
|
- if (activePanel.value === 'skill') {
|
|
|
|
|
- activePanel.value = 'chat';
|
|
|
|
|
- } else {
|
|
|
|
|
- activePanel.value = 'skill';
|
|
|
|
|
- previewFile.value = null;
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const toggleSchedulePanel = () => {
|
|
|
|
|
- if (activePanel.value === 'schedules') {
|
|
|
|
|
|
|
+ const toggleSidePanel = (panel: 'skill' | 'schedules' | 'sub-agents') => {
|
|
|
|
|
+ if (activePanel.value === panel) {
|
|
|
activePanel.value = 'chat';
|
|
activePanel.value = 'chat';
|
|
|
} else {
|
|
} else {
|
|
|
- activePanel.value = 'schedules';
|
|
|
|
|
|
|
+ activePanel.value = panel;
|
|
|
previewFile.value = null;
|
|
previewFile.value = null;
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const toggleSubAgentPanel = () => {
|
|
|
|
|
- if (activePanel.value === 'sub-agents') {
|
|
|
|
|
- activePanel.value = 'chat';
|
|
|
|
|
- } else {
|
|
|
|
|
- activePanel.value = 'sub-agents';
|
|
|
|
|
- previewFile.value = null;
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ // Keep backward-compatible aliases
|
|
|
|
|
+ const toggleSkillPanel = () => toggleSidePanel('skill');
|
|
|
|
|
+ const toggleSchedulePanel = () => toggleSidePanel('schedules');
|
|
|
|
|
+ const toggleSubAgentPanel = () => toggleSidePanel('sub-agents');
|
|
|
|
|
|
|
|
const handleOpenScheduleSession = async (sessionId: string, prompt?: string) => {
|
|
const handleOpenScheduleSession = async (sessionId: string, prompt?: string) => {
|
|
|
activePanel.value = 'chat';
|
|
activePanel.value = 'chat';
|
|
@@ -369,7 +361,7 @@
|
|
|
attachedFiles: [],
|
|
attachedFiles: [],
|
|
|
};
|
|
};
|
|
|
taskList.value.unshift(newTask);
|
|
taskList.value.unshift(newTask);
|
|
|
- historyLoadedTasks.value.delete(sessionId);
|
|
|
|
|
|
|
+ historyLoadedTasks.delete(sessionId);
|
|
|
await switchTask(newTask.id);
|
|
await switchTask(newTask.id);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -470,34 +462,30 @@
|
|
|
const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
|
|
const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
|
|
|
|
|
|
|
|
// 按任务维度的状态存储
|
|
// 按任务维度的状态存储
|
|
|
- const taskAbortControllers = ref<Map<string, AbortController>>(new Map());
|
|
|
|
|
const taskPendingApprovals = ref<Map<string, { sessionId: string; threadId: string }>>(new Map());
|
|
const taskPendingApprovals = ref<Map<string, { sessionId: string; threadId: string }>>(new Map());
|
|
|
|
|
|
|
|
- // loading 和 streaming 改为计算属性,基于当前任务的 isStreaming 状态
|
|
|
|
|
|
|
+ // loading 和 streaming 共享同一个计算属性,基于当前任务的 isStreaming 状态
|
|
|
const loading = computed(() => {
|
|
const loading = computed(() => {
|
|
|
const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
return currentTask?.isStreaming ?? false;
|
|
return currentTask?.isStreaming ?? false;
|
|
|
});
|
|
});
|
|
|
-
|
|
|
|
|
- const streaming = computed(() => {
|
|
|
|
|
- const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
|
|
- return currentTask?.isStreaming ?? false;
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const streaming = loading;
|
|
|
|
|
|
|
|
// 获取当前任务的待审批状态
|
|
// 获取当前任务的待审批状态
|
|
|
const currentPendingApproval = computed(() => {
|
|
const currentPendingApproval = computed(() => {
|
|
|
return taskPendingApprovals.value.get(currentTaskId.value);
|
|
return taskPendingApprovals.value.get(currentTaskId.value);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // Non-reactive Map for AbortControllers - only used imperatively, no need for Vue reactivity
|
|
|
|
|
+ const taskAbortControllers = new Map<string, AbortController>();
|
|
|
|
|
+
|
|
|
// 设置任务的AbortController
|
|
// 设置任务的AbortController
|
|
|
const setTaskAbortController = (taskId: string, controller: AbortController | null) => {
|
|
const setTaskAbortController = (taskId: string, controller: AbortController | null) => {
|
|
|
- const map = new Map(taskAbortControllers.value);
|
|
|
|
|
if (controller) {
|
|
if (controller) {
|
|
|
- map.set(taskId, controller);
|
|
|
|
|
|
|
+ taskAbortControllers.set(taskId, controller);
|
|
|
} else {
|
|
} else {
|
|
|
- map.delete(taskId);
|
|
|
|
|
|
|
+ taskAbortControllers.delete(taskId);
|
|
|
}
|
|
}
|
|
|
- taskAbortControllers.value = map;
|
|
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// 设置任务的审批状态
|
|
// 设置任务的审批状态
|
|
@@ -516,7 +504,7 @@
|
|
|
|
|
|
|
|
// 停止指定任务的流式传输
|
|
// 停止指定任务的流式传输
|
|
|
const stopTaskStreaming = (taskId: string) => {
|
|
const stopTaskStreaming = (taskId: string) => {
|
|
|
- const controller = taskAbortControllers.value.get(taskId);
|
|
|
|
|
|
|
+ const controller = taskAbortControllers.get(taskId);
|
|
|
controller?.abort();
|
|
controller?.abort();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -529,7 +517,8 @@
|
|
|
return currentTask?.sessionId || '';
|
|
return currentTask?.sessionId || '';
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const historyLoadedTasks = ref<Set<string>>(new Set());
|
|
|
|
|
|
|
+ // Non-reactive lookup cache - Vue 3 does not track Set mutations on ref<Set>
|
|
|
|
|
+ const historyLoadedTasks = new Set<string>();
|
|
|
|
|
|
|
|
const setCurrentSessionId = (sessionId: string) => {
|
|
const setCurrentSessionId = (sessionId: string) => {
|
|
|
const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
@@ -538,7 +527,7 @@
|
|
|
currentTask.id = sessionId;
|
|
currentTask.id = sessionId;
|
|
|
currentTask.sessionId = sessionId;
|
|
currentTask.sessionId = sessionId;
|
|
|
currentTaskId.value = sessionId;
|
|
currentTaskId.value = sessionId;
|
|
|
- historyLoadedTasks.value.add(sessionId);
|
|
|
|
|
|
|
+ historyLoadedTasks.add(sessionId);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -558,7 +547,7 @@
|
|
|
previewFile.value = null;
|
|
previewFile.value = null;
|
|
|
showFileList.value = false;
|
|
showFileList.value = false;
|
|
|
|
|
|
|
|
- if (newTask.sessionId && !historyLoadedTasks.value.has(taskId)) {
|
|
|
|
|
|
|
+ if (newTask.sessionId && !historyLoadedTasks.has(taskId)) {
|
|
|
if (newTask.isStreaming) return; // 新任务正在流式中,无需加载历史
|
|
if (newTask.isStreaming) return; // 新任务正在流式中,无需加载历史
|
|
|
try {
|
|
try {
|
|
|
const res = await getDetail(newTask.sessionId);
|
|
const res = await getDetail(newTask.sessionId);
|
|
@@ -566,7 +555,7 @@
|
|
|
if (Array.isArray(messages)) {
|
|
if (Array.isArray(messages)) {
|
|
|
newTask.messages = transformHistoryToMessages(messages);
|
|
newTask.messages = transformHistoryToMessages(messages);
|
|
|
}
|
|
}
|
|
|
- historyLoadedTasks.value.add(taskId);
|
|
|
|
|
|
|
+ historyLoadedTasks.add(taskId);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error('加载历史会话失败:', error);
|
|
console.error('加载历史会话失败:', error);
|
|
|
}
|
|
}
|
|
@@ -669,43 +658,63 @@
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const findPendingApprovalTask = async (): Promise<{ taskId: string; approval: { sessionId: string; threadId?: string } } | null> => {
|
|
|
|
|
|
|
+ const findPendingApprovalTask = (): { taskId: string; approval: { sessionId: string; threadId?: string } } | null => {
|
|
|
|
|
+ // 优先用当前任务 ID 查找
|
|
|
let approval = currentPendingApproval.value;
|
|
let approval = currentPendingApproval.value;
|
|
|
if (approval) return { taskId: currentTaskId.value, approval };
|
|
if (approval) return { taskId: currentTaskId.value, approval };
|
|
|
|
|
+ // 遍历所有审批(可能任务 ID 已变,用 sessionId 做回退匹配)
|
|
|
for (const [taskId, a] of taskPendingApprovals.value.entries()) {
|
|
for (const [taskId, a] of taskPendingApprovals.value.entries()) {
|
|
|
- if (currentTaskId.value !== taskId) {
|
|
|
|
|
- await switchTask(taskId);
|
|
|
|
|
- }
|
|
|
|
|
return { taskId, approval: a };
|
|
return { taskId, approval: a };
|
|
|
}
|
|
}
|
|
|
return null;
|
|
return null;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const handleAskUserSubmit = async (answers: string[]) => {
|
|
const handleAskUserSubmit = async (answers: string[]) => {
|
|
|
- const found = await findPendingApprovalTask();
|
|
|
|
|
- if (!found) return;
|
|
|
|
|
|
|
+ // 防重复提交:如果当前任务正在流式中,忽略
|
|
|
|
|
+ const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
|
|
+ if (currentTask?.isStreaming) return;
|
|
|
|
|
|
|
|
- const { approval } = found;
|
|
|
|
|
- const sessionId = approval.sessionId;
|
|
|
|
|
- if (!sessionId) return;
|
|
|
|
|
|
|
+ const found = findPendingApprovalTask();
|
|
|
|
|
+
|
|
|
|
|
+ // 优先从 taskPendingApprovals 获取,若被清除则从消息的 pendingApprovalData 回退
|
|
|
|
|
+ let sessionId = found?.approval.sessionId;
|
|
|
|
|
+ let threadId = found?.approval.threadId;
|
|
|
|
|
+ const foundTaskId = found?.taskId;
|
|
|
|
|
|
|
|
- const task = taskList.value.find((t) => t.sessionId === sessionId);
|
|
|
|
|
|
|
+ // 查找任务:优先用 foundTaskId,回退到 currentTaskId
|
|
|
|
|
+ let task = foundTaskId ? taskList.value.find((t) => t.id === foundTaskId) : null;
|
|
|
|
|
+ if (!task) task = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
if (!task) return;
|
|
if (!task) return;
|
|
|
|
|
|
|
|
- const taskMessages = task.messages;
|
|
|
|
|
- const lastMsg = taskMessages[taskMessages.length - 1];
|
|
|
|
|
|
|
+ // 切换到目标任务(如果不在当前视图)
|
|
|
|
|
+ if (task.id !== currentTaskId.value) {
|
|
|
|
|
+ await switchTask(task.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 使用正确的消息数组:当前视图用 messages.value,否则用 task.messages
|
|
|
|
|
+ const isViewingTask = currentTaskId.value === task.id;
|
|
|
|
|
+ const sourceArr = isViewingTask ? messages.value : task.messages;
|
|
|
|
|
+ const lastMsg = sourceArr[sourceArr.length - 1];
|
|
|
if (!lastMsg || !lastMsg.isPendingApproval) return;
|
|
if (!lastMsg || !lastMsg.isPendingApproval) return;
|
|
|
|
|
|
|
|
|
|
+ // 若 taskPendingApprovals 已被清除,从消息的 pendingApprovalData 回退取值
|
|
|
|
|
+ if (!sessionId && lastMsg.pendingApprovalData) {
|
|
|
|
|
+ sessionId = lastMsg.pendingApprovalData.sessionId;
|
|
|
|
|
+ threadId = lastMsg.pendingApprovalData.threadId;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!sessionId) return;
|
|
|
|
|
+
|
|
|
lastMsg.isPendingApproval = false;
|
|
lastMsg.isPendingApproval = false;
|
|
|
lastMsg.pendingApprovalData = undefined;
|
|
lastMsg.pendingApprovalData = undefined;
|
|
|
lastMsg.isLoading = true;
|
|
lastMsg.isLoading = true;
|
|
|
lastMsg.generateStartTime = Date.now();
|
|
lastMsg.generateStartTime = Date.now();
|
|
|
|
|
|
|
|
- const aiMsgIndex = taskMessages.length - 1;
|
|
|
|
|
- task.messages = [...taskMessages];
|
|
|
|
|
- if (currentTaskId.value === task.id) {
|
|
|
|
|
- messages.value = task.messages;
|
|
|
|
|
|
|
+ const aiMsgIndex = sourceArr.length - 1;
|
|
|
|
|
+ // 强制触发 Vue 响应式更新
|
|
|
|
|
+ if (isViewingTask) {
|
|
|
|
|
+ messages.value = [...sourceArr];
|
|
|
}
|
|
}
|
|
|
|
|
+ task.messages = [...sourceArr];
|
|
|
|
|
|
|
|
task.isStreaming = true;
|
|
task.isStreaming = true;
|
|
|
showTodoBar.value = false;
|
|
showTodoBar.value = false;
|
|
@@ -718,14 +727,14 @@
|
|
|
const result = await chatResumeStream(
|
|
const result = await chatResumeStream(
|
|
|
{
|
|
{
|
|
|
session_id: sessionId,
|
|
session_id: sessionId,
|
|
|
- thread_id: approval.threadId || undefined,
|
|
|
|
|
|
|
+ thread_id: threadId || undefined,
|
|
|
status: 'answered',
|
|
status: 'answered',
|
|
|
answers,
|
|
answers,
|
|
|
signal: controller.signal,
|
|
signal: controller.signal,
|
|
|
},
|
|
},
|
|
|
- (chunk: string) => {
|
|
|
|
|
- parseSSEData(chunk, aiMsgIndex, task.id);
|
|
|
|
|
- if (currentTaskId.value === task.id) scrollToBottom();
|
|
|
|
|
|
|
+ (data: SseEvent) => {
|
|
|
|
|
+ parseSSEData(data, aiMsgIndex, task.id);
|
|
|
|
|
+ if (currentTaskId.value === task.id) scrollToBottomThrottled();
|
|
|
}
|
|
}
|
|
|
);
|
|
);
|
|
|
|
|
|
|
@@ -751,7 +760,10 @@
|
|
|
} finally {
|
|
} finally {
|
|
|
setTaskAbortController(task.id, null);
|
|
setTaskAbortController(task.id, null);
|
|
|
task.isStreaming = false;
|
|
task.isStreaming = false;
|
|
|
- clearTaskPendingApproval(task.id);
|
|
|
|
|
|
|
+ // 仅当消息不再处于审批状态时才清除(避免 resume 流返回新 interrupt 被误清)
|
|
|
|
|
+ if (!lastMsg.isPendingApproval) {
|
|
|
|
|
+ clearTaskPendingApproval(task.id);
|
|
|
|
|
+ }
|
|
|
if (currentTaskId.value === task.id) {
|
|
if (currentTaskId.value === task.id) {
|
|
|
await scrollToBottom();
|
|
await scrollToBottom();
|
|
|
}
|
|
}
|
|
@@ -759,20 +771,40 @@
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const handleAskUserCancel = async () => {
|
|
const handleAskUserCancel = async () => {
|
|
|
- const found = await findPendingApprovalTask();
|
|
|
|
|
- if (!found) return;
|
|
|
|
|
|
|
+ // 防重复操作:如果当前任务正在流式中,忽略
|
|
|
|
|
+ const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
|
|
+ if (currentTask?.isStreaming) return;
|
|
|
|
|
|
|
|
- const { approval } = found;
|
|
|
|
|
- const sessionId = approval.sessionId;
|
|
|
|
|
- if (!sessionId) return;
|
|
|
|
|
|
|
+ const found = findPendingApprovalTask();
|
|
|
|
|
|
|
|
- const task = taskList.value.find((t) => t.sessionId === sessionId);
|
|
|
|
|
|
|
+ // 优先从 taskPendingApprovals 获取,若被清除则从消息的 pendingApprovalData 回退
|
|
|
|
|
+ let sessionId = found?.approval.sessionId;
|
|
|
|
|
+ let threadId = found?.approval.threadId;
|
|
|
|
|
+ const foundTaskId = found?.taskId;
|
|
|
|
|
+
|
|
|
|
|
+ // 查找任务:优先用 foundTaskId,回退到 currentTaskId
|
|
|
|
|
+ let task = foundTaskId ? taskList.value.find((t) => t.id === foundTaskId) : null;
|
|
|
|
|
+ if (!task) task = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
if (!task) return;
|
|
if (!task) return;
|
|
|
|
|
|
|
|
- const taskMessages = task.messages;
|
|
|
|
|
- const lastMsg = taskMessages[taskMessages.length - 1];
|
|
|
|
|
|
|
+ // 切换到目标任务(如果不在当前视图)
|
|
|
|
|
+ if (task.id !== currentTaskId.value) {
|
|
|
|
|
+ await switchTask(task.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 使用正确的消息数组
|
|
|
|
|
+ const isViewingTask = currentTaskId.value === task.id;
|
|
|
|
|
+ const sourceArr = isViewingTask ? messages.value : task.messages;
|
|
|
|
|
+ const lastMsg = sourceArr[sourceArr.length - 1];
|
|
|
if (!lastMsg || !lastMsg.isPendingApproval) return;
|
|
if (!lastMsg || !lastMsg.isPendingApproval) return;
|
|
|
|
|
|
|
|
|
|
+ // 若 taskPendingApprovals 已被清除,从消息的 pendingApprovalData 回退取值
|
|
|
|
|
+ if (!sessionId && lastMsg.pendingApprovalData) {
|
|
|
|
|
+ sessionId = lastMsg.pendingApprovalData.sessionId;
|
|
|
|
|
+ threadId = lastMsg.pendingApprovalData.threadId;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!sessionId) return;
|
|
|
|
|
+
|
|
|
// 冻结累计耗时到 durationMs,避免取消后计时消失
|
|
// 冻结累计耗时到 durationMs,避免取消后计时消失
|
|
|
lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
|
|
lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
|
|
|
lastMsg.isPendingApproval = false;
|
|
lastMsg.isPendingApproval = false;
|
|
@@ -780,14 +812,17 @@
|
|
|
lastMsg.isLoading = false;
|
|
lastMsg.isLoading = false;
|
|
|
lastMsg.content = (lastMsg.content ? lastMsg.content + '\n\n' : '') + '已取消提问。';
|
|
lastMsg.content = (lastMsg.content ? lastMsg.content + '\n\n' : '') + '已取消提问。';
|
|
|
|
|
|
|
|
- task.messages = [...taskMessages];
|
|
|
|
|
|
|
+ task.messages = [...sourceArr];
|
|
|
|
|
+ if (isViewingTask) {
|
|
|
|
|
+ messages.value = task.messages;
|
|
|
|
|
+ }
|
|
|
clearTaskPendingApproval(task.id);
|
|
clearTaskPendingApproval(task.id);
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
await chatResumeStream(
|
|
await chatResumeStream(
|
|
|
{
|
|
{
|
|
|
session_id: sessionId,
|
|
session_id: sessionId,
|
|
|
- thread_id: approval.threadId || undefined,
|
|
|
|
|
|
|
+ thread_id: threadId || undefined,
|
|
|
status: 'cancelled',
|
|
status: 'cancelled',
|
|
|
},
|
|
},
|
|
|
() => {}
|
|
() => {}
|
|
@@ -924,21 +959,31 @@
|
|
|
originalFile: file,
|
|
originalFile: file,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ // Abort previous FileReader if still in progress
|
|
|
|
|
+ if (activeFileReader.value) {
|
|
|
|
|
+ activeFileReader.value.abort();
|
|
|
|
|
+ activeFileReader.value = null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const reader = new FileReader();
|
|
const reader = new FileReader();
|
|
|
- if (isTextFile(file.name) || isCsvFile(file.name)) {
|
|
|
|
|
- reader.onload = (e) => {
|
|
|
|
|
|
|
+ activeFileReader.value = reader;
|
|
|
|
|
+
|
|
|
|
|
+ reader.onload = (e) => {
|
|
|
|
|
+ activeFileReader.value = null;
|
|
|
|
|
+ if (isTextFile(file.name) || isCsvFile(file.name)) {
|
|
|
newFile.content = e.target?.result as string;
|
|
newFile.content = e.target?.result as string;
|
|
|
- };
|
|
|
|
|
|
|
+ } else if (isWordFile(file.name) || isExcelFile(file.name)) {
|
|
|
|
|
+ newFile.arrayBuffer = e.target?.result as ArrayBuffer;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ newFile.preview = e.target?.result as string;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (isTextFile(file.name) || isCsvFile(file.name)) {
|
|
|
reader.readAsText(file);
|
|
reader.readAsText(file);
|
|
|
} else if (isWordFile(file.name) || isExcelFile(file.name)) {
|
|
} else if (isWordFile(file.name) || isExcelFile(file.name)) {
|
|
|
- reader.onload = (e) => {
|
|
|
|
|
- newFile.arrayBuffer = e.target?.result as ArrayBuffer;
|
|
|
|
|
- };
|
|
|
|
|
reader.readAsArrayBuffer(file);
|
|
reader.readAsArrayBuffer(file);
|
|
|
} else {
|
|
} else {
|
|
|
- reader.onload = (e) => {
|
|
|
|
|
- newFile.preview = e.target?.result as string;
|
|
|
|
|
- };
|
|
|
|
|
reader.readAsDataURL(file);
|
|
reader.readAsDataURL(file);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1012,18 +1057,6 @@
|
|
|
if (nextTask) switchTask(nextTask.id);
|
|
if (nextTask) switchTask(nextTask.id);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const downloadWordReport = () => {
|
|
|
|
|
- const url = currentWordUrl.value;
|
|
|
|
|
- if (!url) return;
|
|
|
|
|
- const a = document.createElement('a');
|
|
|
|
|
- a.href = url;
|
|
|
|
|
- a.download = '';
|
|
|
|
|
- a.target = '_blank';
|
|
|
|
|
- document.body.appendChild(a);
|
|
|
|
|
- a.click();
|
|
|
|
|
- document.body.removeChild(a);
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
const downloadWordFile = (url: string) => {
|
|
const downloadWordFile = (url: string) => {
|
|
|
if (!url) return;
|
|
if (!url) return;
|
|
|
const a = document.createElement('a');
|
|
const a = document.createElement('a');
|
|
@@ -1035,6 +1068,8 @@
|
|
|
document.body.removeChild(a);
|
|
document.body.removeChild(a);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ const downloadWordReport = () => downloadWordFile(currentWordUrl.value);
|
|
|
|
|
+
|
|
|
const handleOpenFilePreview = (file: AttachedFile) => {
|
|
const handleOpenFilePreview = (file: AttachedFile) => {
|
|
|
previewFile.value = file;
|
|
previewFile.value = file;
|
|
|
previewEditable.value = true;
|
|
previewEditable.value = true;
|
|
@@ -1400,46 +1435,27 @@
|
|
|
answers: data.ask_user?.questions?.map(() => '') || [],
|
|
answers: data.ask_user?.questions?.map(() => '') || [],
|
|
|
};
|
|
};
|
|
|
setTaskPendingApproval(taskId, interruptSessionId, data.thread_id || '');
|
|
setTaskPendingApproval(taskId, interruptSessionId, data.thread_id || '');
|
|
|
|
|
+ // 直接初始化 ask_user 答案状态,不依赖 watch(避免任务切换/时序问题)
|
|
|
|
|
+ if (data.ask_user?.questions) {
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ chatMessagesRef.value?.initAskUserAnswers(aiMsgIndex, data.ask_user!.questions);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 保存到流式任务的 messages,并同步 UI
|
|
// 保存到流式任务的 messages,并同步 UI
|
|
|
|
|
+ // Always create a new array reference to trigger Vue reactivity
|
|
|
|
|
+ const updatedMessages = isViewingTask ? [...messages.value] : [...streamingTask.messages];
|
|
|
|
|
+ streamingTask.messages = updatedMessages;
|
|
|
if (isViewingTask) {
|
|
if (isViewingTask) {
|
|
|
- streamingTask.messages = [...messages.value];
|
|
|
|
|
- } else {
|
|
|
|
|
- streamingTask.messages = [...streamingTask.messages];
|
|
|
|
|
|
|
+ messages.value = updatedMessages;
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const parseSSEData = (chunk: string, aiMsgIndex: number, taskId: string) => {
|
|
|
|
|
- const lines = chunk.split('\n');
|
|
|
|
|
-
|
|
|
|
|
- for (const lineRaw of lines) {
|
|
|
|
|
- const line = lineRaw.trim();
|
|
|
|
|
- if (!line) continue;
|
|
|
|
|
-
|
|
|
|
|
- if (line.startsWith('data:')) {
|
|
|
|
|
- try {
|
|
|
|
|
- const jsonStr = line.substring(5).trim();
|
|
|
|
|
- const data = JSON.parse(jsonStr);
|
|
|
|
|
- handleSSEMessage(data, aiMsgIndex, taskId);
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- console.error('解析 SSE data 行失败:', error, line);
|
|
|
|
|
- }
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const data = JSON.parse(line);
|
|
|
|
|
- handleSSEMessage(data, aiMsgIndex, taskId);
|
|
|
|
|
- continue;
|
|
|
|
|
- } catch {
|
|
|
|
|
- // not json
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- handleSSEMessage({ type: 'model_thinking', content: line }, aiMsgIndex, taskId);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const parseSSEData = (data: SseEvent, aiMsgIndex: number, taskId: string) => {
|
|
|
|
|
+ handleSSEMessage(data, aiMsgIndex, taskId);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const sendTextOnly = async (userInput: string, displayContent?: string) => {
|
|
const sendTextOnly = async (userInput: string, displayContent?: string) => {
|
|
@@ -1485,9 +1501,9 @@
|
|
|
mode: editMode.value,
|
|
mode: editMode.value,
|
|
|
signal: controller.signal,
|
|
signal: controller.signal,
|
|
|
},
|
|
},
|
|
|
- (chunk: string) => {
|
|
|
|
|
- parseSSEData(chunk, aiMsgIndex, originTaskId);
|
|
|
|
|
- if (currentTaskId.value === originTaskId) scrollToBottom();
|
|
|
|
|
|
|
+ (data: SseEvent) => {
|
|
|
|
|
+ parseSSEData(data, aiMsgIndex, originTaskId);
|
|
|
|
|
+ if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
|
|
|
}
|
|
}
|
|
|
);
|
|
);
|
|
|
|
|
|
|
@@ -1502,14 +1518,22 @@
|
|
|
originTask.pendingSessionId = undefined;
|
|
originTask.pendingSessionId = undefined;
|
|
|
if (originTask.id !== result.session_id) {
|
|
if (originTask.id !== result.session_id) {
|
|
|
// 将 AbortController 从旧 ID 迁移到新 ID
|
|
// 将 AbortController 从旧 ID 迁移到新 ID
|
|
|
- const controller = taskAbortControllers.value.get(originTaskId);
|
|
|
|
|
|
|
+ const controller = taskAbortControllers.get(originTaskId);
|
|
|
if (controller) {
|
|
if (controller) {
|
|
|
setTaskAbortController(originTaskId, null);
|
|
setTaskAbortController(originTaskId, null);
|
|
|
setTaskAbortController(result.session_id, controller);
|
|
setTaskAbortController(result.session_id, controller);
|
|
|
}
|
|
}
|
|
|
taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
|
|
taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
|
|
|
originTask.id = result.session_id;
|
|
originTask.id = result.session_id;
|
|
|
- historyLoadedTasks.value.add(result.session_id);
|
|
|
|
|
|
|
+ historyLoadedTasks.add(result.session_id);
|
|
|
|
|
+ // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
|
|
|
|
|
+ const approvalEntry = taskPendingApprovals.value.get(originTaskId);
|
|
|
|
|
+ if (approvalEntry) {
|
|
|
|
|
+ const map = new Map(taskPendingApprovals.value);
|
|
|
|
|
+ map.delete(originTaskId);
|
|
|
|
|
+ map.set(result.session_id, approvalEntry);
|
|
|
|
|
+ taskPendingApprovals.value = map;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
if (currentTaskId.value === originTaskId) {
|
|
if (currentTaskId.value === originTaskId) {
|
|
|
currentTaskId.value = result.session_id;
|
|
currentTaskId.value = result.session_id;
|
|
@@ -1599,9 +1623,9 @@
|
|
|
mode: editMode.value,
|
|
mode: editMode.value,
|
|
|
signal: controller.signal,
|
|
signal: controller.signal,
|
|
|
},
|
|
},
|
|
|
- (chunk: string) => {
|
|
|
|
|
- parseSSEData(chunk, aiMsgIndex, originTaskId);
|
|
|
|
|
- if (currentTaskId.value === originTaskId) scrollToBottom();
|
|
|
|
|
|
|
+ (data: SseEvent) => {
|
|
|
|
|
+ parseSSEData(data, aiMsgIndex, originTaskId);
|
|
|
|
|
+ if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
|
|
|
}
|
|
}
|
|
|
);
|
|
);
|
|
|
|
|
|
|
@@ -1616,14 +1640,22 @@
|
|
|
originTask.pendingSessionId = undefined;
|
|
originTask.pendingSessionId = undefined;
|
|
|
if (originTask.id !== result.session_id) {
|
|
if (originTask.id !== result.session_id) {
|
|
|
// 将 AbortController 从旧 ID 迁移到新 ID
|
|
// 将 AbortController 从旧 ID 迁移到新 ID
|
|
|
- const controller = taskAbortControllers.value.get(originTaskId);
|
|
|
|
|
|
|
+ const controller = taskAbortControllers.get(originTaskId);
|
|
|
if (controller) {
|
|
if (controller) {
|
|
|
setTaskAbortController(originTaskId, null);
|
|
setTaskAbortController(originTaskId, null);
|
|
|
setTaskAbortController(result.session_id, controller);
|
|
setTaskAbortController(result.session_id, controller);
|
|
|
}
|
|
}
|
|
|
taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
|
|
taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
|
|
|
originTask.id = result.session_id;
|
|
originTask.id = result.session_id;
|
|
|
- historyLoadedTasks.value.add(result.session_id);
|
|
|
|
|
|
|
+ historyLoadedTasks.add(result.session_id);
|
|
|
|
|
+ // 迁移审批状态 key
|
|
|
|
|
+ const approvalEntry = taskPendingApprovals.value.get(originTaskId);
|
|
|
|
|
+ if (approvalEntry) {
|
|
|
|
|
+ const map = new Map(taskPendingApprovals.value);
|
|
|
|
|
+ map.delete(originTaskId);
|
|
|
|
|
+ map.set(result.session_id, approvalEntry);
|
|
|
|
|
+ taskPendingApprovals.value = map;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
if (currentTaskId.value === originTaskId) {
|
|
if (currentTaskId.value === originTaskId) {
|
|
|
currentTaskId.value = result.session_id;
|
|
currentTaskId.value = result.session_id;
|
|
@@ -1658,6 +1690,22 @@
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ // Throttled version for SSE streaming (no await, uses rAF)
|
|
|
|
|
+ let scrollRafId: number | null = null;
|
|
|
|
|
+ const scrollToBottomThrottled = (force = false) => {
|
|
|
|
|
+ if (scrollRafId) cancelAnimationFrame(scrollRafId);
|
|
|
|
|
+ scrollRafId = requestAnimationFrame(async () => {
|
|
|
|
|
+ scrollRafId = null;
|
|
|
|
|
+ await nextTick();
|
|
|
|
|
+ if (!force && !chatMessagesRef.value?.isAtBottom) return;
|
|
|
|
|
+ const el = chatMessagesRef.value?.messagesRef;
|
|
|
|
|
+ if (el) {
|
|
|
|
|
+ el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Async version for explicit await calls
|
|
|
const scrollToBottom = async (force = false) => {
|
|
const scrollToBottom = async (force = false) => {
|
|
|
await nextTick();
|
|
await nextTick();
|
|
|
if (!force && !chatMessagesRef.value?.isAtBottom) return;
|
|
if (!force && !chatMessagesRef.value?.isAtBottom) return;
|
|
@@ -1710,14 +1758,14 @@
|
|
|
currentTaskId.value = targetTask.id;
|
|
currentTaskId.value = targetTask.id;
|
|
|
|
|
|
|
|
// 如果是历史任务,加载其消息
|
|
// 如果是历史任务,加载其消息
|
|
|
- if (targetTask.sessionId && !historyLoadedTasks.value.has(targetTask.id)) {
|
|
|
|
|
|
|
+ if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
|
|
|
try {
|
|
try {
|
|
|
const res = await getDetail(targetTask.sessionId);
|
|
const res = await getDetail(targetTask.sessionId);
|
|
|
const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
|
|
const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
|
|
|
if (Array.isArray(messages)) {
|
|
if (Array.isArray(messages)) {
|
|
|
targetTask.messages = transformHistoryToMessages(messages);
|
|
targetTask.messages = transformHistoryToMessages(messages);
|
|
|
}
|
|
}
|
|
|
- historyLoadedTasks.value.add(targetTask.id);
|
|
|
|
|
|
|
+ historyLoadedTasks.add(targetTask.id);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error('加载历史会话失败:', error);
|
|
console.error('加载历史会话失败:', error);
|
|
|
}
|
|
}
|
|
@@ -1822,15 +1870,16 @@
|
|
|
}
|
|
}
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
- onMounted(async () => {
|
|
|
|
|
- // fetchSessionList moved to watch(visible) to avoid unnecessary API calls
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
onBeforeUnmount(() => {
|
|
onBeforeUnmount(() => {
|
|
|
document.removeEventListener('mousemove', onDragMove);
|
|
document.removeEventListener('mousemove', onDragMove);
|
|
|
document.removeEventListener('mouseup', onDragEnd);
|
|
document.removeEventListener('mouseup', onDragEnd);
|
|
|
// 组件卸载时主动断开所有任务的流式连接
|
|
// 组件卸载时主动断开所有任务的流式连接
|
|
|
- taskAbortControllers.value.forEach((controller) => controller.abort());
|
|
|
|
|
|
|
+ taskAbortControllers.forEach((controller) => controller.abort());
|
|
|
|
|
+ // Abort in-flight FileReader if any
|
|
|
|
|
+ if (activeFileReader.value) {
|
|
|
|
|
+ activeFileReader.value.abort();
|
|
|
|
|
+ activeFileReader.value = null;
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|