|
|
@@ -65,28 +65,31 @@
|
|
|
@download-word="downloadWordReport"
|
|
|
@export-md="handleExportMarkdown"
|
|
|
@export-word="handleExportWord"
|
|
|
- @file-preview="openFilePreview"
|
|
|
+ @file-preview="handleAttachmentPreview"
|
|
|
+ @download-attachment="handleAttachmentDownload"
|
|
|
@agent-detail-click="handleAgentDetailClick"
|
|
|
@locate-message="handleLocateMessage"
|
|
|
- @open-web="openWebTab"
|
|
|
+ @open-web="handleOpenWebLink"
|
|
|
/>
|
|
|
|
|
|
<!-- 附件列表面板 -->
|
|
|
- <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="openFilePreview" />
|
|
|
+ <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="handleAttachmentPreview" />
|
|
|
|
|
|
<!-- 消息列表 -->
|
|
|
<ChatMessages
|
|
|
ref="chatMessagesRef"
|
|
|
:messages="messages"
|
|
|
:history-loading="isCurrentHistoryLoading"
|
|
|
- @file-preview="openFilePreview"
|
|
|
+ :orphan-attachments="currentTask?.orphanAttachments || []"
|
|
|
+ @file-preview="handleAttachmentPreview"
|
|
|
+ @download-attachment="handleAttachmentDownload"
|
|
|
@download-word="downloadWordFile"
|
|
|
@open-file-preview="handleOpenFilePreview"
|
|
|
@ask-user-submit="handleAskUserSubmit"
|
|
|
@ask-user-cancel="handleAskUserCancel"
|
|
|
@agent-detail-click="handleAgentDetailClick"
|
|
|
@open-markdown-tab="openMarkdownPreviewTab"
|
|
|
- @open-web="openWebTab"
|
|
|
+ @open-web="handleOpenWebLink"
|
|
|
@delete-message="handleDeleteMessage"
|
|
|
@regenerate="handleRegenerate"
|
|
|
@edit-resend="handleEditResend"
|
|
|
@@ -132,24 +135,33 @@
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 右侧辅助面板(标签页容器,与 chat-panel 并列,带滑入滑出动画) -->
|
|
|
- <Transition name="right-panel-slide" @after-leave="onPanelLeaveEnd">
|
|
|
- <RightPanelTabs
|
|
|
- v-if="currentRightPanels.tabs.length > 0"
|
|
|
- :tabs="currentRightPanels.tabs"
|
|
|
- :activeTabId="currentRightPanels.activeTabId"
|
|
|
- :messages="messages"
|
|
|
- @switch-tab="activateTab"
|
|
|
- @close-tab="closeTab"
|
|
|
- @save-success="handleSaveSuccess"
|
|
|
- @schedule-deleted="handleScheduleDeleted"
|
|
|
- @schedule-updated="handleScheduleUpdated"
|
|
|
- @open-schedule-session="handleOpenScheduleSession"
|
|
|
- @open-markdown-tab="openMarkdownPreviewTab"
|
|
|
- />
|
|
|
- </Transition>
|
|
|
</template>
|
|
|
|
|
|
+ <!--
|
|
|
+ 右侧辅助面板(标签页容器):
|
|
|
+ - 始终挂载,用 v-show 控制显隐;这样关闭二维标签后 iframe 仍保留,只隐藏不销毁;
|
|
|
+ - 处于技能/子智能体/定时任务面板时同样只隐藏;
|
|
|
+ - 关闭整个助手弹框时,由 Modal 的 destroyOnClose 真正销毁。
|
|
|
+ -->
|
|
|
+ <Transition name="right-panel-slide" @after-leave="onPanelLeaveEnd">
|
|
|
+ <RightPanelTabs
|
|
|
+ v-show="activePanel === 'chat' && currentRightPanels.tabs.length > 0"
|
|
|
+ :tabs="currentRightPanels.tabs"
|
|
|
+ :activeTabId="currentRightPanels.activeTabId"
|
|
|
+ :messages="messages"
|
|
|
+ :model-preview-loaded="modelPreview.loaded"
|
|
|
+ :model-preview-visible="modelPreviewVisible"
|
|
|
+ :model-preview-route="modelPreview.route"
|
|
|
+ @switch-tab="activateTab"
|
|
|
+ @close-tab="closeTab"
|
|
|
+ @save-success="handleSaveSuccess"
|
|
|
+ @schedule-deleted="handleScheduleDeleted"
|
|
|
+ @schedule-updated="handleScheduleUpdated"
|
|
|
+ @open-schedule-session="handleOpenScheduleSession"
|
|
|
+ @open-markdown-tab="openMarkdownPreviewTab"
|
|
|
+ />
|
|
|
+ </Transition>
|
|
|
+
|
|
|
<!-- 技能面板:非管理员只读(面板内部按 isAdmin 隐藏/禁用写操作) -->
|
|
|
<div v-if="activePanel === 'skill'" class="right-panel full">
|
|
|
<SkillPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
|
|
|
@@ -174,7 +186,7 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
- import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
|
|
|
+ import { ref, nextTick, watch, computed, onBeforeUnmount, reactive } from 'vue';
|
|
|
import dayjs from 'dayjs';
|
|
|
// [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):引入 getMe 用于按 is_admin 隐藏管理入口
|
|
|
import { getDetail, deleteSession, deleteMessage, StreamError, getMe } from '/@/views/ventAI/manageAssistent/api';
|
|
|
@@ -190,8 +202,10 @@
|
|
|
import SchedulePanel from '/@/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue';
|
|
|
import RightPanelTabs from '/@/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue';
|
|
|
import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
|
|
|
- import type { Message, ScheduleTask, Task } from '/@/views/ventAI/manageAssistent/types/index';
|
|
|
+ import type { AttachedFile, Message, ScheduleTask, SubAppRoute, Task } from '/@/views/ventAI/manageAssistent/types/index';
|
|
|
import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
|
|
|
+ import { parseModalPreviewLink } from '/@/views/ventAI/manageAssistent/lib/webLink';
|
|
|
+ import { locateDevice, locateDeviceReport, locateTunnel } from '/@/views/ventAI/manageAssistent/lib/modalPreviewBridge';
|
|
|
import { useChatStream } from '../hooks/useChatStream';
|
|
|
import { useChatTasks } from '../hooks/useChatTasks';
|
|
|
import { useChatPanels } from '../hooks/useChatPanels';
|
|
|
@@ -203,6 +217,7 @@
|
|
|
import { useFileDrop } from '../hooks/useFileDrop';
|
|
|
import { useChatQuota } from '../hooks/useChatQuota';
|
|
|
import { useChatExport } from '../hooks/useChatExport';
|
|
|
+ import { useChatAttachments } from '../hooks/useChatAttachments';
|
|
|
|
|
|
interface Props {
|
|
|
visible: boolean;
|
|
|
@@ -283,6 +298,7 @@
|
|
|
openMarkdownPreviewTab,
|
|
|
openFilePreview,
|
|
|
openWebTab,
|
|
|
+ openModalPreviewTab,
|
|
|
handleOpenFilePreview,
|
|
|
handleSaveSuccess,
|
|
|
getCurrentTaskFiles,
|
|
|
@@ -295,6 +311,190 @@
|
|
|
defaultFullscreen: !!props.defaultFullscreen,
|
|
|
});
|
|
|
|
|
|
+ // —— 会话附件历史:列表按会话拉取,附件内容在预览/下载时按需取回 ——
|
|
|
+ const { loadHistoryAttachments, handleAttachmentPreview: previewAttachment, handleAttachmentDownload } = useChatAttachments();
|
|
|
+ const handleAttachmentPreview = (file: AttachedFile) => previewAttachment(file, openFilePreview);
|
|
|
+
|
|
|
+ // ===== 二维模型生命周期:单活模型 + 关闭隐藏 + 空闲超时销毁 =====
|
|
|
+ interface ModelPreviewCache {
|
|
|
+ loaded: boolean;
|
|
|
+ ownerSessionId: string;
|
|
|
+ modelId: string;
|
|
|
+ route: SubAppRoute | null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 标签关闭/切换到其他面板时模型只隐藏;超过该时长未再次显示才真正销毁
|
|
|
+ const MODEL_PREVIEW_IDLE_MS = 5 * 60 * 1000;
|
|
|
+ const modelPreview = reactive<ModelPreviewCache>({
|
|
|
+ loaded: false,
|
|
|
+ ownerSessionId: '',
|
|
|
+ modelId: '',
|
|
|
+ route: null,
|
|
|
+ });
|
|
|
+ let modelPreviewIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
+ let modelPreviewTransition: Promise<void> | null = null;
|
|
|
+ let pendingModelPreviewRoute: SubAppRoute | null = null;
|
|
|
+ let modelPreviewEpoch = 0;
|
|
|
+ let lastModelLocateSignature = '';
|
|
|
+
|
|
|
+ // 当前会话归属:优先用后端 session_id,空白任务用前端任务 id 兜底
|
|
|
+ const currentSessionKey = (): string => {
|
|
|
+ const task = taskList.value.find((t) => t.id === currentTaskId.value);
|
|
|
+ return task?.sessionId || task?.id || '';
|
|
|
+ };
|
|
|
+
|
|
|
+ // 只有“二维标签处于激活状态 + 聊天面板可见 + 模型已加载”才算可见
|
|
|
+ const modelPreviewVisible = computed(() => {
|
|
|
+ if (activePanel.value !== 'chat' || !modelPreview.loaded) return false;
|
|
|
+ const state = currentRightPanels.value;
|
|
|
+ const active = state.tabs.find((tab) => tab.id === state.activeTabId);
|
|
|
+ return active?.type === 'model2d';
|
|
|
+ });
|
|
|
+
|
|
|
+ const clearModelPreviewIdleTimer = () => {
|
|
|
+ if (modelPreviewIdleTimer !== null) {
|
|
|
+ clearTimeout(modelPreviewIdleTimer);
|
|
|
+ modelPreviewIdleTimer = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const destroyModelPreview = () => {
|
|
|
+ modelPreviewEpoch++;
|
|
|
+ pendingModelPreviewRoute = null;
|
|
|
+ clearModelPreviewIdleTimer();
|
|
|
+ modelPreview.loaded = false;
|
|
|
+ modelPreview.modelId = '';
|
|
|
+ modelPreview.route = null;
|
|
|
+ modelPreview.ownerSessionId = '';
|
|
|
+ lastModelLocateSignature = '';
|
|
|
+ };
|
|
|
+
|
|
|
+ const scheduleModelPreviewIdleDestroy = () => {
|
|
|
+ clearModelPreviewIdleTimer();
|
|
|
+ if (!modelPreview.loaded) return;
|
|
|
+ modelPreviewIdleTimer = setTimeout(() => {
|
|
|
+ modelPreviewIdleTimer = null;
|
|
|
+ destroyModelPreview();
|
|
|
+ }, MODEL_PREVIEW_IDLE_MS);
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 同一模型下只做定位,不重载 iframe:
|
|
|
+ * tunid → 巷道定位,locationId → 设备定位,locationName → 报表定位。
|
|
|
+ */
|
|
|
+ const applyModelLocate = (route: SubAppRoute | null) => {
|
|
|
+ const query = route?.query || {};
|
|
|
+ const modelId = query.model_id || modelPreview.modelId;
|
|
|
+ if (!modelId) return;
|
|
|
+ const signature = [modelId, query.tunid || '', query.locationId || '', query.locationName || '', query.deviceType || ''].join('|');
|
|
|
+ if (signature === lastModelLocateSignature) return;
|
|
|
+ lastModelLocateSignature = signature;
|
|
|
+
|
|
|
+ const deviceType = query.deviceType || (query.tunid ? 'tun' : 'fanlocal');
|
|
|
+ if (query.tunid) {
|
|
|
+ locateTunnel({ model_id: modelId, tunid: query.tunid, deviceType });
|
|
|
+ } else if (query.locationId) {
|
|
|
+ locateDevice({ model_id: modelId, deviceType, locationId: query.locationId });
|
|
|
+ } else if (query.locationName) {
|
|
|
+ locateDeviceReport({ model_id: modelId, deviceType, locationName: query.locationName });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 确保当前加载的模型与目标路由一致:
|
|
|
+ * - 未加载:加载;
|
|
|
+ * - model_id 相同:只隐藏/显示,定位参数走 bridge,不重载;
|
|
|
+ * - model_id 不同:先卸载旧 iframe,下一帧再挂载新模型。
|
|
|
+ */
|
|
|
+ const ensureModelPreview = async (route: SubAppRoute | null) => {
|
|
|
+ if (!route) return;
|
|
|
+ const modelId = route.query?.model_id || '';
|
|
|
+
|
|
|
+ if (modelPreview.loaded && modelPreview.modelId === modelId) {
|
|
|
+ modelPreview.ownerSessionId = currentSessionKey();
|
|
|
+ applyModelLocate(route);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ pendingModelPreviewRoute = route;
|
|
|
+ if (modelPreviewTransition) return;
|
|
|
+
|
|
|
+ modelPreviewTransition = (async () => {
|
|
|
+ while (pendingModelPreviewRoute) {
|
|
|
+ const target = pendingModelPreviewRoute;
|
|
|
+ pendingModelPreviewRoute = null;
|
|
|
+ const targetModelId = target.query?.model_id || '';
|
|
|
+
|
|
|
+ if (modelPreview.loaded && modelPreview.modelId === targetModelId) {
|
|
|
+ modelPreview.ownerSessionId = currentSessionKey();
|
|
|
+ applyModelLocate(target);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const epoch = modelPreviewEpoch;
|
|
|
+ clearModelPreviewIdleTimer();
|
|
|
+ modelPreview.loaded = false;
|
|
|
+ // 先让旧 iframe 真正卸载,再挂载新模型,避免同帧复用组件导致旧模型残留
|
|
|
+ await nextTick();
|
|
|
+ if (epoch !== modelPreviewEpoch) return;
|
|
|
+
|
|
|
+ modelPreview.route = target;
|
|
|
+ modelPreview.modelId = targetModelId;
|
|
|
+ modelPreview.ownerSessionId = currentSessionKey();
|
|
|
+ lastModelLocateSignature = '';
|
|
|
+ modelPreview.loaded = true;
|
|
|
+ }
|
|
|
+ })().finally(() => {
|
|
|
+ modelPreviewTransition = null;
|
|
|
+ if (pendingModelPreviewRoute) {
|
|
|
+ void ensureModelPreview(pendingModelPreviewRoute);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ await modelPreviewTransition;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 会话切换、标签激活/关闭、面板切换后,按当前激活标签同步模型状态
|
|
|
+ const syncModelPreviewForActiveTab = () => {
|
|
|
+ if (!props.visible || activePanel.value !== 'chat') return;
|
|
|
+ const state = currentRightPanels.value;
|
|
|
+ const active = state.tabs.find((tab) => tab.id === state.activeTabId);
|
|
|
+ if (active?.type !== 'model2d') return;
|
|
|
+ void ensureModelPreview(active.subAppRoute || { path: '/modal-preview' });
|
|
|
+ };
|
|
|
+
|
|
|
+ const openModelPreview = (route: SubAppRoute, title?: string) => {
|
|
|
+ openModalPreviewTab({ title: title || '二维模型', subAppRoute: route });
|
|
|
+ void ensureModelPreview(route);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 隐藏后开始 5 分钟空闲倒计时;重新显示立即取消
|
|
|
+ watch(modelPreviewVisible, (visible) => {
|
|
|
+ if (visible) clearModelPreviewIdleTimer();
|
|
|
+ else scheduleModelPreviewIdleDestroy();
|
|
|
+ });
|
|
|
+
|
|
|
+ watch(
|
|
|
+ () => [currentTaskId.value, currentRightPanels.value.activeTabId, activePanel.value] as const,
|
|
|
+ () => syncModelPreviewForActiveTab()
|
|
|
+ );
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 聊天链接分流:链接中包含 modal-preview 时打开二维模型标签,并把链接参数传给子应用;
|
|
|
+ * 普通 http(s) 链接仍走右侧 Web 面板。
|
|
|
+ */
|
|
|
+ const handleOpenWebLink = (payload: { url: string; title?: string }) => {
|
|
|
+ const route = parseModalPreviewLink(payload.url);
|
|
|
+ if (route) {
|
|
|
+ openModelPreview(route, payload.title);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ openWebTab(payload);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 二维模型标签/模型可由业务入口按需打开(宿主可通过组件 ref 调用)
|
|
|
+ defineExpose({ openModalPreviewTab, openModelPreview });
|
|
|
+
|
|
|
// —— 拖拽/居中职责已下沉到 useChatDrag(与面板全屏共用 lib/modalWrap) ——
|
|
|
const drag = useChatDrag({
|
|
|
getIsFullscreen: () => isFullscreen.value,
|
|
|
@@ -380,6 +580,17 @@
|
|
|
if (nextTask) switchTask(nextTask.id);
|
|
|
};
|
|
|
|
|
|
+ // 消息被删除后,附件文件仍保留在服务端(message_id 变为 null);
|
|
|
+ // 这里把本地附件立即转入兜底列表,避免要刷新后才重新出现。
|
|
|
+ const moveAttachmentsToOrphans = (task: Task | undefined, files: AttachedFile[]) => {
|
|
|
+ if (!task || !files.length) return;
|
|
|
+ const merged = [...(task.orphanAttachments || [])];
|
|
|
+ for (const file of files) {
|
|
|
+ if (!merged.some((item) => item.id === file.id)) merged.push(file);
|
|
|
+ }
|
|
|
+ task.orphanAttachments = merged;
|
|
|
+ };
|
|
|
+
|
|
|
// 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
|
|
|
const handleDeleteMessage = (msg: Message) => {
|
|
|
if (!msg.id) return;
|
|
|
@@ -407,6 +618,7 @@
|
|
|
message.success('消息已删除');
|
|
|
if (ownerTask) {
|
|
|
ownerTask.messages = ownerTask.messages.filter((m) => m !== msg);
|
|
|
+ moveAttachmentsToOrphans(ownerTask, [msg.attachedFile, ...(msg.attachedFiles || [])].filter(Boolean) as AttachedFile[]);
|
|
|
}
|
|
|
} catch {
|
|
|
message.error('删除失败');
|
|
|
@@ -566,6 +778,30 @@
|
|
|
return currentTask?.sessionId || '';
|
|
|
};
|
|
|
|
|
|
+ /**
|
|
|
+ * 拉取单个会话的历史消息与附件。
|
|
|
+ * 流式中的任务不拉历史(避免覆盖在途消息);附件加载失败不阻断消息展示。
|
|
|
+ */
|
|
|
+ const loadTaskHistory = async (task: Task) => {
|
|
|
+ const sessionId = task.sessionId;
|
|
|
+ if (!sessionId || historyLoadedTasks.has(task.id) || task.isStreaming) return;
|
|
|
+
|
|
|
+ setHistoryLoading(task.id, true);
|
|
|
+ try {
|
|
|
+ const res = await getDetail(sessionId);
|
|
|
+ const history = Array.isArray(res?.messages) ? transformHistoryToMessages(res.messages) : [];
|
|
|
+ task.messages = history;
|
|
|
+ await loadHistoryAttachments(task, sessionId, history);
|
|
|
+ historyLoadedTasks.add(task.id);
|
|
|
+ } catch (error) {
|
|
|
+ // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
|
|
|
+ console.error('加载历史会话失败:', error);
|
|
|
+ message.error('加载历史会话失败,请稍后重试');
|
|
|
+ } finally {
|
|
|
+ setHistoryLoading(task.id, false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
const switchTask = async (taskId: string) => {
|
|
|
activePanel.value = 'chat';
|
|
|
|
|
|
@@ -579,26 +815,8 @@
|
|
|
showFileList.value = false;
|
|
|
|
|
|
if (newTask.sessionId && !historyLoadedTasks.has(taskId)) {
|
|
|
- // 流式中的任务不拉历史(避免覆盖在途消息),但必须继续走完下面的初始化:
|
|
|
- // currentTaskId 已经切过来了,提前 return 会让上下文用量/模式/进度条/滚动全部停在上一个任务上。
|
|
|
- if (!newTask.isStreaming) {
|
|
|
- // 标记加载中:消息区据 isCurrentHistoryLoading 显示加载态,而不是先闪一屏空会话
|
|
|
- setHistoryLoading(taskId, true);
|
|
|
- try {
|
|
|
- const res = await getDetail(newTask.sessionId);
|
|
|
- const messages = res?.messages;
|
|
|
- if (Array.isArray(messages)) {
|
|
|
- newTask.messages = transformHistoryToMessages(messages);
|
|
|
- }
|
|
|
- historyLoadedTasks.add(taskId);
|
|
|
- } catch (error) {
|
|
|
- // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
|
|
|
- console.error('加载历史会话失败:', error);
|
|
|
- message.error('加载历史会话失败,请稍后重试');
|
|
|
- } finally {
|
|
|
- setHistoryLoading(taskId, false);
|
|
|
- }
|
|
|
- }
|
|
|
+ // 流式中的任务由 loadTaskHistory 内部跳过,但下面的上下文/模式/进度条初始化必须继续执行
|
|
|
+ await loadTaskHistory(newTask);
|
|
|
}
|
|
|
|
|
|
if (newTask.sessionId) {
|
|
|
@@ -670,6 +888,11 @@
|
|
|
if (task.sessionId) {
|
|
|
await deleteSession(task.sessionId);
|
|
|
}
|
|
|
+ // 删除的是当前已加载模型所属的会话:直接销毁模型(而不是等空闲超时)
|
|
|
+ const ownerKey = task.sessionId || task.id;
|
|
|
+ if (modelPreview.ownerSessionId && modelPreview.ownerSessionId === ownerKey) {
|
|
|
+ destroyModelPreview();
|
|
|
+ }
|
|
|
const realIndex = taskList.value.findIndex((t) => t.id === task.id);
|
|
|
if (realIndex !== -1) {
|
|
|
taskList.value.splice(realIndex, 1);
|
|
|
@@ -694,6 +917,14 @@
|
|
|
const hasText = inputMessage.value.trim();
|
|
|
const hasFile = pendingFile.value;
|
|
|
|
|
|
+ // 发送前置快照:请求失败时按这份内容回填输入区(文本 + 附件)。
|
|
|
+ // 必须在 createNewTask 之前取:createNewTask -> switchTask 会清空输入区。
|
|
|
+ // 输入框清空会触发输入区内部把 # 会话 / $ 技能引用一起清掉,这里只回填用户能直接看到的两项;
|
|
|
+ // 引用标签需要用户重新选择(避免父组件状态与输入区内部状态不同步)。
|
|
|
+ const draftText = inputMessage.value;
|
|
|
+ const draftFile = pendingFile.value;
|
|
|
+
|
|
|
+ // 后端 message 为必填字段,纯附件不能单独发送,必须有文字
|
|
|
if (!hasText) {
|
|
|
message.warning('请输入消息内容');
|
|
|
return;
|
|
|
@@ -707,7 +938,15 @@
|
|
|
// 否则 push 出去的消息会落在视图之外的临时数组里,之后再也不会显示
|
|
|
if (!currentTask.value) {
|
|
|
await createNewTask();
|
|
|
- if (!currentTask.value) return;
|
|
|
+ if (!currentTask.value) {
|
|
|
+ // 建任务失败时也把草稿还回去,避免空白任务创建异常把用户输入吞掉
|
|
|
+ inputMessage.value = draftText;
|
|
|
+ pendingFile.value = draftFile;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // createNewTask -> switchTask 会清空输入区,把刚捕获的草稿放回去再组装请求
|
|
|
+ inputMessage.value = draftText;
|
|
|
+ pendingFile.value = draftFile;
|
|
|
}
|
|
|
|
|
|
const sessionPrefix = selectedSessions.value.length > 0 ? selectedSessions.value.map((s) => `#session:${s.id}`).join(' ') + ' ' : '';
|
|
|
@@ -725,8 +964,6 @@
|
|
|
displayContent = `[<img src="${iconUrl}" class="session-ref-icon" /> 引用 ${selectedSessions.value.length} 个会话:${names}] ${displayContent}`;
|
|
|
}
|
|
|
|
|
|
- const fileToSend = pendingFile.value;
|
|
|
-
|
|
|
inputMessage.value = '';
|
|
|
pendingFile.value = null;
|
|
|
selectedSessions.value = [];
|
|
|
@@ -741,7 +978,7 @@
|
|
|
chatMessagesRef.value?.forceScrollToBottom();
|
|
|
|
|
|
if (hasFile) {
|
|
|
- await sendWithAttachment(fileToSend!, userInput, displayContent);
|
|
|
+ await sendWithAttachment(draftFile!, userInput, displayContent);
|
|
|
} else {
|
|
|
await sendTextOnly(userInput, displayContent);
|
|
|
}
|
|
|
@@ -766,6 +1003,13 @@
|
|
|
// 这轮请求失败了,后端没有对应记录:标记为仅前端消息,回填主键时跳过
|
|
|
localOnly: true,
|
|
|
});
|
|
|
+
|
|
|
+ // 失败回填:只在用户仍停留在发起任务上时恢复,避免把旧会话的草稿塞进正在看的另一个会话。
|
|
|
+ // 输入框/附件已被用户重新填写时不覆盖(宁可保留用户新输入,也不要把旧内容盖上去)。
|
|
|
+ if (currentTask.value === originTask) {
|
|
|
+ if (!inputMessage.value) inputMessage.value = draftText;
|
|
|
+ if (!pendingFile.value) pendingFile.value = draftFile;
|
|
|
+ }
|
|
|
}
|
|
|
} finally {
|
|
|
// 自动滚到底只对「用户仍停在这条任务上」有意义,否则会把正在看的别的会话拽到底部。
|
|
|
@@ -868,22 +1112,9 @@
|
|
|
|
|
|
currentTaskId.value = targetTask.id;
|
|
|
|
|
|
- // 如果是历史任务,加载其消息
|
|
|
+ // 如果是历史任务,加载其消息与附件
|
|
|
if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
|
|
|
- setHistoryLoading(targetTask.id, true);
|
|
|
- try {
|
|
|
- const res = await getDetail(targetTask.sessionId);
|
|
|
- const messages = res?.messages;
|
|
|
- if (Array.isArray(messages)) {
|
|
|
- targetTask.messages = transformHistoryToMessages(messages);
|
|
|
- }
|
|
|
- historyLoadedTasks.add(targetTask.id);
|
|
|
- } catch (error) {
|
|
|
- console.error('加载历史会话失败:', error);
|
|
|
- message.error('加载历史会话失败,请稍后重试');
|
|
|
- } finally {
|
|
|
- setHistoryLoading(targetTask.id, false);
|
|
|
- }
|
|
|
+ await loadTaskHistory(targetTask);
|
|
|
}
|
|
|
|
|
|
if (targetTask.sessionId) {
|
|
|
@@ -905,7 +1136,12 @@
|
|
|
|
|
|
await nextTick(() => {
|
|
|
chatMessagesRef.value?.forceScrollToBottom();
|
|
|
+ // 重新打开助手时,如果当前标签就是二维模型,按需重新加载
|
|
|
+ syncModelPreviewForActiveTab();
|
|
|
});
|
|
|
+ } else {
|
|
|
+ // 关闭整个助手弹框:模型立即销毁,而不是隐藏
|
|
|
+ destroyModelPreview();
|
|
|
}
|
|
|
},
|
|
|
{ immediate: true }
|
|
|
@@ -922,6 +1158,8 @@
|
|
|
activeFileReader.value.abort();
|
|
|
activeFileReader.value = null;
|
|
|
}
|
|
|
+ // 清掉二维模型的空闲销毁定时器,避免卸载后仍回调
|
|
|
+ clearModelPreviewIdleTimer();
|
|
|
});
|
|
|
</script>
|
|
|
|
|
|
@@ -969,15 +1207,15 @@
|
|
|
top: 16px;
|
|
|
left: 24px;
|
|
|
z-index: 10;
|
|
|
- width: 32px;
|
|
|
- height: 32px;
|
|
|
- background: #1a6696;
|
|
|
- border-radius: 4px;
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
+ background: #1a6696;
|
|
|
cursor: pointer;
|
|
|
transition: background 0.3s;
|
|
|
+ border-radius: 4px;
|
|
|
|
|
|
&:hover {
|
|
|
background: #1f7ab8;
|
|
|
@@ -1013,34 +1251,34 @@
|
|
|
}
|
|
|
|
|
|
.chat-panel {
|
|
|
- flex: 1;
|
|
|
- display: flex;
|
|
|
position: relative;
|
|
|
+ display: flex;
|
|
|
+ flex: 1;
|
|
|
flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
background-image: var(--img-chat-panel-bg);
|
|
|
background-repeat: no-repeat;
|
|
|
background-size: 100% 100%;
|
|
|
border-radius: 4px;
|
|
|
- overflow: hidden;
|
|
|
min-width: 0;
|
|
|
}
|
|
|
|
|
|
.right-panel {
|
|
|
- width: calc(50% - 140px);
|
|
|
- height: 100%;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
+ width: calc(50% - 140px);
|
|
|
+ height: 100%;
|
|
|
background-image: var(--img-chat-panel-bg);
|
|
|
background-repeat: no-repeat;
|
|
|
background-size: 100% 100%;
|
|
|
border-radius: 4px;
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
|
&.full {
|
|
|
flex: 1;
|
|
|
width: auto;
|
|
|
min-width: 0;
|
|
|
}
|
|
|
- flex-shrink: 0;
|
|
|
}
|
|
|
|
|
|
/* 右侧面板滑入滑出过渡动画 */
|
|
|
@@ -1085,5 +1323,5 @@
|
|
|
</style>
|
|
|
|
|
|
<style lang="less">
|
|
|
- @import '../styles/modal.less';
|
|
|
+ @import url('../styles/modal.less');
|
|
|
</style>
|