|
|
@@ -7,6 +7,7 @@
|
|
|
destroyOnClose
|
|
|
:closable="showControls"
|
|
|
:zIndex="props.zIndex"
|
|
|
+ :get-container="getModalContainer"
|
|
|
:class="['ai-assistant-modal', { fullscreen: isFullscreen }]"
|
|
|
>
|
|
|
<template #title>
|
|
|
@@ -51,11 +52,19 @@
|
|
|
:canGoPrev="canGoPrev"
|
|
|
:canGoNext="canGoNext"
|
|
|
:currentWordUrl="currentWordUrl"
|
|
|
+ :capsule-files="capsuleFiles"
|
|
|
+ :capsule-links="capsuleLinks"
|
|
|
+ :capsule-agents="capsuleAgents"
|
|
|
@go-to-prev="goToPrev"
|
|
|
@go-to-next="goToNext"
|
|
|
@create-task="createNewTask"
|
|
|
@save-title="handleSaveTitle"
|
|
|
@download-word="downloadWordReport"
|
|
|
+ @export-md="handleExportMarkdown"
|
|
|
+ @export-word="handleExportWord"
|
|
|
+ @file-preview="openFilePreview"
|
|
|
+ @agent-detail-click="handleAgentDetailClick"
|
|
|
+ @locate-message="handleLocateMessage"
|
|
|
/>
|
|
|
|
|
|
<!-- 附件列表面板 -->
|
|
|
@@ -73,6 +82,8 @@
|
|
|
@agent-detail-click="handleAgentDetailClick"
|
|
|
@open-markdown-tab="openMarkdownPreviewTab"
|
|
|
@delete-message="handleDeleteMessage"
|
|
|
+ @regenerate="handleRegenerate"
|
|
|
+ @edit-resend="handleEditResend"
|
|
|
/>
|
|
|
|
|
|
<!-- 任务进度条 -->
|
|
|
@@ -192,6 +203,8 @@
|
|
|
import type { TodayUsage } from '../api';
|
|
|
import type { AttachedFile, Message, Task, RightPanelTab, RightPanelState, SseEvent } from './chatModal/types';
|
|
|
import { isTextFile, isVideoFile, isAudioFile, isWordFile, isExcelFile, isCsvFile } from './chatModal/utils';
|
|
|
+ import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from './chatModal/capsuleData';
|
|
|
+ import { buildConversationMarkdown, markdownToDocxBlob } from './chatModal/exportDoc';
|
|
|
|
|
|
interface Props {
|
|
|
visible: boolean;
|
|
|
@@ -200,6 +213,8 @@
|
|
|
// 是否显示窗口控制按钮(最大化/还原、关闭)
|
|
|
showControls?: boolean;
|
|
|
zIndex?: number;
|
|
|
+ // 挂载容器:不传则默认挂到 body;首页等场景传入 adaptive-container,使其与页面同坐标系、同缩放
|
|
|
+ getContainer?: () => HTMLElement;
|
|
|
}
|
|
|
|
|
|
interface Emits {
|
|
|
@@ -212,6 +227,20 @@
|
|
|
});
|
|
|
const emit = defineEmits<Emits>();
|
|
|
|
|
|
+ // 弹框真正渲染/被 antd 滚动锁锁定的目标:包一层 size-0 空壳,
|
|
|
+ // 避免 antd Modal 打开时滚动锁直接改写 #adaptive-container 的宽度(导致整页缩放错乱、露出白边)
|
|
|
+ const portalWrapper = ref<HTMLDivElement | null>(null);
|
|
|
+
|
|
|
+ const getModalContainer = (): HTMLElement => {
|
|
|
+ if (portalWrapper.value) return portalWrapper.value;
|
|
|
+ const target = props.getContainer ? props.getContainer() : document.body;
|
|
|
+ const wrapper = document.createElement('div');
|
|
|
+ wrapper.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;';
|
|
|
+ target.appendChild(wrapper);
|
|
|
+ portalWrapper.value = wrapper;
|
|
|
+ return wrapper;
|
|
|
+ };
|
|
|
+
|
|
|
const taskList = ref<Task[]>([]);
|
|
|
|
|
|
// 已从接口拉取过的会话 id,用于识别流式期间后端新建的会话
|
|
|
@@ -430,13 +459,6 @@
|
|
|
|
|
|
const isFullscreen = ref(!!props.defaultFullscreen);
|
|
|
|
|
|
- // 响应式视口宽度,用于计算 modal 最大宽度不超过屏幕
|
|
|
- const viewportWidth = ref(window.innerWidth);
|
|
|
- const onResize = () => {
|
|
|
- viewportWidth.value = window.innerWidth;
|
|
|
- };
|
|
|
- window.addEventListener('resize', onResize);
|
|
|
-
|
|
|
const currentTask = computed(() => taskList.value.find((t) => t.id === currentTaskId.value));
|
|
|
|
|
|
const currentRightPanels = computed<RightPanelState>(() => {
|
|
|
@@ -454,10 +476,14 @@
|
|
|
const isPanelLeaving = ref(false);
|
|
|
const modalShouldExpand = computed(() => hasRightPanel.value || isPanelLeaving.value);
|
|
|
|
|
|
- // modal 宽度:全屏100vw / 有右侧面板时1600px(不超视口)/ 默认1400px
|
|
|
+ // modal 宽度:全屏时用容器实际宽度(容器自带 scale 统一缩放适配视口),挂 body 时用 100vw;
|
|
|
+ // 有右侧面板时 1600 / 默认 1400(设计稿像素)。高度由 CSS 的 height:100% 撑满容器
|
|
|
const modalWidth = computed(() => {
|
|
|
- if (isFullscreen.value) return '100vw';
|
|
|
- if (modalShouldExpand.value) return Math.min(1600, viewportWidth.value);
|
|
|
+ if (isFullscreen.value) {
|
|
|
+ const container = props.getContainer ? props.getContainer() : document.body;
|
|
|
+ return container === document.body ? '100vw' : `${container.clientWidth}px`;
|
|
|
+ }
|
|
|
+ if (modalShouldExpand.value) return 1600;
|
|
|
return 1400;
|
|
|
});
|
|
|
|
|
|
@@ -1090,12 +1116,17 @@
|
|
|
});
|
|
|
};
|
|
|
|
|
|
+ // 胶囊面板"定位到消息":滚动消息列表到该智能体所在的消息
|
|
|
+ const handleLocateMessage = (messageIndex: number) => {
|
|
|
+ chatMessagesRef.value?.scrollToMessage(messageIndex, 'center');
|
|
|
+ };
|
|
|
+
|
|
|
// 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
|
|
|
const handleDeleteMessage = (msg: Message) => {
|
|
|
if (!msg.id) return;
|
|
|
Modal.confirm({
|
|
|
title: '提示',
|
|
|
- content: '删除后该消息将不再进入模型上下文,是否继续?',
|
|
|
+ content: '是否删除该条消息?',
|
|
|
okText: '确认',
|
|
|
cancelText: '取消',
|
|
|
wrapClassName: 'delete-task-confirm-modal',
|
|
|
@@ -1140,13 +1171,7 @@
|
|
|
const msgIndex = messages.value.findIndex((m) => m.attachedFile?.id === file.id);
|
|
|
if (msgIndex !== -1) {
|
|
|
nextTick(() => {
|
|
|
- const messageElements = chatMessagesRef.value?.messagesRef?.querySelectorAll('.message-wrapper');
|
|
|
- if (messageElements && messageElements[msgIndex]) {
|
|
|
- (messageElements[msgIndex] as HTMLElement).scrollIntoView({
|
|
|
- behavior: 'smooth',
|
|
|
- block: 'center',
|
|
|
- });
|
|
|
- }
|
|
|
+ chatMessagesRef.value?.scrollToMessage(msgIndex, 'center');
|
|
|
});
|
|
|
}
|
|
|
};
|
|
|
@@ -1198,6 +1223,11 @@
|
|
|
removeTab(task.rightPanels, tabId);
|
|
|
};
|
|
|
|
|
|
+ // 胶囊聚合数据:转换逻辑内聚在 capsuleData 纯函数模块中,此处只做响应式接线
|
|
|
+ const capsuleFiles = computed(() => buildCapsuleFiles(currentTask.value, messages.value));
|
|
|
+ const capsuleLinks = computed(() => buildCapsuleLinks(capsuleFiles.value, messages.value));
|
|
|
+ const capsuleAgents = computed(() => buildCapsuleAgents(messages.value, (i) => chatMessagesRef.value?.isAnswerVisible(i)));
|
|
|
+
|
|
|
const currentWordUrl = computed(() => {
|
|
|
return currentTask.value?.wordUrl || '';
|
|
|
});
|
|
|
@@ -1688,20 +1718,11 @@
|
|
|
handleSSEMessage(data, aiMsgIndex, taskId);
|
|
|
};
|
|
|
|
|
|
- const sendTextOnly = async (userInput: string, displayContent?: string) => {
|
|
|
+ // 发送流式 AI 回答的核心:推送一条空的 AI 消息 → 建 AbortController → 调 SSE → 处理 session_id 迁移 → 异常/清理。
|
|
|
+ // 正常发送、编辑重发、重新生成都复用此函数,file 存在且带 originalFile 时走 PDF 审查流,否则走纯文本流。
|
|
|
+ const streamAiAnswer = async (userInput: string, file?: AttachedFile) => {
|
|
|
const originTaskId = currentTaskId.value;
|
|
|
-
|
|
|
const now = dayjs();
|
|
|
- const userMsg: Message = {
|
|
|
- type: 'user',
|
|
|
- content: displayContent || userInput,
|
|
|
- time: now.format('HH:mm'),
|
|
|
- createdAt: now.toISOString(),
|
|
|
- };
|
|
|
- messages.value.push(userMsg);
|
|
|
- await nextTick();
|
|
|
- chatMessagesRef.value?.forceScrollToBottom();
|
|
|
-
|
|
|
const aiMsgIndex = messages.value.length;
|
|
|
const aiMsg: Message = {
|
|
|
type: 'ai',
|
|
|
@@ -1723,19 +1744,33 @@
|
|
|
const controller = new AbortController();
|
|
|
setTaskAbortController(originTaskId, controller);
|
|
|
|
|
|
+ const onChunk = (data: SseEvent) => {
|
|
|
+ parseSSEData(data, aiMsgIndex, originTaskId);
|
|
|
+ if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
|
|
|
+ };
|
|
|
+
|
|
|
try {
|
|
|
- const result = await unifiedStream(
|
|
|
- {
|
|
|
- message: userInput,
|
|
|
- session_id: getCurrentSessionId() || undefined,
|
|
|
- mode: editMode.value,
|
|
|
- signal: controller.signal,
|
|
|
- },
|
|
|
- (data: SseEvent) => {
|
|
|
- parseSSEData(data, aiMsgIndex, originTaskId);
|
|
|
- if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
|
|
|
- }
|
|
|
- );
|
|
|
+ const originalFile = file?.originalFile;
|
|
|
+ const result = originalFile
|
|
|
+ ? await reviewPdfStream(
|
|
|
+ {
|
|
|
+ file: originalFile,
|
|
|
+ session_id: getCurrentSessionId() || undefined,
|
|
|
+ message: userInput || undefined,
|
|
|
+ mode: editMode.value,
|
|
|
+ signal: controller.signal,
|
|
|
+ },
|
|
|
+ onChunk
|
|
|
+ )
|
|
|
+ : await unifiedStream(
|
|
|
+ {
|
|
|
+ message: userInput,
|
|
|
+ session_id: getCurrentSessionId() || undefined,
|
|
|
+ mode: editMode.value,
|
|
|
+ signal: controller.signal,
|
|
|
+ },
|
|
|
+ onChunk
|
|
|
+ );
|
|
|
|
|
|
if (result.session_id) {
|
|
|
const originTask = taskList.value.find((t) => t.id === originTaskId);
|
|
|
@@ -1743,7 +1778,8 @@
|
|
|
originTask.sessionId = result.session_id;
|
|
|
// 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
|
|
|
if (originTask.pendingSessionId !== result.session_id) {
|
|
|
- originTask.name = userInput.substring(0, 20) + (userInput.length > 20 ? '...' : '');
|
|
|
+ const taskName = userInput || (file ? `文件:${file.name}` : '');
|
|
|
+ originTask.name = taskName.substring(0, 20) + (taskName.length > 20 ? '...' : '');
|
|
|
}
|
|
|
originTask.pendingSessionId = undefined;
|
|
|
if (originTask.id !== result.session_id) {
|
|
|
@@ -1798,9 +1834,25 @@
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ const sendTextOnly = async (userInput: string, displayContent?: string) => {
|
|
|
+ const now = dayjs();
|
|
|
+ const userMsg: Message = {
|
|
|
+ type: 'user',
|
|
|
+ content: displayContent || userInput,
|
|
|
+ rawInput: userInput,
|
|
|
+ time: now.format('HH:mm'),
|
|
|
+ createdAt: now.toISOString(),
|
|
|
+ };
|
|
|
+ messages.value.push(userMsg);
|
|
|
+ await nextTick();
|
|
|
+ chatMessagesRef.value?.forceScrollToBottom();
|
|
|
+
|
|
|
+ await streamAiAnswer(userInput);
|
|
|
+ };
|
|
|
+
|
|
|
const sendWithAttachment = async (file: AttachedFile, userInput: string, displayContent?: string) => {
|
|
|
const originTaskId = currentTaskId.value;
|
|
|
- let currentTask = taskList.value.find((t) => t.id === originTaskId);
|
|
|
+ const currentTask = taskList.value.find((t) => t.id === originTaskId);
|
|
|
if (currentTask) {
|
|
|
currentTask.attachedFiles.push(file);
|
|
|
} else {
|
|
|
@@ -1811,6 +1863,7 @@
|
|
|
const userMsg: Message = {
|
|
|
type: 'user',
|
|
|
content: displayContent || userInput || `上传了文件:${file.name}`,
|
|
|
+ rawInput: userInput,
|
|
|
time: now.format('HH:mm'),
|
|
|
createdAt: now.toISOString(),
|
|
|
attachedFile: file,
|
|
|
@@ -1819,104 +1872,192 @@
|
|
|
await nextTick();
|
|
|
chatMessagesRef.value?.forceScrollToBottom();
|
|
|
|
|
|
- const aiMsgIndex = messages.value.length;
|
|
|
- const aiMsg: Message = {
|
|
|
- type: 'ai',
|
|
|
- content: '',
|
|
|
- time: now.format('HH:mm'),
|
|
|
- createdAt: now.toISOString(),
|
|
|
- isLoading: true,
|
|
|
- thinkingSteps: [],
|
|
|
- generateStartTime: Date.now(),
|
|
|
- };
|
|
|
- messages.value.push(aiMsg);
|
|
|
-
|
|
|
- showTodoBar.value = false;
|
|
|
- currentTodos.value = [];
|
|
|
-
|
|
|
+ // 附件缺原始文件对象(如历史消息回填)时降级为纯文本重发
|
|
|
if (!file.originalFile) {
|
|
|
- throw new Error('缺少原始文件对象');
|
|
|
+ await streamAiAnswer(userInput);
|
|
|
+ return;
|
|
|
}
|
|
|
|
|
|
- const originTask = taskList.value.find((t) => t.id === originTaskId);
|
|
|
- if (originTask) originTask.isStreaming = true;
|
|
|
+ await streamAiAnswer(userInput, file);
|
|
|
+ };
|
|
|
|
|
|
- const controller = new AbortController();
|
|
|
- setTaskAbortController(originTaskId, controller);
|
|
|
+ // 剥离用户消息里注入的技能/会话引用 <img> HTML(rawInput 缺失时的兜底)
|
|
|
+ const stripInjectedRefs = (content: string): string =>
|
|
|
+ (content || '')
|
|
|
+ .replace(/\[<img[^>]*>[^\]]*\]/g, '')
|
|
|
+ .replace(/<img[^>]*>/g, '')
|
|
|
+ .trim();
|
|
|
|
|
|
+ // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
|
|
|
+ // 用 getDetail 返回的列表按尾部对齐回填,避免编辑重发/重新生成删除时后端残留重复记录。
|
|
|
+ const hydrateMessageIds = async () => {
|
|
|
+ const sessionId = getCurrentSessionId();
|
|
|
+ if (!sessionId) return;
|
|
|
try {
|
|
|
- const result = await reviewPdfStream(
|
|
|
- {
|
|
|
- file: file.originalFile!,
|
|
|
- session_id: getCurrentSessionId() || undefined,
|
|
|
- message: userInput || undefined,
|
|
|
- mode: editMode.value,
|
|
|
- signal: controller.signal,
|
|
|
- },
|
|
|
- (data: SseEvent) => {
|
|
|
- parseSSEData(data, aiMsgIndex, originTaskId);
|
|
|
- if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
|
|
|
+ const res = await getDetail(sessionId);
|
|
|
+ const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
|
|
|
+ if (!Array.isArray(raw)) return;
|
|
|
+ // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
|
|
|
+ const history = transformHistoryToMessages(raw);
|
|
|
+ const ids = history.map((m) => m.id).filter((id): id is number => id != null);
|
|
|
+ if (!ids.length) return;
|
|
|
+ const msgs = messages.value;
|
|
|
+ const offset = ids.length - msgs.length;
|
|
|
+ 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];
|
|
|
}
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('补齐消息 id 失败:', e);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)
|
|
|
+ const removeDuplicateRegenerateUserMessage = async (expectedContent: string) => {
|
|
|
+ const sessionId = getCurrentSessionId();
|
|
|
+ if (!sessionId) return;
|
|
|
+ try {
|
|
|
+ const res = await getDetail(sessionId);
|
|
|
+ const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
|
|
|
+ if (!Array.isArray(raw)) return;
|
|
|
+
|
|
|
+ // 保留第一条 user(原始提问),删除其余与重发内容一致的重复 user,避免多次重新生成残留
|
|
|
+ const userMsgs = raw.filter((m: any) => m?.role === 'user' || m?.type === 'user_message');
|
|
|
+ const duplicates = userMsgs.filter(
|
|
|
+ (m: any, idx: number) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
|
|
|
);
|
|
|
+ for (const dup of duplicates) {
|
|
|
+ await deleteMessage(dup.id);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('删除重发产生的重复用户消息失败:', e);
|
|
|
+ }
|
|
|
+ };
|
|
|
|
|
|
- if (result.session_id) {
|
|
|
- const originTask = taskList.value.find((t) => t.id === originTaskId);
|
|
|
- if (originTask && !originTask.sessionId) {
|
|
|
- originTask.sessionId = result.session_id;
|
|
|
- if (originTask.pendingSessionId !== result.session_id) {
|
|
|
- const taskName = userInput || `文件:${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);
|
|
|
- }
|
|
|
- taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
|
|
|
- originTask.id = 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) {
|
|
|
- currentTaskId.value = result.session_id;
|
|
|
- }
|
|
|
- }
|
|
|
+ // 重新生成最后一条回答
|
|
|
+ const handleRegenerate = async () => {
|
|
|
+ const task = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
+ if (!task || task.isStreaming) return;
|
|
|
+ if (currentPendingApproval.value) {
|
|
|
+ message.warning('请先审批执行计划');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const msgs = messages.value;
|
|
|
+ const lastAiIdx = msgs.length - 1;
|
|
|
+ if (lastAiIdx < 0 || msgs[lastAiIdx].type !== 'ai') return;
|
|
|
+
|
|
|
+ // 找前一条用户消息作为重发内容
|
|
|
+ let userIdx = -1;
|
|
|
+ for (let i = lastAiIdx - 1; i >= 0; i--) {
|
|
|
+ if (msgs[i].type === 'user') {
|
|
|
+ userIdx = i;
|
|
|
+ break;
|
|
|
}
|
|
|
- } catch (error) {
|
|
|
- // 用户主动停止生成
|
|
|
- if ((error as Error)?.name === 'AbortError') {
|
|
|
- const originTask = taskList.value.find((t) => t.id === originTaskId);
|
|
|
- const arr = currentTaskId.value === originTaskId ? messages.value : 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 = '已停止生成。';
|
|
|
- }
|
|
|
- }
|
|
|
- if (originTask && currentTaskId.value !== originTaskId) {
|
|
|
- originTask.messages = [...(arr || [])];
|
|
|
- }
|
|
|
- return;
|
|
|
+ }
|
|
|
+ if (userIdx < 0) return;
|
|
|
+
|
|
|
+ const userMsg = msgs[userIdx];
|
|
|
+ const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
|
|
|
+ const attachedFile = userMsg.attachedFile;
|
|
|
+
|
|
|
+ // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成
|
|
|
+ await streamAiAnswer(userInput, attachedFile);
|
|
|
+ // 删除重发产生的重复用户消息,形成「一次提问、多次回答」
|
|
|
+ await removeDuplicateRegenerateUserMessage(userInput);
|
|
|
+ // 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
|
|
|
+ await hydrateMessageIds();
|
|
|
+ };
|
|
|
+
|
|
|
+ // 编辑用户消息并重发(截断该消息及其后所有消息)
|
|
|
+ const handleEditResend = async (index: number, newContent: string) => {
|
|
|
+ const task = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
+ if (!task || task.isStreaming) return;
|
|
|
+ if (currentPendingApproval.value) {
|
|
|
+ message.warning('请先审批执行计划');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const msgs = messages.value;
|
|
|
+ if (index < 0 || index >= msgs.length || msgs[index].type !== 'user') return;
|
|
|
+ const trimmed = (newContent || '').trim();
|
|
|
+ if (!trimmed) {
|
|
|
+ message.warning('消息内容不能为空');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const attachedFile = msgs[index].attachedFile;
|
|
|
+
|
|
|
+ await hydrateMessageIds();
|
|
|
+ // 从后往前删除后端记录(有 id 的才调接口)
|
|
|
+ const toDelete = messages.value.slice(index).filter((m) => m.id != null);
|
|
|
+ for (const m of toDelete) {
|
|
|
+ try {
|
|
|
+ await deleteMessage(m.id!);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('删除消息失败:', e);
|
|
|
}
|
|
|
- throw error;
|
|
|
- } finally {
|
|
|
- // 任务在流结束时可能已被改名为后端 session_id,需要使用最终的 ID 清理状态
|
|
|
- const finalTaskId = originTask?.id || originTaskId;
|
|
|
- setTaskAbortController(finalTaskId, null);
|
|
|
- if (originTask) originTask.isStreaming = false;
|
|
|
+ }
|
|
|
+ // 本地截断到编辑位置
|
|
|
+ messages.value = messages.value.slice(0, index);
|
|
|
+ task.messages = [...messages.value];
|
|
|
+ const now = dayjs();
|
|
|
+ const userMsg: Message = {
|
|
|
+ type: 'user',
|
|
|
+ content: trimmed,
|
|
|
+ rawInput: trimmed,
|
|
|
+ time: now.format('HH:mm'),
|
|
|
+ createdAt: now.toISOString(),
|
|
|
+ attachedFile,
|
|
|
+ };
|
|
|
+ messages.value.push(userMsg);
|
|
|
+ task.messages = [...messages.value];
|
|
|
+ await nextTick();
|
|
|
+ chatMessagesRef.value?.forceScrollToBottom();
|
|
|
+ await streamAiAnswer(trimmed, attachedFile);
|
|
|
+ };
|
|
|
+
|
|
|
+ const sanitizeFileName = (name: string): string => (name || '会话').replace(/[\\/:*?"<>|]/g, '_');
|
|
|
+
|
|
|
+ const downloadBlob = (blob: Blob, filename: string) => {
|
|
|
+ const url = URL.createObjectURL(blob);
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = url;
|
|
|
+ a.download = filename;
|
|
|
+ document.body.appendChild(a);
|
|
|
+ a.click();
|
|
|
+ document.body.removeChild(a);
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 导出会话为 Markdown
|
|
|
+ const handleExportMarkdown = () => {
|
|
|
+ if (!messages.value.length) {
|
|
|
+ message.warning('当前会话暂无内容');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const md = buildConversationMarkdown(messages.value);
|
|
|
+ const filename = `${sanitizeFileName(currentTask.value?.name || '会话')}_${dayjs().format('YYYYMMDD_HHmmss')}.md`;
|
|
|
+ downloadBlob(new Blob([md], { type: 'text/markdown;charset=utf-8;' }), filename);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 导出会话为 Word (.docx)
|
|
|
+ const handleExportWord = async () => {
|
|
|
+ if (!messages.value.length) {
|
|
|
+ message.warning('当前会话暂无内容');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const md = buildConversationMarkdown(messages.value);
|
|
|
+ const title = currentTask.value?.name || '会话';
|
|
|
+ const blob = await markdownToDocxBlob(md, title);
|
|
|
+ const filename = `${sanitizeFileName(title)}_${dayjs().format('YYYYMMDD_HHmmss')}.docx`;
|
|
|
+ downloadBlob(blob, filename);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('导出 Word 失败:', e);
|
|
|
+ message.error('导出 Word 失败');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
@@ -2033,6 +2174,9 @@
|
|
|
let dragStartY = 0;
|
|
|
let modalStartLeft = 0;
|
|
|
let modalStartTop = 0;
|
|
|
+ // 容器 scale 缩放比(视觉尺寸/布局尺寸),用于把鼠标视口坐标换算回布局坐标
|
|
|
+ let dragScaleX = 1;
|
|
|
+ let dragScaleY = 1;
|
|
|
let draggedEl: HTMLElement | null = null;
|
|
|
|
|
|
const findModalWrap = (target: HTMLElement): HTMLElement | null =>
|
|
|
@@ -2069,17 +2213,20 @@
|
|
|
draggedEl = findModalWrap(e.target as HTMLElement) || queryModalWrap();
|
|
|
if (!draggedEl) return;
|
|
|
|
|
|
+ // 记录容器缩放比,并把鼠标/弹框的视口坐标换算回布局坐标,避免不同分辨率下拖拽位移失真
|
|
|
+ const rect = draggedEl.getBoundingClientRect();
|
|
|
+ dragScaleX = draggedEl.offsetWidth ? rect.width / draggedEl.offsetWidth : 1;
|
|
|
+ dragScaleY = draggedEl.offsetHeight ? rect.height / draggedEl.offsetHeight : 1;
|
|
|
+
|
|
|
isDragging = true;
|
|
|
dragStartX = e.clientX;
|
|
|
dragStartY = e.clientY;
|
|
|
-
|
|
|
- const rect = draggedEl.getBoundingClientRect();
|
|
|
- modalStartLeft = rect.left;
|
|
|
- modalStartTop = rect.top;
|
|
|
+ modalStartLeft = rect.left / dragScaleX;
|
|
|
+ modalStartTop = rect.top / dragScaleY;
|
|
|
|
|
|
draggedEl.style.position = 'fixed';
|
|
|
- draggedEl.style.left = `${rect.left}px`;
|
|
|
- draggedEl.style.top = `${rect.top}px`;
|
|
|
+ draggedEl.style.left = `${modalStartLeft}px`;
|
|
|
+ draggedEl.style.top = `${modalStartTop}px`;
|
|
|
draggedEl.style.margin = '0';
|
|
|
draggedEl.style.transform = 'none';
|
|
|
|
|
|
@@ -2089,8 +2236,8 @@
|
|
|
|
|
|
const onDragMove = (e: MouseEvent) => {
|
|
|
if (!isDragging || !draggedEl) return;
|
|
|
- draggedEl.style.left = `${modalStartLeft + (e.clientX - dragStartX)}px`;
|
|
|
- draggedEl.style.top = `${modalStartTop + (e.clientY - dragStartY)}px`;
|
|
|
+ draggedEl.style.left = `${modalStartLeft + (e.clientX - dragStartX) / dragScaleX}px`;
|
|
|
+ draggedEl.style.top = `${modalStartTop + (e.clientY - dragStartY) / dragScaleY}px`;
|
|
|
};
|
|
|
|
|
|
const onDragEnd = () => {
|
|
|
@@ -2111,7 +2258,9 @@
|
|
|
onBeforeUnmount(() => {
|
|
|
document.removeEventListener('mousemove', onDragMove);
|
|
|
document.removeEventListener('mouseup', onDragEnd);
|
|
|
- window.removeEventListener('resize', onResize);
|
|
|
+ // 移除挂载空壳,避免残留
|
|
|
+ portalWrapper.value?.remove();
|
|
|
+ portalWrapper.value = null;
|
|
|
// 组件卸载时主动断开所有任务的流式连接
|
|
|
taskAbortControllers.forEach((controller) => controller.abort());
|
|
|
// Abort in-flight FileReader if any
|
|
|
@@ -2343,8 +2492,9 @@
|
|
|
|
|
|
&.fullscreen {
|
|
|
top: 0 !important;
|
|
|
+ height: 100% !important;
|
|
|
padding: 0 !important;
|
|
|
- max-width: 100vw !important;
|
|
|
+ max-width: none !important;
|
|
|
margin: 0 !important;
|
|
|
background-color: #09172c;
|
|
|
|
|
|
@@ -2354,11 +2504,15 @@
|
|
|
margin: 0 !important;
|
|
|
top: 0 !important;
|
|
|
}
|
|
|
+ .zxm-modal-close,
|
|
|
+ .maximize-btn {
|
|
|
+ top: 40px;
|
|
|
+ }
|
|
|
|
|
|
.zxm-modal-content {
|
|
|
- width: 100vw !important;
|
|
|
- height: 100vh !important;
|
|
|
- max-width: 100vw !important;
|
|
|
+ width: 100% !important;
|
|
|
+ height: 100% !important;
|
|
|
+ max-width: 100% !important;
|
|
|
margin: 0;
|
|
|
border-radius: 0;
|
|
|
overflow: hidden;
|
|
|
@@ -2379,7 +2533,7 @@
|
|
|
height: 90px;
|
|
|
}
|
|
|
.zxm-modal-body {
|
|
|
- height: calc(100vh - 120px);
|
|
|
+ height: calc(100% - 120px);
|
|
|
overflow: hidden;
|
|
|
padding: 0 24px 12px;
|
|
|
}
|
|
|
@@ -2506,7 +2660,7 @@
|
|
|
.delete-task-confirm-modal {
|
|
|
.zxm-modal-confirm-title,
|
|
|
.zxm-modal-confirm-content {
|
|
|
- color: #fff;
|
|
|
+ color: #fff !important;
|
|
|
}
|
|
|
}
|
|
|
</style>
|