|
|
@@ -53,6 +53,8 @@
|
|
|
@create-task="createNewTask"
|
|
|
@save-title="handleSaveTitle"
|
|
|
@download-word="downloadWordReport"
|
|
|
+ @export-md="handleExportMarkdown"
|
|
|
+ @export-word="handleExportWord"
|
|
|
/>
|
|
|
|
|
|
<!-- 附件列表面板 -->
|
|
|
@@ -70,6 +72,8 @@
|
|
|
@agent-detail-click="handleAgentDetailClick"
|
|
|
@open-markdown-tab="openMarkdownPreviewTab"
|
|
|
@delete-message="handleDeleteMessage"
|
|
|
+ @regenerate="handleRegenerate"
|
|
|
+ @edit-resend="handleEditResend"
|
|
|
/>
|
|
|
|
|
|
<!-- 任务进度条 -->
|
|
|
@@ -185,6 +189,7 @@
|
|
|
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 { buildConversationMarkdown, markdownToDocxBlob } from './chatModal/exportDoc';
|
|
|
|
|
|
interface Props {
|
|
|
visible: boolean;
|
|
|
@@ -1651,20 +1656,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',
|
|
|
@@ -1686,19 +1682,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);
|
|
|
@@ -1706,7 +1716,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) {
|
|
|
@@ -1761,9 +1772,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 {
|
|
|
@@ -1774,6 +1801,7 @@
|
|
|
const userMsg: Message = {
|
|
|
type: 'user',
|
|
|
content: displayContent || userInput || `上传了文件:${file.name}`,
|
|
|
+ rawInput: userInput,
|
|
|
time: now.format('HH:mm'),
|
|
|
createdAt: now.toISOString(),
|
|
|
attachedFile: file,
|
|
|
@@ -1782,104 +1810,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 失败');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
@@ -2326,7 +2442,7 @@
|
|
|
top: 0 !important;
|
|
|
}
|
|
|
.zxm-modal-close,
|
|
|
- .maximize-btn {
|
|
|
+ .maximize-btn {
|
|
|
top: 40px;
|
|
|
}
|
|
|
|