AiAssistantModal.vue 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359
  1. <template>
  2. <a-modal
  3. :visible="props.visible"
  4. @cancel="emit('close')"
  5. :width="modalWidth"
  6. :footer="null"
  7. destroyOnClose
  8. :closable="showControls"
  9. :zIndex="props.zIndex"
  10. :get-container="getModalContainer"
  11. :class="['ai-assistant-modal', { fullscreen: isFullscreen }]"
  12. >
  13. <template #title>
  14. <div :class="['draggable-title', { 'no-drag': isFullscreen }]" @mousedown="onDragStart">"通安智风"大模型</div>
  15. </template>
  16. <!-- 最大化/还原按钮 -->
  17. <div v-if="showControls" class="maximize-btn" @click="toggleFullscreen">
  18. <div :class="['maximize-icon', { minimized: isFullscreen }]"></div>
  19. </div>
  20. <!-- 收起/展开按钮 -->
  21. <div class="collapse-btn" @click="togglePanel">
  22. <div :class="['collapse-icon', { collapsed: panelCollapsed }]"></div>
  23. </div>
  24. <div class="ai-container" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
  25. <!-- 拖拽经过时的「可放置」提示。pointer-events: none 不能去掉:遮罩一旦能命中,
  26. 它自己会连续触发 dragenter/dragleave,反馈会持续闪断 -->
  27. <div v-if="isDragOver" class="drop-overlay">
  28. <div class="drop-overlay-text">松开鼠标添加附件</div>
  29. </div>
  30. <!-- 左侧任务列表 -->
  31. <div :class="['task-panel-wrapper', { collapsed: panelCollapsed }]">
  32. <TaskListPanel
  33. :taskList="taskList"
  34. :currentTaskId="currentTaskId"
  35. @switch-task="switchTask"
  36. @create-task="createNewTask"
  37. @delete-task="handleDeleteTask"
  38. @pin-task="handlePinTask"
  39. @toggle-skill="toggleSkillPanel"
  40. @toggle-sub-agents="toggleSubAgentPanel"
  41. @toggle-schedules="toggleSchedulePanel"
  42. />
  43. <!-- 「技能」「子智能体」两个入口对所有角色都显示:角色差异收敛在面板内部
  44. (非管理员只读:隐藏创建/编辑/删除、禁用启停开关),而不是把入口整个藏掉。 -->
  45. </div>
  46. <!-- 右侧内容区:根据 activePanel 切换显示 -->
  47. <!-- 聊天区域 -->
  48. <template v-if="activePanel === 'chat'">
  49. <div class="chat-panel">
  50. <!-- 顶部任务选择器 -->
  51. <ChatHeader
  52. :currentTask="currentTask"
  53. :panelCollapsed="panelCollapsed"
  54. :canGoPrev="canGoPrev"
  55. :canGoNext="canGoNext"
  56. :currentWordUrl="currentWordUrl"
  57. :capsule-files="capsuleFiles"
  58. :capsule-links="capsuleLinks"
  59. :capsule-agents="capsuleAgents"
  60. @go-to-prev="goToPrev"
  61. @go-to-next="goToNext"
  62. @create-task="createNewTask"
  63. @save-title="handleSaveTitle"
  64. @download-word="downloadWordReport"
  65. @export-md="handleExportMarkdown"
  66. @export-word="handleExportWord"
  67. @file-preview="handleAttachmentPreview"
  68. @download-attachment="handleAttachmentDownload"
  69. @agent-detail-click="handleAgentDetailClick"
  70. @locate-message="handleLocateMessage"
  71. @open-link="handleOpenLink"
  72. />
  73. <!-- 附件列表面板 -->
  74. <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="handleAttachmentPreview" />
  75. <!-- 消息列表 -->
  76. <ChatMessages
  77. ref="chatMessagesRef"
  78. :messages="messages"
  79. :history-loading="isCurrentHistoryLoading"
  80. :orphan-attachments="currentTask?.orphanAttachments || []"
  81. @file-preview="handleAttachmentPreview"
  82. @download-attachment="handleAttachmentDownload"
  83. @download-word="downloadWordFile"
  84. @open-file-preview="handleOpenFilePreview"
  85. @ask-user-submit="handleAskUserSubmit"
  86. @ask-user-cancel="handleAskUserCancel"
  87. @agent-detail-click="handleAgentDetailClick"
  88. @open-markdown-tab="openMarkdownPreviewTab"
  89. @open-link="handleOpenLink"
  90. @delete-message="handleDeleteMessage"
  91. @regenerate="handleRegenerate"
  92. @edit-resend="handleEditResend"
  93. />
  94. <!-- 任务进度条 -->
  95. <TodoListBar v-if="showTodoBar" :todos="currentTodos" />
  96. <!-- 输入区域 -->
  97. <!-- [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3)::isAdmin 透传给输入区,
  98. 由输入区隐藏「思考级别」切换控件;注意只隐藏"切换",
  99. 思考级别的读取(getModelMsg)对所有人保持开放 -->
  100. <ChatInputArea
  101. ref="chatInputRef"
  102. v-model="inputMessage"
  103. :pendingFile="pendingFile"
  104. :loading="loading || !!currentPendingApproval"
  105. :streaming="loading"
  106. :contextUsed="contextUsed"
  107. :contextMax="contextMax"
  108. :contextPercent="contextPercent"
  109. :contextBreakdown="contextBreakdown"
  110. :usageLimit="usageToday?.limit_tokens ?? 0"
  111. :usageSpent="usageToday?.spent_tokens ?? 0"
  112. :usageRemaining="usageToday?.remaining_tokens ?? 0"
  113. :usageByModel="usageToday?.by_model ?? []"
  114. :initialThinkLevel="thinkLevel"
  115. :isAdmin="isAdmin"
  116. :initialEditMode="editMode"
  117. :taskList="taskList"
  118. :currentTaskId="currentTaskId"
  119. @send="handleSendMessage"
  120. @stop="stopStreaming"
  121. @file-upload="triggerFileUpload"
  122. @file-change="handleFileUpload"
  123. @file-add="handleFileAdded"
  124. @remove-pending-file="removePendingFile"
  125. @pending-file-preview="openFilePreview"
  126. @think-level-change="handleThinkLevelChange"
  127. @edit-mode-change="handleEditModeChange"
  128. @update:selected-sessions="handleUpdateSelectedSessions"
  129. @update:selected-skill="handleUpdateSelectedSkill"
  130. />
  131. </div>
  132. </template>
  133. <!--
  134. 右侧辅助面板(标签页容器):
  135. - 始终挂载,用 v-show 控制显隐;这样关闭二维标签后 iframe 仍保留,只隐藏不销毁;
  136. - 处于技能/子智能体/定时任务面板时同样只隐藏;
  137. - 关闭整个助手弹框时,由 Modal 的 destroyOnClose 真正销毁。
  138. -->
  139. <Transition name="right-panel-slide" @after-leave="onPanelLeaveEnd">
  140. <RightPanelTabs
  141. v-show="activePanel === 'chat' && currentRightPanels.tabs.length > 0"
  142. :tabs="currentRightPanels.tabs"
  143. :activeTabId="currentRightPanels.activeTabId"
  144. :messages="messages"
  145. :model-preview-loaded="modelPreview.loaded"
  146. :model-preview-visible="modelPreviewVisible"
  147. :model-preview-route="modelPreview.route"
  148. @switch-tab="activateTab"
  149. @close-tab="closeTab"
  150. @save-success="handleSaveSuccess"
  151. @schedule-deleted="handleScheduleDeleted"
  152. @schedule-updated="handleScheduleUpdated"
  153. @open-schedule-session="handleOpenScheduleSession"
  154. @open-markdown-tab="openMarkdownPreviewTab"
  155. />
  156. </Transition>
  157. <!-- 技能面板:非管理员只读(面板内部按 isAdmin 隐藏/禁用写操作) -->
  158. <div v-if="activePanel === 'skill'" class="right-panel full">
  159. <SkillPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
  160. </div>
  161. <!-- 子智能体面板:非管理员只读 -->
  162. <div v-if="activePanel === 'sub-agents'" class="right-panel full">
  163. <SubAgentPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
  164. </div>
  165. <!-- 定时任务面板 -->
  166. <div v-if="activePanel === 'schedules'" class="right-panel full">
  167. <SchedulePanel
  168. ref="schedulePanelRef"
  169. @close="activePanel = 'chat'"
  170. @open-session="handleOpenScheduleSession"
  171. @data-changed="fetchScheduleListForDetail"
  172. />
  173. </div>
  174. </div>
  175. </a-modal>
  176. </template>
  177. <script setup lang="ts">
  178. import { ref, nextTick, watch, computed, onBeforeUnmount, reactive } from 'vue';
  179. import dayjs from 'dayjs';
  180. // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):引入 getMe 用于按 is_admin 隐藏管理入口
  181. import { getDetail, deleteSession, deleteMessage, StreamError, getMe } from '/@/views/ventAI/manageAssistent/api';
  182. import { message, Modal } from 'ant-design-vue';
  183. import TaskListPanel from '/@/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue';
  184. import ChatHeader from '/@/views/ventAI/manageAssistent/components/chat/ChatHeader.vue';
  185. import FileListPanel from '/@/views/ventAI/manageAssistent/components/panels/FileListPanel.vue';
  186. import ChatMessages from '/@/views/ventAI/manageAssistent/components/chat/ChatMessages.vue';
  187. import ChatInputArea from '/@/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue';
  188. import TodoListBar from '/@/views/ventAI/manageAssistent/components/chat/TodoListBar.vue';
  189. import SkillPanel from '/@/views/ventAI/manageAssistent/components/panels/SkillPanel.vue';
  190. import SubAgentPanel from '/@/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue';
  191. import SchedulePanel from '/@/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue';
  192. import RightPanelTabs from '/@/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue';
  193. import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
  194. import type { AttachedFile, Message, ScheduleTask, SubAppRoute, Task } from '/@/views/ventAI/manageAssistent/types/index';
  195. import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
  196. import { parseModalPreviewLink, isFileDownloadLink, downloadLinkFile } from '/@/views/ventAI/manageAssistent/lib/index';
  197. import { locateDevice, locateDeviceReport, locateTunnel, onLocateResult } from '/@/views/ventAI/manageAssistent/lib/modalPreviewBridge';
  198. import { useChatStream } from '../hooks/useChatStream';
  199. import { useChatTasks } from '../hooks/useChatTasks';
  200. import { useChatPanels } from '../hooks/useChatPanels';
  201. import { useChatDrag } from '../hooks/useChatDrag';
  202. import { useChatApproval } from '../hooks/useChatApproval';
  203. import { useChatSend } from '../hooks/useChatSend';
  204. import { useChatScroll } from '../hooks/useChatScroll';
  205. import { useChatFiles } from '../hooks/useChatFiles';
  206. import { useFileDrop } from '../hooks/useFileDrop';
  207. import { useChatQuota } from '../hooks/useChatQuota';
  208. import { useChatExport } from '../hooks/useChatExport';
  209. import { useChatAttachments } from '../hooks/useChatAttachments';
  210. interface Props {
  211. visible: boolean;
  212. // 初始即全屏显示(首页等嵌入场景)
  213. defaultFullscreen?: boolean;
  214. // 是否显示窗口控制按钮(最大化/还原、关闭)
  215. showControls?: boolean;
  216. zIndex?: number;
  217. // 挂载容器:不传则默认挂到 body;首页等场景传入 adaptive-container,使其与页面同坐标系、同缩放
  218. getContainer?: () => HTMLElement;
  219. }
  220. interface Emits {
  221. (e: 'close'): void;
  222. }
  223. const props = withDefaults(defineProps<Props>(), {
  224. defaultFullscreen: false,
  225. showControls: true,
  226. });
  227. const emit = defineEmits<Emits>();
  228. // 弹框真正渲染/被 antd 滚动锁锁定的目标:包一层 size-0 空壳,
  229. // 避免 antd Modal 打开时滚动锁直接改写 #adaptive-container 的宽度(导致整页缩放错乱、露出白边)
  230. const portalWrapper = ref<HTMLDivElement | null>(null);
  231. const getModalContainer = (): HTMLElement => {
  232. if (portalWrapper.value) return portalWrapper.value;
  233. const target = props.getContainer ? props.getContainer() : document.body;
  234. const wrapper = document.createElement('div');
  235. wrapper.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;';
  236. target.appendChild(wrapper);
  237. portalWrapper.value = wrapper;
  238. return wrapper;
  239. };
  240. // —— 会话状态与列表相关职责已下沉到 useChatTasks ——
  241. const {
  242. taskList,
  243. currentTaskId,
  244. currentTask,
  245. messages,
  246. historyLoadedTasks,
  247. historyLoadingTaskIds,
  248. setHistoryLoading,
  249. getDefaultMessages,
  250. transformHistoryToMessages,
  251. fetchSessionList,
  252. handlePinTask,
  253. handleSaveTitle,
  254. setCurrentSessionId,
  255. } = useChatTasks();
  256. // 当前任务的历史是否正在拉取:切换任务时新任务的 messages 要等接口回来才填充,
  257. // 消息区据此显示加载态,避免中间先闪一屏空会话缺省图。
  258. const isCurrentHistoryLoading = computed(() => historyLoadingTaskIds.value.includes(currentTaskId.value));
  259. // —— 右侧面板/标签页/全屏相关职责已下沉到 useChatPanels ——
  260. const {
  261. showFileList,
  262. activePanel,
  263. panelCollapsed,
  264. togglePanel,
  265. toggleSkillPanel,
  266. toggleSchedulePanel,
  267. toggleSubAgentPanel,
  268. isFullscreen,
  269. currentRightPanels,
  270. modalWidth,
  271. onPanelLeaveEnd,
  272. toggleFullscreen,
  273. getPanelState,
  274. activateTab,
  275. removeTab,
  276. closeTab,
  277. handleAgentDetailClick,
  278. handleLocateMessage,
  279. openMarkdownPreviewTab,
  280. openFilePreview,
  281. openModalPreviewTab,
  282. handleOpenFilePreview,
  283. handleSaveSuccess,
  284. getCurrentTaskFiles,
  285. } = useChatPanels({
  286. getCurrentTask: () => currentTask.value,
  287. getMessages: () => messages.value,
  288. getChatMessages: () => chatMessagesRef.value,
  289. getContainer: () => (props.getContainer ? props.getContainer() : document.body),
  290. resetModalPosition: () => drag.centerModal(),
  291. defaultFullscreen: !!props.defaultFullscreen,
  292. });
  293. // —— 会话附件历史:列表按会话拉取,附件内容在预览/下载时按需取回 ——
  294. const { loadHistoryAttachments, handleAttachmentPreview: previewAttachment, handleAttachmentDownload } = useChatAttachments();
  295. const handleAttachmentPreview = (file: AttachedFile) => previewAttachment(file, openFilePreview);
  296. // ===== 二维模型生命周期:单活模型 + 关闭隐藏 + 空闲超时销毁 =====
  297. interface ModelPreviewCache {
  298. loaded: boolean;
  299. ownerSessionId: string;
  300. modelId: string;
  301. route: SubAppRoute | null;
  302. }
  303. // 标签关闭/切换到其他面板时模型只隐藏;超过该时长未再次显示才真正销毁
  304. const MODEL_PREVIEW_IDLE_MS = 5 * 60 * 1000;
  305. const modelPreview = reactive<ModelPreviewCache>({
  306. loaded: false,
  307. ownerSessionId: '',
  308. modelId: '',
  309. route: null,
  310. });
  311. let modelPreviewIdleTimer: ReturnType<typeof setTimeout> | null = null;
  312. let modelPreviewTransition: Promise<void> | null = null;
  313. let pendingModelPreviewRoute: SubAppRoute | null = null;
  314. let modelPreviewEpoch = 0;
  315. let lastModelLocateSignature = '';
  316. // 当前会话归属:优先用后端 session_id,空白任务用前端任务 id 兜底
  317. const currentSessionKey = (): string => {
  318. const task = taskList.value.find((t) => t.id === currentTaskId.value);
  319. return task?.sessionId || task?.id || '';
  320. };
  321. // 只有“二维标签处于激活状态 + 聊天面板可见 + 模型已加载”才算可见
  322. const modelPreviewVisible = computed(() => {
  323. if (activePanel.value !== 'chat' || !modelPreview.loaded) return false;
  324. const state = currentRightPanels.value;
  325. const active = state.tabs.find((tab) => tab.id === state.activeTabId);
  326. return active?.type === 'model2d';
  327. });
  328. const clearModelPreviewIdleTimer = () => {
  329. if (modelPreviewIdleTimer !== null) {
  330. clearTimeout(modelPreviewIdleTimer);
  331. modelPreviewIdleTimer = null;
  332. }
  333. };
  334. const destroyModelPreview = () => {
  335. modelPreviewEpoch++;
  336. pendingModelPreviewRoute = null;
  337. clearModelPreviewIdleTimer();
  338. modelPreview.loaded = false;
  339. modelPreview.modelId = '';
  340. modelPreview.route = null;
  341. modelPreview.ownerSessionId = '';
  342. lastModelLocateSignature = '';
  343. };
  344. const scheduleModelPreviewIdleDestroy = () => {
  345. clearModelPreviewIdleTimer();
  346. if (!modelPreview.loaded) return;
  347. modelPreviewIdleTimer = setTimeout(() => {
  348. modelPreviewIdleTimer = null;
  349. destroyModelPreview();
  350. }, MODEL_PREVIEW_IDLE_MS);
  351. };
  352. /**
  353. * 同一模型下只做定位,不重载 iframe:
  354. * tunid → 巷道定位,locationId → 设备定位,locationName → 报表定位。
  355. */
  356. const applyModelLocate = (route: SubAppRoute | null): boolean => {
  357. const query = route?.query || {};
  358. const modelId = query.model_id || modelPreview.modelId;
  359. if (!modelId) return false;
  360. const signature = [modelId, query.tunid || '', query.locationId || '', query.locationName || '', query.deviceType || ''].join('|');
  361. if (signature === lastModelLocateSignature) return false;
  362. lastModelLocateSignature = signature;
  363. const deviceType = query.deviceType || (query.tunid ? 'tun' : 'fanlocal');
  364. if (query.tunid) {
  365. locateTunnel({ model_id: modelId, tunid: query.tunid, deviceType });
  366. } else if (query.locationId) {
  367. locateDevice({ model_id: modelId, deviceType, locationId: query.locationId });
  368. } else if (query.locationName) {
  369. locateDeviceReport({ model_id: modelId, deviceType, locationName: query.locationName });
  370. }
  371. return true;
  372. };
  373. /**
  374. * 确保当前加载的模型与目标路由一致:
  375. * - 未加载:加载;
  376. * - model_id 相同:只隐藏/显示,定位参数走 bridge,不重载;
  377. * - model_id 不同:先卸载旧 iframe,下一帧再挂载新模型。
  378. */
  379. const ensureModelPreview = async (route: SubAppRoute | null) => {
  380. if (!route) return;
  381. const modelId = route.query?.model_id || '';
  382. if (modelPreview.loaded && modelPreview.modelId === modelId) {
  383. modelPreview.ownerSessionId = currentSessionKey();
  384. // 同一个模型只做定位;定位参数也没变时界面不会有任何变化,明确提示一下
  385. if (!applyModelLocate(route)) message.info('当前显示的已是该模型');
  386. return;
  387. }
  388. pendingModelPreviewRoute = route;
  389. if (modelPreviewTransition) return;
  390. modelPreviewTransition = (async () => {
  391. while (pendingModelPreviewRoute) {
  392. const target = pendingModelPreviewRoute;
  393. pendingModelPreviewRoute = null;
  394. const targetModelId = target.query?.model_id || '';
  395. if (modelPreview.loaded && modelPreview.modelId === targetModelId) {
  396. modelPreview.ownerSessionId = currentSessionKey();
  397. if (!applyModelLocate(target)) message.info('当前显示的已是该模型');
  398. continue;
  399. }
  400. const epoch = modelPreviewEpoch;
  401. clearModelPreviewIdleTimer();
  402. modelPreview.loaded = false;
  403. // 先让旧 iframe 真正卸载,再挂载新模型,避免同帧复用组件导致旧模型残留
  404. await nextTick();
  405. if (epoch !== modelPreviewEpoch) return;
  406. modelPreview.route = target;
  407. modelPreview.modelId = targetModelId;
  408. modelPreview.ownerSessionId = currentSessionKey();
  409. lastModelLocateSignature = '';
  410. modelPreview.loaded = true;
  411. // 子应用只靠模型 id 不会主动加载模型;首次打开必须补发一次链接里的定位消息,
  412. // 由子应用在 locate 处理里完成“加载模型 + 定位”。等 ModalPreview 挂载/注册桥接后再发,
  413. // 未 ready 时桥接会自动排队,ready 后补发。
  414. await nextTick();
  415. applyModelLocate(target);
  416. }
  417. })().finally(() => {
  418. modelPreviewTransition = null;
  419. if (pendingModelPreviewRoute) {
  420. void ensureModelPreview(pendingModelPreviewRoute);
  421. }
  422. });
  423. await modelPreviewTransition;
  424. };
  425. // 会话切换、标签激活/关闭、面板切换后,按当前激活标签同步模型状态
  426. const syncModelPreviewForActiveTab = () => {
  427. if (!props.visible || activePanel.value !== 'chat') return;
  428. const state = currentRightPanels.value;
  429. const active = state.tabs.find((tab) => tab.id === state.activeTabId);
  430. if (active?.type !== 'model2d') return;
  431. void ensureModelPreview(active.subAppRoute || { path: '/modal-preview' });
  432. };
  433. const openModelPreview = (route: SubAppRoute, title?: string) => {
  434. const tab = openModalPreviewTab({ title: title || '二维模型', subAppRoute: route });
  435. if (!tab) {
  436. // 没有当前会话时开不了标签页,不提示的话点击就是“没反应”
  437. message.warning('请先新建或选择一个会话,再打开模型预览');
  438. return;
  439. }
  440. void ensureModelPreview(route);
  441. };
  442. // 子应用没能定位(例如链接里的设备在模型里找不到)时界面不会有任何变化,必须提示用户,
  443. // 否则表现就是“点了没反应”
  444. const offLocateResult = onLocateResult((result) => {
  445. if (result.ok) return;
  446. message.warning(result.message || '未能在模型中找到该位置,请检查链接参数');
  447. });
  448. // 隐藏后开始 5 分钟空闲倒计时;重新显示立即取消
  449. watch(modelPreviewVisible, (visible) => {
  450. if (visible) clearModelPreviewIdleTimer();
  451. else scheduleModelPreviewIdleDestroy();
  452. });
  453. watch(
  454. () => [currentTaskId.value, currentRightPanels.value.activeTabId, activePanel.value] as const,
  455. () => syncModelPreviewForActiveTab()
  456. );
  457. /**
  458. * 聊天链接分流:
  459. * 1) 链接里带 modal-preview → 右侧面板打开二维模型预览,并把链接参数传给子应用;
  460. * 2) 文件下载链接 → 直接下载(带令牌取回文件再保存);
  461. * 3) 其余链接 → 浏览器新标签页打开。
  462. */
  463. const handleOpenLink = async (payload: { url: string; title?: string }) => {
  464. if (!payload?.url) return;
  465. const route = parseModalPreviewLink(payload.url);
  466. if (route) {
  467. openModelPreview(route, payload.title);
  468. return;
  469. }
  470. if (isFileDownloadLink(payload.url)) {
  471. const close = message.loading('正在下载文件…', 0);
  472. const handled = await downloadLinkFile(payload.url);
  473. close();
  474. if (!handled) message.error('文件下载失败,请稍后重试');
  475. return;
  476. }
  477. window.open(payload.url, '_blank', 'noopener,noreferrer');
  478. };
  479. // 二维模型标签/模型可由业务入口按需打开(宿主可通过组件 ref 调用)
  480. defineExpose({ openModalPreviewTab, openModelPreview });
  481. // —— 拖拽/居中职责已下沉到 useChatDrag(与面板全屏共用 lib/modalWrap) ——
  482. const drag = useChatDrag({
  483. getIsFullscreen: () => isFullscreen.value,
  484. getVisible: () => props.visible,
  485. });
  486. const { onDragStart } = drag;
  487. // —— 待审批流程已下沉到 useChatApproval ——
  488. const { setTaskPendingApproval, currentPendingApproval, migrateTaskApproval, handleAskUserSubmit, handleAskUserCancel } = useChatApproval({
  489. getTaskList: () => taskList.value,
  490. getCurrentTaskId: () => currentTaskId.value,
  491. switchTask: (id) => switchTask(id),
  492. setTaskAbortController: (id, controller) => setTaskAbortController(id, controller),
  493. handleStreamEvent: (data, aiMsg, taskId) => chatStream.handleEvent(data, aiMsg, taskId),
  494. scrollToBottom: (force) => scrollToBottom(force),
  495. scrollToBottomThrottled: (force) => scrollToBottomThrottled(force),
  496. refreshUsage: () => fetchTodayUsage(),
  497. resetTodos: () => {
  498. showTodoBar.value = false;
  499. currentTodos.value = [];
  500. },
  501. setCurrentSessionId: (id) => setCurrentSessionId(id),
  502. // 延迟求值:useChatSend 在下面才初始化(与其它依赖同一约定,避免 setup 期 TDZ)
  503. hydrateMessageIds: (sessionId, msgs) => hydrateSendMessageIds(sessionId, msgs),
  504. });
  505. // —— 发送/流式管线已下沉到 useChatSend(handleSendMessage 作为输入框编排留在组件里) ——
  506. const {
  507. loading,
  508. stopStreaming,
  509. setTaskAbortController,
  510. abortAllStreams,
  511. getSkillRefIconUrl,
  512. getSessionRefIconUrl,
  513. sendTextOnly,
  514. sendWithAttachment,
  515. handleRegenerate,
  516. handleEditResend,
  517. hydrateMessageIds: hydrateSendMessageIds,
  518. } = useChatSend({
  519. getTaskList: () => taskList.value,
  520. getCurrentTaskId: () => currentTaskId.value,
  521. setCurrentTaskId: (taskId) => {
  522. currentTaskId.value = taskId;
  523. },
  524. getMessages: () => messages.value,
  525. setTaskList: (next) => {
  526. taskList.value = next;
  527. },
  528. getEditMode: () => editMode.value,
  529. getPendingApproval: () => currentPendingApproval.value,
  530. getChatMessages: () => chatMessagesRef.value,
  531. handleStreamEvent: (data, aiMsg, taskId) => chatStream.handleEvent(data, aiMsg, taskId),
  532. scrollToBottomThrottled: (force) => scrollToBottomThrottled(force),
  533. resetTodos: () => {
  534. showTodoBar.value = false;
  535. currentTodos.value = [];
  536. },
  537. migrateTaskApproval: (from, to) => migrateTaskApproval(from, to),
  538. historyLoadedTasks,
  539. transformHistoryToMessages,
  540. });
  541. // 胶囊聚合数据:转换逻辑内聚在 capsuleData 纯函数模块中,此处只做响应式接线
  542. const capsuleFiles = computed(() => buildCapsuleFiles(currentTask.value, messages.value));
  543. const capsuleLinks = computed(() => buildCapsuleLinks(capsuleFiles.value, messages.value));
  544. const capsuleAgents = computed(() => buildCapsuleAgents(messages.value, (i) => chatMessagesRef.value?.isAnswerVisible(i)));
  545. // 顶部上一个/下一个会话导航
  546. const currentTaskIndex = computed(() => taskList.value.findIndex((t) => t.id === currentTaskId.value));
  547. const canGoPrev = computed(() => currentTaskIndex.value > 0);
  548. const canGoNext = computed(() => currentTaskIndex.value >= 0 && currentTaskIndex.value < taskList.value.length - 1);
  549. const goToPrev = () => {
  550. if (!canGoPrev.value) return;
  551. const prevTask = taskList.value[currentTaskIndex.value - 1];
  552. if (prevTask) switchTask(prevTask.id);
  553. };
  554. const goToNext = () => {
  555. if (!canGoNext.value) return;
  556. const nextTask = taskList.value[currentTaskIndex.value + 1];
  557. if (nextTask) switchTask(nextTask.id);
  558. };
  559. // 消息被删除后,附件文件仍保留在服务端(message_id 变为 null);
  560. // 这里把本地附件立即转入兜底列表,避免要刷新后才重新出现。
  561. const moveAttachmentsToOrphans = (task: Task | undefined, files: AttachedFile[]) => {
  562. if (!task || !files.length) return;
  563. const merged = [...(task.orphanAttachments || [])];
  564. for (const file of files) {
  565. if (!merged.some((item) => item.id === file.id)) merged.push(file);
  566. }
  567. task.orphanAttachments = merged;
  568. };
  569. // 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
  570. const handleDeleteMessage = (msg: Message) => {
  571. if (!msg.id) return;
  572. // 确认框与删除请求都是异步的,用户可能在这期间切到别的会话:记住这条消息所属的任务,
  573. // 写回它而不是「当前任务」,否则被删的消息会一直留在原任务里直到刷新。
  574. const ownerTask = currentTask.value;
  575. Modal.confirm({
  576. title: '提示',
  577. content: '是否删除该条消息?',
  578. okText: '确认',
  579. cancelText: '取消',
  580. wrapClassName: 'delete-task-confirm-modal',
  581. onOk: async () => {
  582. try {
  583. await deleteMessage(msg.id!);
  584. // 被中断拆成两段的回答里,前一段也是后端单独一条记录,一并删除;失败只记日志不阻断本地移除
  585. // ——主行已删,界面上这条消息本就该消失(残留的前段刷新后会单独出现,属可接受的退化)
  586. for (const absorbedId of msg.absorbedRowIds ?? []) {
  587. try {
  588. await deleteMessage(absorbedId);
  589. } catch (e) {
  590. console.error('删除被中断段落失败:', e);
  591. }
  592. }
  593. message.success('消息已删除');
  594. if (ownerTask) {
  595. ownerTask.messages = ownerTask.messages.filter((m) => m !== msg);
  596. moveAttachmentsToOrphans(ownerTask, [msg.attachedFile, ...(msg.attachedFiles || [])].filter(Boolean) as AttachedFile[]);
  597. }
  598. } catch {
  599. message.error('删除失败');
  600. }
  601. },
  602. });
  603. };
  604. const chatInputRef = ref<InstanceType<typeof ChatInputArea>>();
  605. const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
  606. const scheduleListForDetail = ref<ScheduleTask[]>([]);
  607. const fetchScheduleListForDetail = async () => {
  608. try {
  609. const res = await getScheduleList();
  610. // getScheduleList 已按真实契约标注 { tasks, total }
  611. scheduleListForDetail.value = res?.tasks ?? [];
  612. } catch (e) {
  613. console.error('获取定时任务列表失败:', e);
  614. scheduleListForDetail.value = [];
  615. message.error('获取定时任务列表失败,定时任务详情可能不是最新');
  616. }
  617. };
  618. const currentTodos = ref<Array<{ content: string; status: string }>>([]);
  619. const showTodoBar = ref(false);
  620. const syncTodoBarFromMessages = () => {
  621. for (let i = messages.value.length - 1; i >= 0; i--) {
  622. const msg = messages.value[i];
  623. if (msg.type === 'ai' && msg.thinkingSteps) {
  624. for (let j = msg.thinkingSteps.length - 1; j >= 0; j--) {
  625. const step = msg.thinkingSteps[j];
  626. if (step.type === 'updated_todo_list' && step.todos) {
  627. currentTodos.value = [...step.todos];
  628. showTodoBar.value = true;
  629. return;
  630. }
  631. }
  632. }
  633. }
  634. showTodoBar.value = false;
  635. currentTodos.value = [];
  636. };
  637. const handleOpenScheduleSession = async (sessionId: string, prompt?: string) => {
  638. activePanel.value = 'chat';
  639. await fetchSessionList();
  640. await fetchScheduleListForDetail();
  641. const existing = taskList.value.find((t) => t.sessionId === sessionId);
  642. if (existing) {
  643. await switchTask(existing.id);
  644. } else {
  645. const newTask: Task = {
  646. id: sessionId,
  647. name: '定时任务',
  648. sessionId,
  649. messages: [],
  650. attachedFiles: [],
  651. };
  652. taskList.value.unshift(newTask);
  653. historyLoadedTasks.delete(sessionId);
  654. await switchTask(newTask.id);
  655. }
  656. // 如果有 prompt,自动发送消息
  657. if (prompt) {
  658. inputMessage.value = prompt;
  659. await nextTick();
  660. await handleSendMessage();
  661. }
  662. };
  663. const schedulePanelRef = ref();
  664. const handleScheduleDeleted = () => {
  665. const task = currentTask.value;
  666. if (task?.rightPanels) {
  667. const tab = task.rightPanels.tabs.find((t) => t.type === 'schedule');
  668. if (tab) removeTab(task.rightPanels, tab.id);
  669. }
  670. fetchScheduleListForDetail();
  671. schedulePanelRef.value?.fetchScheduleList?.();
  672. };
  673. const handleScheduleUpdated = async () => {
  674. await fetchScheduleListForDetail();
  675. // 同步标签内任务对象为最新数据(编辑/启停后)
  676. const task = currentTask.value;
  677. const tab = task?.rightPanels?.tabs.find((t) => t.type === 'schedule');
  678. if (tab?.type === 'schedule') {
  679. const fresh = scheduleListForDetail.value.find((s) => s.id === tab.task.id);
  680. if (fresh) tab.task = fresh;
  681. }
  682. schedulePanelRef.value?.fetchScheduleList?.();
  683. };
  684. const inputMessage = ref('');
  685. const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
  686. const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
  687. // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):当前登录用户是否管理员
  688. // (TfAgents /ventAI/api/model/me 的 is_admin;原审计写 /api/me,经实测校正,见交付说明 §⑩)。
  689. // 默认 false = 保守语义:取不到权限信息时按非管理员处理,管理入口隐藏。
  690. const isAdmin = ref(false);
  691. // —— 以下四组职责已下沉到 composables(额度/导出/滚动/文件),组件只做接线 ——
  692. const { scrollToBottom, scrollToBottomThrottled } = useChatScroll(() => chatMessagesRef.value);
  693. const { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, addFile, removePendingFile } = useChatFiles(() => chatInputRef.value);
  694. // 拖拽 / 粘贴进来的文件与「选择文件」共用同一条入队路径:校验与读取分流都在 addFile 内
  695. const handleFileAdded = (file: File) => {
  696. addFile(file);
  697. };
  698. const handleDropFiles = (files: File[]) => {
  699. // 待发附件是单值,多文件拖入时取第一个并说明,避免用户以为全都加上了
  700. if (files.length > 1) message.warning('一次只能添加一个附件,已取第一个文件');
  701. addFile(files[0]);
  702. };
  703. const { isDragOver, dragHandlers } = useFileDrop({
  704. onFiles: handleDropFiles,
  705. isVisible: () => props.visible,
  706. });
  707. const { onDragEnter, onDragOver, onDragLeave, onDrop } = dragHandlers;
  708. const {
  709. usageToday,
  710. fetchTodayUsage,
  711. contextUsed,
  712. contextMax,
  713. contextPercent,
  714. contextBreakdown,
  715. thinkLevel,
  716. editMode,
  717. fetchModelMsg,
  718. fetchSessionMode,
  719. fetchContext,
  720. invalidateQuotaFetch,
  721. handleThinkLevelChange,
  722. handleEditModeChange,
  723. } = useChatQuota(() => getCurrentSessionId());
  724. const { currentWordUrl, downloadWordFile, downloadWordReport, handleExportMarkdown, handleExportWord } = useChatExport(
  725. () => messages.value,
  726. () => currentTask.value
  727. );
  728. const handleUpdateSelectedSessions = (sessions: Array<{ id: string; name: string }>) => {
  729. selectedSessions.value = sessions;
  730. };
  731. const handleUpdateSelectedSkill = (skill: { name: string; display_name: string } | null) => {
  732. selectedSkill.value = skill;
  733. };
  734. const getCurrentSessionId = (): string => {
  735. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  736. return currentTask?.sessionId || '';
  737. };
  738. /**
  739. * 拉取单个会话的历史消息与附件。
  740. * 流式中的任务不拉历史(避免覆盖在途消息);附件加载失败不阻断消息展示。
  741. */
  742. const loadTaskHistory = async (task: Task) => {
  743. const sessionId = task.sessionId;
  744. if (!sessionId || historyLoadedTasks.has(task.id) || task.isStreaming) return;
  745. setHistoryLoading(task.id, true);
  746. try {
  747. const res = await getDetail(sessionId);
  748. const history = Array.isArray(res?.messages) ? transformHistoryToMessages(res.messages) : [];
  749. task.messages = history;
  750. await loadHistoryAttachments(task, sessionId, history);
  751. historyLoadedTasks.add(task.id);
  752. } catch (error) {
  753. // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
  754. console.error('加载历史会话失败:', error);
  755. message.error('加载历史会话失败,请稍后重试');
  756. } finally {
  757. setHistoryLoading(task.id, false);
  758. }
  759. };
  760. const switchTask = async (taskId: string) => {
  761. activePanel.value = 'chat';
  762. // 单一数据源:messages 直接读当前任务,切换任务前无需把视图数组回写到旧任务
  763. currentTaskId.value = taskId;
  764. const newTask = taskList.value.find((t) => t.id === taskId);
  765. if (!newTask) return;
  766. inputMessage.value = '';
  767. pendingFile.value = null;
  768. showFileList.value = false;
  769. if (newTask.sessionId && !historyLoadedTasks.has(taskId)) {
  770. // 流式中的任务由 loadTaskHistory 内部跳过,但下面的上下文/模式/进度条初始化必须继续执行
  771. await loadTaskHistory(newTask);
  772. }
  773. if (newTask.sessionId) {
  774. fetchContext(newTask.sessionId);
  775. fetchSessionMode(newTask.sessionId);
  776. } else {
  777. // 空白会话不拉取额度:作废在途请求,否则上一条会话的响应会盖掉下面刚重置的默认值
  778. invalidateQuotaFetch();
  779. contextUsed.value = 0;
  780. contextPercent.value = 0;
  781. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  782. editMode.value = 'full';
  783. }
  784. syncTodoBarFromMessages();
  785. // 切换任务时检测是否为定时任务会话:自动打开/关闭定时任务详情标签
  786. const scheduleItem = newTask.sessionId ? scheduleListForDetail.value.find((s) => s.feedback_session_id === newTask.sessionId) : undefined;
  787. const panelState = getPanelState(newTask);
  788. const existingScheduleTab = panelState.tabs.find((t) => t.type === 'schedule');
  789. if (scheduleItem) {
  790. if (existingScheduleTab) {
  791. panelState.activeTabId = existingScheduleTab.id;
  792. } else {
  793. panelState.tabs.push({ id: 'schedule', type: 'schedule', title: scheduleItem.name || '定时任务', task: scheduleItem });
  794. panelState.activeTabId = 'schedule';
  795. }
  796. } else if (existingScheduleTab) {
  797. removeTab(panelState, existingScheduleTab.id);
  798. }
  799. await nextTick();
  800. chatMessagesRef.value?.forceScrollToBottom();
  801. };
  802. const createNewTask = () => {
  803. const existingBlank = taskList.value.find((t) => !t.sessionId && t.messages.length === 0);
  804. if (existingBlank) {
  805. switchTask(existingBlank.id);
  806. return;
  807. }
  808. const newTask: Task = {
  809. id: `task-${Date.now()}`,
  810. name: '新任务',
  811. sessionId: '',
  812. messages: getDefaultMessages(),
  813. attachedFiles: [],
  814. };
  815. invalidateQuotaFetch();
  816. contextUsed.value = 0;
  817. contextPercent.value = 0;
  818. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  819. editMode.value = 'full';
  820. taskList.value.unshift(newTask);
  821. switchTask(newTask.id);
  822. };
  823. const handleDeleteTask = (task: Task, _index: number) => {
  824. Modal.confirm({
  825. title: '提示',
  826. content: '是否删除该对话?',
  827. okText: '确认',
  828. cancelText: '取消',
  829. wrapClassName: 'delete-task-confirm-modal',
  830. onOk: async () => {
  831. try {
  832. if (task.sessionId) {
  833. await deleteSession(task.sessionId);
  834. }
  835. // 删除的是当前已加载模型所属的会话:直接销毁模型(而不是等空闲超时)
  836. const ownerKey = task.sessionId || task.id;
  837. if (modelPreview.ownerSessionId && modelPreview.ownerSessionId === ownerKey) {
  838. destroyModelPreview();
  839. }
  840. const realIndex = taskList.value.findIndex((t) => t.id === task.id);
  841. if (realIndex !== -1) {
  842. taskList.value.splice(realIndex, 1);
  843. }
  844. if (currentTaskId.value === task.id) {
  845. if (taskList.value.length > 0) {
  846. switchTask(taskList.value[0].id);
  847. } else {
  848. // 任务已清空:没有当前任务后 messages 自动回落到空数组
  849. currentTaskId.value = '';
  850. }
  851. }
  852. message.success('删除成功');
  853. } catch {
  854. message.error('删除失败');
  855. }
  856. },
  857. });
  858. };
  859. const handleSendMessage = async () => {
  860. const hasText = inputMessage.value.trim();
  861. const hasFile = pendingFile.value;
  862. // 发送前置快照:请求失败时按这份内容回填输入区(文本 + 附件)。
  863. // 必须在 createNewTask 之前取:createNewTask -> switchTask 会清空输入区。
  864. // 输入框清空会触发输入区内部把 # 会话 / $ 技能引用一起清掉,这里只回填用户能直接看到的两项;
  865. // 引用标签需要用户重新选择(避免父组件状态与输入区内部状态不同步)。
  866. const draftText = inputMessage.value;
  867. const draftFile = pendingFile.value;
  868. // 后端 message 为必填字段,纯附件不能单独发送,必须有文字
  869. if (!hasText) {
  870. message.warning('请输入消息内容');
  871. return;
  872. }
  873. if (loading.value) return;
  874. if (currentPendingApproval.value) {
  875. message.warning('请先审批执行计划');
  876. return;
  877. }
  878. // 没有当前任务时(例如会话被全部删除)先建一个:消息是挂在任务上的,
  879. // 否则 push 出去的消息会落在视图之外的临时数组里,之后再也不会显示
  880. if (!currentTask.value) {
  881. await createNewTask();
  882. if (!currentTask.value) {
  883. // 建任务失败时也把草稿还回去,避免空白任务创建异常把用户输入吞掉
  884. inputMessage.value = draftText;
  885. pendingFile.value = draftFile;
  886. return;
  887. }
  888. // createNewTask -> switchTask 会清空输入区,把刚捕获的草稿放回去再组装请求
  889. inputMessage.value = draftText;
  890. pendingFile.value = draftFile;
  891. }
  892. const sessionPrefix = selectedSessions.value.length > 0 ? selectedSessions.value.map((s) => `#session:${s.id}`).join(' ') + ' ' : '';
  893. const skillPrefix = selectedSkill.value ? `#skill:${selectedSkill.value.name} ` : '';
  894. const userInput = skillPrefix + sessionPrefix + inputMessage.value.trim();
  895. let displayContent = inputMessage.value.trim();
  896. if (selectedSkill.value) {
  897. const skillIconUrl = getSkillRefIconUrl();
  898. displayContent = `[<img src="${skillIconUrl}" class="session-ref-icon" /> 技能:${selectedSkill.value.display_name}] ${displayContent}`;
  899. }
  900. if (selectedSessions.value.length > 0) {
  901. const names = selectedSessions.value.map((s) => s.name).join('、');
  902. const iconUrl = getSessionRefIconUrl();
  903. displayContent = `[<img src="${iconUrl}" class="session-ref-icon" /> 引用 ${selectedSessions.value.length} 个会话:${names}] ${displayContent}`;
  904. }
  905. inputMessage.value = '';
  906. pendingFile.value = null;
  907. selectedSessions.value = [];
  908. selectedSkill.value = null;
  909. // 记住本次发送属于哪个任务:请求期间用户可能切到别的会话,失败时的报错必须落回发起它的任务,
  910. // 否则报错气泡会塞进正在看的那条会话,而发起任务的占位消息没人清理(永远停在「正在输入」)。
  911. const originTask = currentTask.value;
  912. try {
  913. await nextTick();
  914. chatMessagesRef.value?.forceScrollToBottom();
  915. if (hasFile) {
  916. await sendWithAttachment(draftFile!, userInput, displayContent);
  917. } else {
  918. await sendTextOnly(userInput, displayContent);
  919. }
  920. } catch (error) {
  921. console.error('发送消息失败:', error);
  922. const isStreamError = error instanceof StreamError;
  923. const errText = isStreamError ? error.message : '发送失败,请重试';
  924. message.error(errText);
  925. if (isStreamError) fetchTodayUsage();
  926. // 归位到发起本次请求的任务。任务可能在请求期间被删除,那时只保留上面的提示。
  927. if (originTask) {
  928. const msgs = originTask.messages;
  929. const lastMsg = msgs[msgs.length - 1];
  930. if (lastMsg && lastMsg.type === 'ai' && lastMsg.isLoading) {
  931. msgs.pop();
  932. }
  933. msgs.push({
  934. type: 'ai',
  935. content: isStreamError ? error.message : '抱歉,请求失败,请稍后重试。',
  936. time: dayjs().format('HH:mm'),
  937. // 这轮请求失败了,后端没有对应记录:标记为仅前端消息,回填主键时跳过
  938. localOnly: true,
  939. });
  940. // 失败回填:只在用户仍停留在发起任务上时恢复,避免把旧会话的草稿塞进正在看的另一个会话。
  941. // 输入框/附件已被用户重新填写时不覆盖(宁可保留用户新输入,也不要把旧内容盖上去)。
  942. if (currentTask.value === originTask) {
  943. if (!inputMessage.value) inputMessage.value = draftText;
  944. if (!pendingFile.value) pendingFile.value = draftFile;
  945. }
  946. }
  947. } finally {
  948. // 自动滚到底只对「用户仍停在这条任务上」有意义,否则会把正在看的别的会话拽到底部。
  949. // 按对象身份比较:任务在拿到真实 session_id 后会被改名(id 会变),用 id 比较会误判。
  950. if (currentTask.value === originTask) {
  951. await scrollToBottom(true);
  952. }
  953. }
  954. };
  955. // 统一对话流(SSE)事件管线:事件 → 消息状态的映射收敛在 hooks/useChatStream 内,
  956. // 本组件只注入「任务查找 / 进度条 / 用量刷新 / 待审批登记 / 答案初始化」这些视图侧钩子。
  957. const chatStream = useChatStream({
  958. findTask: (taskId) => taskList.value.find((t) => t.id === taskId),
  959. isCurrentTask: (taskId) => currentTaskId.value === taskId,
  960. setTodos: (todos) => {
  961. currentTodos.value = [...todos];
  962. showTodoBar.value = true;
  963. },
  964. refreshContext: (sessionId) => {
  965. fetchContext(sessionId);
  966. },
  967. refreshUsage: () => {
  968. fetchTodayUsage();
  969. },
  970. markPendingApproval: (taskId, sessionId, threadId) => {
  971. setTaskPendingApproval(taskId, sessionId, threadId);
  972. },
  973. initAskUserAnswers: (aiMsg, questions) => {
  974. chatMessagesRef.value?.initAskUserAnswers(aiMsg, questions);
  975. },
  976. });
  977. /**
  978. * 拉取当前登录用户的权限信息(TfAgents 的 GET /ventAI/api/model/me)。
  979. *
  980. * [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3)。
  981. * 为什么需要:TfAgents 已把「技能上传/启停、子智能体增删改、思考级别切换」等全局变更端点收归管理员
  982. * (用户已定案方案 B:后端返回 is_admin,前端隐藏入口),基座据此隐藏对应入口,避免非管理员点了才被 403。
  983. *
  984. * 安全语义(保守失败,历史踩坑点必须保留):任何异常(未登录 401 / 接口未上线 404 / 网络失败)
  985. * 一律按「非管理员」处理——isAdmin 置 false,且不缓存结果(每次打开都重新拉取,管理员名单可能在
  986. * 服务端 .env 变更)。注意这只是界面可见性,真正的拦截仍在 TfAgents 后端——前端隐藏不等于安全。
  987. */
  988. const fetchMe = async () => {
  989. try {
  990. const me = await getMe();
  991. isAdmin.value = !!me?.is_admin;
  992. } catch (e) {
  993. // 取不到(未登录/接口未上线/令牌失效)→ 按非管理员处理,隐藏管理入口
  994. isAdmin.value = false;
  995. console.warn('获取当前用户权限失败,管理入口将隐藏:', e);
  996. }
  997. };
  998. // 发送流式 AI 回答的核心:推送一条空的 AI 消息 → 建 AbortController → 调 SSE → 处理 session_id 迁移 → 异常/清理。
  999. // 正常发送、编辑重发、重新生成都复用此函数,file 存在且带 originalFile 时走 PDF 审查流,否则走纯文本流。
  1000. // Throttled version for SSE streaming (no await, uses rAF)
  1001. // immediate:组件若在挂载时就已经是可见状态(例如宿主页面默认展开、或恢复了上次的展开状态),
  1002. // false→true 的边沿不会发生,初始化就永远不执行,面板会停在空白状态。立即执行一次并让不可见分支
  1003. // 直接返回,等价于「打开时才初始化」的懒加载语义。
  1004. watch(
  1005. () => props.visible,
  1006. async (newVisible) => {
  1007. // 关闭弹框无需任何回写:消息单一数据源直接挂在任务对象上;仅在打开时做初始化
  1008. if (newVisible) {
  1009. await fetchSessionList();
  1010. fetchModelMsg();
  1011. fetchScheduleListForDetail();
  1012. fetchTodayUsage();
  1013. // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):每次打开时刷新权限(不缓存;失败置 false 见 fetchMe)
  1014. fetchMe();
  1015. // 去除重复的空白任务,只保留一个
  1016. taskList.value = taskList.value.filter((task, index) => {
  1017. if (task.sessionId) return true;
  1018. return index === taskList.value.findIndex((t) => !t.sessionId);
  1019. });
  1020. // 优先回到关闭前所在的任务(如流式中的新任务),否则默认选中第一条历史记录
  1021. const firstHistoryTask = taskList.value.find((t) => t.sessionId);
  1022. const prevTask = taskList.value.find((t) => t.id === currentTaskId.value);
  1023. let targetTask: Task;
  1024. if (prevTask) {
  1025. targetTask = prevTask;
  1026. } else if (firstHistoryTask) {
  1027. targetTask = firstHistoryTask;
  1028. } else {
  1029. // 没有历史记录时才创建空白新任务
  1030. targetTask = {
  1031. id: `task-${Date.now()}`,
  1032. name: '新任务',
  1033. sessionId: '',
  1034. messages: getDefaultMessages(),
  1035. attachedFiles: [],
  1036. };
  1037. taskList.value.unshift(targetTask);
  1038. }
  1039. currentTaskId.value = targetTask.id;
  1040. // 如果是历史任务,加载其消息与附件
  1041. if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
  1042. await loadTaskHistory(targetTask);
  1043. }
  1044. if (targetTask.sessionId) {
  1045. fetchContext(targetTask.sessionId);
  1046. fetchSessionMode(targetTask.sessionId);
  1047. } else {
  1048. invalidateQuotaFetch();
  1049. contextUsed.value = 0;
  1050. contextPercent.value = 0;
  1051. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  1052. editMode.value = 'full';
  1053. }
  1054. syncTodoBarFromMessages();
  1055. pendingFile.value = null;
  1056. showFileList.value = false;
  1057. inputMessage.value = '';
  1058. await nextTick(() => {
  1059. chatMessagesRef.value?.forceScrollToBottom();
  1060. // 重新打开助手时,如果当前标签就是二维模型,按需重新加载
  1061. syncModelPreviewForActiveTab();
  1062. });
  1063. } else {
  1064. // 关闭整个助手弹框:模型立即销毁,而不是隐藏
  1065. destroyModelPreview();
  1066. }
  1067. },
  1068. { immediate: true }
  1069. );
  1070. onBeforeUnmount(() => {
  1071. offLocateResult();
  1072. // 移除挂载空壳,避免残留
  1073. portalWrapper.value?.remove();
  1074. portalWrapper.value = null;
  1075. // 组件卸载时主动断开所有任务的流式连接
  1076. abortAllStreams();
  1077. // Abort in-flight FileReader if any
  1078. if (activeFileReader.value) {
  1079. activeFileReader.value.abort();
  1080. activeFileReader.value = null;
  1081. }
  1082. // 清掉二维模型的空闲销毁定时器,避免卸载后仍回调
  1083. clearModelPreviewIdleTimer();
  1084. });
  1085. </script>
  1086. <style scoped lang="less">
  1087. @import '../styles/tokens.less';
  1088. .ai-container {
  1089. display: flex;
  1090. height: 700px;
  1091. gap: 10px;
  1092. overflow: hidden;
  1093. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  1094. /* 拖拽提示遮罩的定位基准 */
  1095. position: relative;
  1096. }
  1097. /* 拖拽上传提示:z-index 低于弹框的收起/最大化按钮(10),拖拽时窗口控件仍保持可见 */
  1098. .drop-overlay {
  1099. position: absolute;
  1100. top: 0;
  1101. right: 0;
  1102. bottom: 0;
  1103. left: 0;
  1104. z-index: 5;
  1105. display: flex;
  1106. align-items: center;
  1107. justify-content: center;
  1108. pointer-events: none;
  1109. background: @color-primary-a10;
  1110. border: 2px dashed @color-primary-a50;
  1111. border-radius: 4px;
  1112. }
  1113. .drop-overlay-text {
  1114. padding: 8px 20px;
  1115. border-radius: 6px;
  1116. background: @color-panel-bg;
  1117. border: 1px solid @color-panel-border;
  1118. color: @color-text;
  1119. font-size: 13px;
  1120. }
  1121. .collapse-btn {
  1122. position: absolute;
  1123. top: 16px;
  1124. left: 24px;
  1125. z-index: 10;
  1126. display: flex;
  1127. align-items: center;
  1128. justify-content: center;
  1129. width: 32px;
  1130. height: 32px;
  1131. background: #1a6696;
  1132. cursor: pointer;
  1133. transition: background 0.3s;
  1134. border-radius: 4px;
  1135. &:hover {
  1136. background: #1f7ab8;
  1137. }
  1138. .collapse-icon {
  1139. width: 16px;
  1140. height: 16px;
  1141. background-image: var(--img-chat-collapse-icon);
  1142. background-repeat: no-repeat;
  1143. background-size: 100% 100%;
  1144. &.collapsed {
  1145. background-image: var(--img-chat-expand-icon);
  1146. }
  1147. }
  1148. }
  1149. .task-panel-wrapper {
  1150. width: 300px;
  1151. flex-shrink: 0;
  1152. overflow: hidden;
  1153. transition:
  1154. width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  1155. margin 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  1156. opacity 0.25s ease;
  1157. &.collapsed {
  1158. width: 0;
  1159. margin: 0;
  1160. opacity: 0;
  1161. }
  1162. }
  1163. .chat-panel {
  1164. position: relative;
  1165. display: flex;
  1166. flex: 1;
  1167. flex-direction: column;
  1168. overflow: hidden;
  1169. background-image: var(--img-chat-panel-bg);
  1170. background-repeat: no-repeat;
  1171. background-size: 100% 100%;
  1172. border-radius: 4px;
  1173. min-width: 0;
  1174. }
  1175. .right-panel {
  1176. display: flex;
  1177. flex-direction: column;
  1178. width: calc(50% - 140px);
  1179. height: 100%;
  1180. background-image: var(--img-chat-panel-bg);
  1181. background-repeat: no-repeat;
  1182. background-size: 100% 100%;
  1183. border-radius: 4px;
  1184. flex-shrink: 0;
  1185. &.full {
  1186. flex: 1;
  1187. width: auto;
  1188. min-width: 0;
  1189. }
  1190. }
  1191. /* 右侧面板滑入滑出过渡动画 */
  1192. :deep(.right-panel-slide-enter-active) {
  1193. transition:
  1194. opacity 0.3s ease,
  1195. transform 0.3s ease;
  1196. }
  1197. :deep(.right-panel-slide-leave-active) {
  1198. transition:
  1199. opacity 0.25s ease,
  1200. transform 0.25s ease;
  1201. }
  1202. :deep(.right-panel-slide-enter-from) {
  1203. opacity: 0;
  1204. transform: translateX(30px);
  1205. }
  1206. :deep(.right-panel-slide-leave-to) {
  1207. opacity: 0;
  1208. transform: translateX(30px);
  1209. }
  1210. .draggable-title {
  1211. cursor: grab;
  1212. user-select: none;
  1213. &:active {
  1214. cursor: grabbing;
  1215. }
  1216. &.no-drag {
  1217. cursor: default;
  1218. &:active {
  1219. cursor: default;
  1220. }
  1221. }
  1222. }
  1223. </style>
  1224. <style lang="less">
  1225. @import url('../styles/modal.less');
  1226. </style>