1
0

AiAssistantModal.vue 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072
  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="openFilePreview"
  68. @agent-detail-click="handleAgentDetailClick"
  69. @locate-message="handleLocateMessage"
  70. />
  71. <!-- 附件列表面板 -->
  72. <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="openFilePreview" />
  73. <!-- 消息列表 -->
  74. <ChatMessages
  75. ref="chatMessagesRef"
  76. :messages="messages"
  77. :history-loading="isCurrentHistoryLoading"
  78. @file-preview="openFilePreview"
  79. @download-word="downloadWordFile"
  80. @open-file-preview="handleOpenFilePreview"
  81. @ask-user-submit="handleAskUserSubmit"
  82. @ask-user-cancel="handleAskUserCancel"
  83. @agent-detail-click="handleAgentDetailClick"
  84. @open-markdown-tab="openMarkdownPreviewTab"
  85. @delete-message="handleDeleteMessage"
  86. @regenerate="handleRegenerate"
  87. @edit-resend="handleEditResend"
  88. />
  89. <!-- 任务进度条 -->
  90. <TodoListBar v-if="showTodoBar" :todos="currentTodos" />
  91. <!-- 输入区域 -->
  92. <!-- [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3)::isAdmin 透传给输入区,
  93. 由输入区隐藏「思考级别」切换控件;注意只隐藏"切换",
  94. 思考级别的读取(getModelMsg)对所有人保持开放 -->
  95. <ChatInputArea
  96. ref="chatInputRef"
  97. v-model="inputMessage"
  98. :pendingFile="pendingFile"
  99. :loading="loading || !!currentPendingApproval"
  100. :streaming="loading"
  101. :contextUsed="contextUsed"
  102. :contextMax="contextMax"
  103. :contextPercent="contextPercent"
  104. :contextBreakdown="contextBreakdown"
  105. :usageLimit="usageToday?.limit_tokens ?? 0"
  106. :usageSpent="usageToday?.spent_tokens ?? 0"
  107. :usageRemaining="usageToday?.remaining_tokens ?? 0"
  108. :usageByModel="usageToday?.by_model ?? []"
  109. :initialThinkLevel="thinkLevel"
  110. :isAdmin="isAdmin"
  111. :initialEditMode="editMode"
  112. :taskList="taskList"
  113. :currentTaskId="currentTaskId"
  114. @send="handleSendMessage"
  115. @stop="stopStreaming"
  116. @file-upload="triggerFileUpload"
  117. @file-change="handleFileUpload"
  118. @file-add="handleFileAdded"
  119. @remove-pending-file="removePendingFile"
  120. @pending-file-preview="openFilePreview"
  121. @think-level-change="handleThinkLevelChange"
  122. @edit-mode-change="handleEditModeChange"
  123. @update:selected-sessions="handleUpdateSelectedSessions"
  124. @update:selected-skill="handleUpdateSelectedSkill"
  125. />
  126. </div>
  127. <!-- 右侧辅助面板(标签页容器,与 chat-panel 并列,带滑入滑出动画) -->
  128. <Transition name="right-panel-slide" @after-leave="onPanelLeaveEnd">
  129. <RightPanelTabs
  130. v-if="currentRightPanels.tabs.length > 0"
  131. :tabs="currentRightPanels.tabs"
  132. :activeTabId="currentRightPanels.activeTabId"
  133. :messages="messages"
  134. @switch-tab="activateTab"
  135. @close-tab="closeTab"
  136. @save-success="handleSaveSuccess"
  137. @schedule-deleted="handleScheduleDeleted"
  138. @schedule-updated="handleScheduleUpdated"
  139. @open-schedule-session="handleOpenScheduleSession"
  140. @open-markdown-tab="openMarkdownPreviewTab"
  141. />
  142. </Transition>
  143. </template>
  144. <!-- 技能面板:非管理员只读(面板内部按 isAdmin 隐藏/禁用写操作) -->
  145. <div v-if="activePanel === 'skill'" class="right-panel full">
  146. <SkillPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
  147. </div>
  148. <!-- 子智能体面板:非管理员只读 -->
  149. <div v-if="activePanel === 'sub-agents'" class="right-panel full">
  150. <SubAgentPanel :is-admin="isAdmin" @close="activePanel = 'chat'" />
  151. </div>
  152. <!-- 定时任务面板 -->
  153. <div v-if="activePanel === 'schedules'" class="right-panel full">
  154. <SchedulePanel
  155. ref="schedulePanelRef"
  156. @close="activePanel = 'chat'"
  157. @open-session="handleOpenScheduleSession"
  158. @data-changed="fetchScheduleListForDetail"
  159. />
  160. </div>
  161. </div>
  162. </a-modal>
  163. </template>
  164. <script setup lang="ts">
  165. import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
  166. import dayjs from 'dayjs';
  167. // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):引入 getMe 用于按 is_admin 隐藏管理入口
  168. import { getDetail, deleteSession, deleteMessage, StreamError, getMe } from '/@/views/ventAI/manageAssistent/api';
  169. import { message, Modal } from 'ant-design-vue';
  170. import TaskListPanel from '/@/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue';
  171. import ChatHeader from '/@/views/ventAI/manageAssistent/components/chat/ChatHeader.vue';
  172. import FileListPanel from '/@/views/ventAI/manageAssistent/components/panels/FileListPanel.vue';
  173. import ChatMessages from '/@/views/ventAI/manageAssistent/components/chat/ChatMessages.vue';
  174. import ChatInputArea from '/@/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue';
  175. import TodoListBar from '/@/views/ventAI/manageAssistent/components/chat/TodoListBar.vue';
  176. import SkillPanel from '/@/views/ventAI/manageAssistent/components/panels/SkillPanel.vue';
  177. import SubAgentPanel from '/@/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue';
  178. import SchedulePanel from '/@/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue';
  179. import RightPanelTabs from '/@/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue';
  180. import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
  181. import type { Message, ScheduleTask, Task } from '/@/views/ventAI/manageAssistent/types/index';
  182. import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
  183. import { useChatStream } from '../hooks/useChatStream';
  184. import { useChatTasks } from '../hooks/useChatTasks';
  185. import { useChatPanels } from '../hooks/useChatPanels';
  186. import { useChatDrag } from '../hooks/useChatDrag';
  187. import { useChatApproval } from '../hooks/useChatApproval';
  188. import { useChatSend } from '../hooks/useChatSend';
  189. import { useChatScroll } from '../hooks/useChatScroll';
  190. import { useChatFiles } from '../hooks/useChatFiles';
  191. import { useFileDrop } from '../hooks/useFileDrop';
  192. import { useChatQuota } from '../hooks/useChatQuota';
  193. import { useChatExport } from '../hooks/useChatExport';
  194. interface Props {
  195. visible: boolean;
  196. // 初始即全屏显示(首页等嵌入场景)
  197. defaultFullscreen?: boolean;
  198. // 是否显示窗口控制按钮(最大化/还原、关闭)
  199. showControls?: boolean;
  200. zIndex?: number;
  201. // 挂载容器:不传则默认挂到 body;首页等场景传入 adaptive-container,使其与页面同坐标系、同缩放
  202. getContainer?: () => HTMLElement;
  203. }
  204. interface Emits {
  205. (e: 'close'): void;
  206. }
  207. const props = withDefaults(defineProps<Props>(), {
  208. defaultFullscreen: false,
  209. showControls: true,
  210. });
  211. const emit = defineEmits<Emits>();
  212. // 弹框真正渲染/被 antd 滚动锁锁定的目标:包一层 size-0 空壳,
  213. // 避免 antd Modal 打开时滚动锁直接改写 #adaptive-container 的宽度(导致整页缩放错乱、露出白边)
  214. const portalWrapper = ref<HTMLDivElement | null>(null);
  215. const getModalContainer = (): HTMLElement => {
  216. if (portalWrapper.value) return portalWrapper.value;
  217. const target = props.getContainer ? props.getContainer() : document.body;
  218. const wrapper = document.createElement('div');
  219. wrapper.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;';
  220. target.appendChild(wrapper);
  221. portalWrapper.value = wrapper;
  222. return wrapper;
  223. };
  224. // —— 会话状态与列表相关职责已下沉到 useChatTasks ——
  225. const {
  226. taskList,
  227. currentTaskId,
  228. currentTask,
  229. messages,
  230. historyLoadedTasks,
  231. historyLoadingTaskIds,
  232. setHistoryLoading,
  233. getDefaultMessages,
  234. transformHistoryToMessages,
  235. fetchSessionList,
  236. handlePinTask,
  237. handleSaveTitle,
  238. setCurrentSessionId,
  239. } = useChatTasks();
  240. // 当前任务的历史是否正在拉取:切换任务时新任务的 messages 要等接口回来才填充,
  241. // 消息区据此显示加载态,避免中间先闪一屏空会话缺省图。
  242. const isCurrentHistoryLoading = computed(() => historyLoadingTaskIds.value.includes(currentTaskId.value));
  243. // —— 右侧面板/标签页/全屏相关职责已下沉到 useChatPanels ——
  244. const {
  245. showFileList,
  246. activePanel,
  247. panelCollapsed,
  248. togglePanel,
  249. toggleSkillPanel,
  250. toggleSchedulePanel,
  251. toggleSubAgentPanel,
  252. isFullscreen,
  253. currentRightPanels,
  254. modalWidth,
  255. onPanelLeaveEnd,
  256. toggleFullscreen,
  257. getPanelState,
  258. activateTab,
  259. removeTab,
  260. closeTab,
  261. handleAgentDetailClick,
  262. handleLocateMessage,
  263. openMarkdownPreviewTab,
  264. openFilePreview,
  265. handleOpenFilePreview,
  266. handleSaveSuccess,
  267. getCurrentTaskFiles,
  268. } = useChatPanels({
  269. getCurrentTask: () => currentTask.value,
  270. getMessages: () => messages.value,
  271. getChatMessages: () => chatMessagesRef.value,
  272. getContainer: () => (props.getContainer ? props.getContainer() : document.body),
  273. resetModalPosition: () => drag.centerModal(),
  274. defaultFullscreen: !!props.defaultFullscreen,
  275. });
  276. // —— 拖拽/居中职责已下沉到 useChatDrag(与面板全屏共用 lib/modalWrap) ——
  277. const drag = useChatDrag({
  278. getIsFullscreen: () => isFullscreen.value,
  279. getVisible: () => props.visible,
  280. });
  281. const { onDragStart } = drag;
  282. // —— 待审批流程已下沉到 useChatApproval ——
  283. const { setTaskPendingApproval, currentPendingApproval, migrateTaskApproval, handleAskUserSubmit, handleAskUserCancel } = useChatApproval({
  284. getTaskList: () => taskList.value,
  285. getCurrentTaskId: () => currentTaskId.value,
  286. switchTask: (id) => switchTask(id),
  287. setTaskAbortController: (id, controller) => setTaskAbortController(id, controller),
  288. handleStreamEvent: (data, aiMsg, taskId) => chatStream.handleEvent(data, aiMsg, taskId),
  289. scrollToBottom: (force) => scrollToBottom(force),
  290. scrollToBottomThrottled: (force) => scrollToBottomThrottled(force),
  291. refreshUsage: () => fetchTodayUsage(),
  292. resetTodos: () => {
  293. showTodoBar.value = false;
  294. currentTodos.value = [];
  295. },
  296. setCurrentSessionId: (id) => setCurrentSessionId(id),
  297. });
  298. // —— 发送/流式管线已下沉到 useChatSend(handleSendMessage 作为输入框编排留在组件里) ——
  299. const {
  300. loading,
  301. stopStreaming,
  302. setTaskAbortController,
  303. abortAllStreams,
  304. getSkillRefIconUrl,
  305. getSessionRefIconUrl,
  306. sendTextOnly,
  307. sendWithAttachment,
  308. handleRegenerate,
  309. handleEditResend,
  310. } = useChatSend({
  311. getTaskList: () => taskList.value,
  312. getCurrentTaskId: () => currentTaskId.value,
  313. setCurrentTaskId: (taskId) => {
  314. currentTaskId.value = taskId;
  315. },
  316. getMessages: () => messages.value,
  317. setTaskList: (next) => {
  318. taskList.value = next;
  319. },
  320. getEditMode: () => editMode.value,
  321. getPendingApproval: () => currentPendingApproval.value,
  322. getChatMessages: () => chatMessagesRef.value,
  323. handleStreamEvent: (data, aiMsg, taskId) => chatStream.handleEvent(data, aiMsg, taskId),
  324. scrollToBottomThrottled: (force) => scrollToBottomThrottled(force),
  325. resetTodos: () => {
  326. showTodoBar.value = false;
  327. currentTodos.value = [];
  328. },
  329. migrateTaskApproval: (from, to) => migrateTaskApproval(from, to),
  330. historyLoadedTasks,
  331. transformHistoryToMessages,
  332. });
  333. // 胶囊聚合数据:转换逻辑内聚在 capsuleData 纯函数模块中,此处只做响应式接线
  334. const capsuleFiles = computed(() => buildCapsuleFiles(currentTask.value, messages.value));
  335. const capsuleLinks = computed(() => buildCapsuleLinks(capsuleFiles.value, messages.value));
  336. const capsuleAgents = computed(() => buildCapsuleAgents(messages.value, (i) => chatMessagesRef.value?.isAnswerVisible(i)));
  337. // 顶部上一个/下一个会话导航
  338. const currentTaskIndex = computed(() => taskList.value.findIndex((t) => t.id === currentTaskId.value));
  339. const canGoPrev = computed(() => currentTaskIndex.value > 0);
  340. const canGoNext = computed(() => currentTaskIndex.value >= 0 && currentTaskIndex.value < taskList.value.length - 1);
  341. const goToPrev = () => {
  342. if (!canGoPrev.value) return;
  343. const prevTask = taskList.value[currentTaskIndex.value - 1];
  344. if (prevTask) switchTask(prevTask.id);
  345. };
  346. const goToNext = () => {
  347. if (!canGoNext.value) return;
  348. const nextTask = taskList.value[currentTaskIndex.value + 1];
  349. if (nextTask) switchTask(nextTask.id);
  350. };
  351. // 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
  352. const handleDeleteMessage = (msg: Message) => {
  353. if (!msg.id) return;
  354. // 确认框与删除请求都是异步的,用户可能在这期间切到别的会话:记住这条消息所属的任务,
  355. // 写回它而不是「当前任务」,否则被删的消息会一直留在原任务里直到刷新。
  356. const ownerTask = currentTask.value;
  357. Modal.confirm({
  358. title: '提示',
  359. content: '是否删除该条消息?',
  360. okText: '确认',
  361. cancelText: '取消',
  362. wrapClassName: 'delete-task-confirm-modal',
  363. onOk: async () => {
  364. try {
  365. await deleteMessage(msg.id!);
  366. message.success('消息已删除');
  367. if (ownerTask) {
  368. ownerTask.messages = ownerTask.messages.filter((m) => m !== msg);
  369. }
  370. } catch {
  371. message.error('删除失败');
  372. }
  373. },
  374. });
  375. };
  376. const chatInputRef = ref<InstanceType<typeof ChatInputArea>>();
  377. const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
  378. const scheduleListForDetail = ref<ScheduleTask[]>([]);
  379. const fetchScheduleListForDetail = async () => {
  380. try {
  381. const res = await getScheduleList();
  382. // getScheduleList 已按真实契约标注 { tasks, total }
  383. scheduleListForDetail.value = res?.tasks ?? [];
  384. } catch (e) {
  385. console.error('获取定时任务列表失败:', e);
  386. scheduleListForDetail.value = [];
  387. message.error('获取定时任务列表失败,定时任务详情可能不是最新');
  388. }
  389. };
  390. const currentTodos = ref<Array<{ content: string; status: string }>>([]);
  391. const showTodoBar = ref(false);
  392. const syncTodoBarFromMessages = () => {
  393. for (let i = messages.value.length - 1; i >= 0; i--) {
  394. const msg = messages.value[i];
  395. if (msg.type === 'ai' && msg.thinkingSteps) {
  396. for (let j = msg.thinkingSteps.length - 1; j >= 0; j--) {
  397. const step = msg.thinkingSteps[j];
  398. if (step.type === 'updated_todo_list' && step.todos) {
  399. currentTodos.value = [...step.todos];
  400. showTodoBar.value = true;
  401. return;
  402. }
  403. }
  404. }
  405. }
  406. showTodoBar.value = false;
  407. currentTodos.value = [];
  408. };
  409. const handleOpenScheduleSession = async (sessionId: string, prompt?: string) => {
  410. activePanel.value = 'chat';
  411. await fetchSessionList();
  412. await fetchScheduleListForDetail();
  413. const existing = taskList.value.find((t) => t.sessionId === sessionId);
  414. if (existing) {
  415. await switchTask(existing.id);
  416. } else {
  417. const newTask: Task = {
  418. id: sessionId,
  419. name: '定时任务',
  420. sessionId,
  421. messages: [],
  422. attachedFiles: [],
  423. };
  424. taskList.value.unshift(newTask);
  425. historyLoadedTasks.delete(sessionId);
  426. await switchTask(newTask.id);
  427. }
  428. // 如果有 prompt,自动发送消息
  429. if (prompt) {
  430. inputMessage.value = prompt;
  431. await nextTick();
  432. await handleSendMessage();
  433. }
  434. };
  435. const schedulePanelRef = ref();
  436. const handleScheduleDeleted = () => {
  437. const task = currentTask.value;
  438. if (task?.rightPanels) {
  439. const tab = task.rightPanels.tabs.find((t) => t.type === 'schedule');
  440. if (tab) removeTab(task.rightPanels, tab.id);
  441. }
  442. fetchScheduleListForDetail();
  443. schedulePanelRef.value?.fetchScheduleList?.();
  444. };
  445. const handleScheduleUpdated = async () => {
  446. await fetchScheduleListForDetail();
  447. // 同步标签内任务对象为最新数据(编辑/启停后)
  448. const task = currentTask.value;
  449. const tab = task?.rightPanels?.tabs.find((t) => t.type === 'schedule');
  450. if (tab?.type === 'schedule') {
  451. const fresh = scheduleListForDetail.value.find((s) => s.id === tab.task.id);
  452. if (fresh) tab.task = fresh;
  453. }
  454. schedulePanelRef.value?.fetchScheduleList?.();
  455. };
  456. const inputMessage = ref('');
  457. const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
  458. const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
  459. // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):当前登录用户是否管理员
  460. // (TfAgents /ventAI/api/model/me 的 is_admin;原审计写 /api/me,经实测校正,见交付说明 §⑩)。
  461. // 默认 false = 保守语义:取不到权限信息时按非管理员处理,管理入口隐藏。
  462. const isAdmin = ref(false);
  463. // —— 以下四组职责已下沉到 composables(额度/导出/滚动/文件),组件只做接线 ——
  464. const { scrollToBottom, scrollToBottomThrottled } = useChatScroll(() => chatMessagesRef.value);
  465. const { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, addFile, removePendingFile } = useChatFiles(() => chatInputRef.value);
  466. // 拖拽 / 粘贴进来的文件与「选择文件」共用同一条入队路径:校验与读取分流都在 addFile 内
  467. const handleFileAdded = (file: File) => {
  468. addFile(file);
  469. };
  470. const handleDropFiles = (files: File[]) => {
  471. // 待发附件是单值,多文件拖入时取第一个并说明,避免用户以为全都加上了
  472. if (files.length > 1) message.warning('一次只能添加一个附件,已取第一个文件');
  473. addFile(files[0]);
  474. };
  475. const { isDragOver, dragHandlers } = useFileDrop({
  476. onFiles: handleDropFiles,
  477. isVisible: () => props.visible,
  478. });
  479. const { onDragEnter, onDragOver, onDragLeave, onDrop } = dragHandlers;
  480. const {
  481. usageToday,
  482. fetchTodayUsage,
  483. contextUsed,
  484. contextMax,
  485. contextPercent,
  486. contextBreakdown,
  487. thinkLevel,
  488. editMode,
  489. fetchModelMsg,
  490. fetchSessionMode,
  491. fetchContext,
  492. invalidateQuotaFetch,
  493. handleThinkLevelChange,
  494. handleEditModeChange,
  495. } = useChatQuota(() => getCurrentSessionId());
  496. const { currentWordUrl, downloadWordFile, downloadWordReport, handleExportMarkdown, handleExportWord } = useChatExport(
  497. () => messages.value,
  498. () => currentTask.value
  499. );
  500. const handleUpdateSelectedSessions = (sessions: Array<{ id: string; name: string }>) => {
  501. selectedSessions.value = sessions;
  502. };
  503. const handleUpdateSelectedSkill = (skill: { name: string; display_name: string } | null) => {
  504. selectedSkill.value = skill;
  505. };
  506. const getCurrentSessionId = (): string => {
  507. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  508. return currentTask?.sessionId || '';
  509. };
  510. const switchTask = async (taskId: string) => {
  511. activePanel.value = 'chat';
  512. // 单一数据源:messages 直接读当前任务,切换任务前无需把视图数组回写到旧任务
  513. currentTaskId.value = taskId;
  514. const newTask = taskList.value.find((t) => t.id === taskId);
  515. if (!newTask) return;
  516. inputMessage.value = '';
  517. pendingFile.value = null;
  518. showFileList.value = false;
  519. if (newTask.sessionId && !historyLoadedTasks.has(taskId)) {
  520. // 流式中的任务不拉历史(避免覆盖在途消息),但必须继续走完下面的初始化:
  521. // currentTaskId 已经切过来了,提前 return 会让上下文用量/模式/进度条/滚动全部停在上一个任务上。
  522. if (!newTask.isStreaming) {
  523. // 标记加载中:消息区据 isCurrentHistoryLoading 显示加载态,而不是先闪一屏空会话
  524. setHistoryLoading(taskId, true);
  525. try {
  526. const res = await getDetail(newTask.sessionId);
  527. const messages = res?.messages;
  528. if (Array.isArray(messages)) {
  529. newTask.messages = transformHistoryToMessages(messages);
  530. }
  531. historyLoadedTasks.add(taskId);
  532. } catch (error) {
  533. // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
  534. console.error('加载历史会话失败:', error);
  535. message.error('加载历史会话失败,请稍后重试');
  536. } finally {
  537. setHistoryLoading(taskId, false);
  538. }
  539. }
  540. }
  541. if (newTask.sessionId) {
  542. fetchContext(newTask.sessionId);
  543. fetchSessionMode(newTask.sessionId);
  544. } else {
  545. // 空白会话不拉取额度:作废在途请求,否则上一条会话的响应会盖掉下面刚重置的默认值
  546. invalidateQuotaFetch();
  547. contextUsed.value = 0;
  548. contextPercent.value = 0;
  549. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  550. editMode.value = 'full';
  551. }
  552. syncTodoBarFromMessages();
  553. // 切换任务时检测是否为定时任务会话:自动打开/关闭定时任务详情标签
  554. const scheduleItem = newTask.sessionId ? scheduleListForDetail.value.find((s) => s.feedback_session_id === newTask.sessionId) : undefined;
  555. const panelState = getPanelState(newTask);
  556. const existingScheduleTab = panelState.tabs.find((t) => t.type === 'schedule');
  557. if (scheduleItem) {
  558. if (existingScheduleTab) {
  559. panelState.activeTabId = existingScheduleTab.id;
  560. } else {
  561. panelState.tabs.push({ id: 'schedule', type: 'schedule', title: scheduleItem.name || '定时任务', task: scheduleItem });
  562. panelState.activeTabId = 'schedule';
  563. }
  564. } else if (existingScheduleTab) {
  565. removeTab(panelState, existingScheduleTab.id);
  566. }
  567. await nextTick();
  568. chatMessagesRef.value?.forceScrollToBottom();
  569. };
  570. const createNewTask = () => {
  571. const existingBlank = taskList.value.find((t) => !t.sessionId && t.messages.length === 0);
  572. if (existingBlank) {
  573. switchTask(existingBlank.id);
  574. return;
  575. }
  576. const newTask: Task = {
  577. id: `task-${Date.now()}`,
  578. name: '新任务',
  579. sessionId: '',
  580. messages: getDefaultMessages(),
  581. attachedFiles: [],
  582. };
  583. invalidateQuotaFetch();
  584. contextUsed.value = 0;
  585. contextPercent.value = 0;
  586. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  587. editMode.value = 'full';
  588. taskList.value.unshift(newTask);
  589. switchTask(newTask.id);
  590. };
  591. const handleDeleteTask = (task: Task, _index: number) => {
  592. Modal.confirm({
  593. title: '提示',
  594. content: '是否删除该对话?',
  595. okText: '确认',
  596. cancelText: '取消',
  597. wrapClassName: 'delete-task-confirm-modal',
  598. onOk: async () => {
  599. try {
  600. if (task.sessionId) {
  601. await deleteSession(task.sessionId);
  602. }
  603. const realIndex = taskList.value.findIndex((t) => t.id === task.id);
  604. if (realIndex !== -1) {
  605. taskList.value.splice(realIndex, 1);
  606. }
  607. if (currentTaskId.value === task.id) {
  608. if (taskList.value.length > 0) {
  609. switchTask(taskList.value[0].id);
  610. } else {
  611. // 任务已清空:没有当前任务后 messages 自动回落到空数组
  612. currentTaskId.value = '';
  613. }
  614. }
  615. message.success('删除成功');
  616. } catch {
  617. message.error('删除失败');
  618. }
  619. },
  620. });
  621. };
  622. const handleSendMessage = async () => {
  623. const hasText = inputMessage.value.trim();
  624. const hasFile = pendingFile.value;
  625. if (!hasText) {
  626. message.warning('请输入消息内容');
  627. return;
  628. }
  629. if (loading.value) return;
  630. if (currentPendingApproval.value) {
  631. message.warning('请先审批执行计划');
  632. return;
  633. }
  634. // 没有当前任务时(例如会话被全部删除)先建一个:消息是挂在任务上的,
  635. // 否则 push 出去的消息会落在视图之外的临时数组里,之后再也不会显示
  636. if (!currentTask.value) {
  637. await createNewTask();
  638. if (!currentTask.value) return;
  639. }
  640. const sessionPrefix = selectedSessions.value.length > 0 ? selectedSessions.value.map((s) => `#session:${s.id}`).join(' ') + ' ' : '';
  641. const skillPrefix = selectedSkill.value ? `#skill:${selectedSkill.value.name} ` : '';
  642. const userInput = skillPrefix + sessionPrefix + inputMessage.value.trim();
  643. let displayContent = inputMessage.value.trim();
  644. if (selectedSkill.value) {
  645. const skillIconUrl = getSkillRefIconUrl();
  646. displayContent = `[<img src="${skillIconUrl}" class="session-ref-icon" /> 技能:${selectedSkill.value.display_name}] ${displayContent}`;
  647. }
  648. if (selectedSessions.value.length > 0) {
  649. const names = selectedSessions.value.map((s) => s.name).join('、');
  650. const iconUrl = getSessionRefIconUrl();
  651. displayContent = `[<img src="${iconUrl}" class="session-ref-icon" /> 引用 ${selectedSessions.value.length} 个会话:${names}] ${displayContent}`;
  652. }
  653. const fileToSend = pendingFile.value;
  654. inputMessage.value = '';
  655. pendingFile.value = null;
  656. selectedSessions.value = [];
  657. selectedSkill.value = null;
  658. // 记住本次发送属于哪个任务:请求期间用户可能切到别的会话,失败时的报错必须落回发起它的任务,
  659. // 否则报错气泡会塞进正在看的那条会话,而发起任务的占位消息没人清理(永远停在「正在输入」)。
  660. const originTask = currentTask.value;
  661. try {
  662. await nextTick();
  663. chatMessagesRef.value?.forceScrollToBottom();
  664. if (hasFile) {
  665. await sendWithAttachment(fileToSend!, userInput, displayContent);
  666. } else {
  667. await sendTextOnly(userInput, displayContent);
  668. }
  669. } catch (error) {
  670. console.error('发送消息失败:', error);
  671. const isStreamError = error instanceof StreamError;
  672. const errText = isStreamError ? error.message : '发送失败,请重试';
  673. message.error(errText);
  674. if (isStreamError) fetchTodayUsage();
  675. // 归位到发起本次请求的任务。任务可能在请求期间被删除,那时只保留上面的提示。
  676. if (originTask) {
  677. const msgs = originTask.messages;
  678. const lastMsg = msgs[msgs.length - 1];
  679. if (lastMsg && lastMsg.type === 'ai' && lastMsg.isLoading) {
  680. msgs.pop();
  681. }
  682. msgs.push({
  683. type: 'ai',
  684. content: isStreamError ? error.message : '抱歉,请求失败,请稍后重试。',
  685. time: dayjs().format('HH:mm'),
  686. });
  687. }
  688. } finally {
  689. // 自动滚到底只对「用户仍停在这条任务上」有意义,否则会把正在看的别的会话拽到底部。
  690. // 按对象身份比较:任务在拿到真实 session_id 后会被改名(id 会变),用 id 比较会误判。
  691. if (currentTask.value === originTask) {
  692. await scrollToBottom(true);
  693. }
  694. }
  695. };
  696. // 统一对话流(SSE)事件管线:事件 → 消息状态的映射收敛在 hooks/useChatStream 内,
  697. // 本组件只注入「任务查找 / 进度条 / 用量刷新 / 待审批登记 / 答案初始化」这些视图侧钩子。
  698. const chatStream = useChatStream({
  699. findTask: (taskId) => taskList.value.find((t) => t.id === taskId),
  700. isCurrentTask: (taskId) => currentTaskId.value === taskId,
  701. setTodos: (todos) => {
  702. currentTodos.value = [...todos];
  703. showTodoBar.value = true;
  704. },
  705. refreshContext: (sessionId) => {
  706. fetchContext(sessionId);
  707. },
  708. refreshUsage: () => {
  709. fetchTodayUsage();
  710. },
  711. markPendingApproval: (taskId, sessionId, threadId) => {
  712. setTaskPendingApproval(taskId, sessionId, threadId);
  713. },
  714. initAskUserAnswers: (aiMsg, questions) => {
  715. chatMessagesRef.value?.initAskUserAnswers(aiMsg, questions);
  716. },
  717. });
  718. /**
  719. * 拉取当前登录用户的权限信息(TfAgents 的 GET /ventAI/api/model/me)。
  720. *
  721. * [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3)。
  722. * 为什么需要:TfAgents 已把「技能上传/启停、子智能体增删改、思考级别切换」等全局变更端点收归管理员
  723. * (用户已定案方案 B:后端返回 is_admin,前端隐藏入口),基座据此隐藏对应入口,避免非管理员点了才被 403。
  724. *
  725. * 安全语义(保守失败,历史踩坑点必须保留):任何异常(未登录 401 / 接口未上线 404 / 网络失败)
  726. * 一律按「非管理员」处理——isAdmin 置 false,且不缓存结果(每次打开都重新拉取,管理员名单可能在
  727. * 服务端 .env 变更)。注意这只是界面可见性,真正的拦截仍在 TfAgents 后端——前端隐藏不等于安全。
  728. */
  729. const fetchMe = async () => {
  730. try {
  731. const me = await getMe();
  732. isAdmin.value = !!me?.is_admin;
  733. } catch (e) {
  734. // 取不到(未登录/接口未上线/令牌失效)→ 按非管理员处理,隐藏管理入口
  735. isAdmin.value = false;
  736. console.warn('获取当前用户权限失败,管理入口将隐藏:', e);
  737. }
  738. };
  739. // 发送流式 AI 回答的核心:推送一条空的 AI 消息 → 建 AbortController → 调 SSE → 处理 session_id 迁移 → 异常/清理。
  740. // 正常发送、编辑重发、重新生成都复用此函数,file 存在且带 originalFile 时走 PDF 审查流,否则走纯文本流。
  741. // Throttled version for SSE streaming (no await, uses rAF)
  742. // immediate:组件若在挂载时就已经是可见状态(例如宿主页面默认展开、或恢复了上次的展开状态),
  743. // false→true 的边沿不会发生,初始化就永远不执行,面板会停在空白状态。立即执行一次并让不可见分支
  744. // 直接返回,等价于「打开时才初始化」的懒加载语义。
  745. watch(
  746. () => props.visible,
  747. async (newVisible) => {
  748. // 关闭弹框无需任何回写:消息单一数据源直接挂在任务对象上;仅在打开时做初始化
  749. if (newVisible) {
  750. await fetchSessionList();
  751. fetchModelMsg();
  752. fetchScheduleListForDetail();
  753. fetchTodayUsage();
  754. // [审计-第四轮 M3] 重放自我方 fix/base-agent-token(M1/M2/M3):每次打开时刷新权限(不缓存;失败置 false 见 fetchMe)
  755. fetchMe();
  756. // 去除重复的空白任务,只保留一个
  757. taskList.value = taskList.value.filter((task, index) => {
  758. if (task.sessionId) return true;
  759. return index === taskList.value.findIndex((t) => !t.sessionId);
  760. });
  761. // 优先回到关闭前所在的任务(如流式中的新任务),否则默认选中第一条历史记录
  762. const firstHistoryTask = taskList.value.find((t) => t.sessionId);
  763. const prevTask = taskList.value.find((t) => t.id === currentTaskId.value);
  764. let targetTask: Task;
  765. if (prevTask) {
  766. targetTask = prevTask;
  767. } else if (firstHistoryTask) {
  768. targetTask = firstHistoryTask;
  769. } else {
  770. // 没有历史记录时才创建空白新任务
  771. targetTask = {
  772. id: `task-${Date.now()}`,
  773. name: '新任务',
  774. sessionId: '',
  775. messages: getDefaultMessages(),
  776. attachedFiles: [],
  777. };
  778. taskList.value.unshift(targetTask);
  779. }
  780. currentTaskId.value = targetTask.id;
  781. // 如果是历史任务,加载其消息
  782. if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
  783. setHistoryLoading(targetTask.id, true);
  784. try {
  785. const res = await getDetail(targetTask.sessionId);
  786. const messages = res?.messages;
  787. if (Array.isArray(messages)) {
  788. targetTask.messages = transformHistoryToMessages(messages);
  789. }
  790. historyLoadedTasks.add(targetTask.id);
  791. } catch (error) {
  792. console.error('加载历史会话失败:', error);
  793. message.error('加载历史会话失败,请稍后重试');
  794. } finally {
  795. setHistoryLoading(targetTask.id, false);
  796. }
  797. }
  798. if (targetTask.sessionId) {
  799. fetchContext(targetTask.sessionId);
  800. fetchSessionMode(targetTask.sessionId);
  801. } else {
  802. invalidateQuotaFetch();
  803. contextUsed.value = 0;
  804. contextPercent.value = 0;
  805. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  806. editMode.value = 'full';
  807. }
  808. syncTodoBarFromMessages();
  809. pendingFile.value = null;
  810. showFileList.value = false;
  811. inputMessage.value = '';
  812. await nextTick(() => {
  813. chatMessagesRef.value?.forceScrollToBottom();
  814. });
  815. }
  816. },
  817. { immediate: true }
  818. );
  819. onBeforeUnmount(() => {
  820. // 移除挂载空壳,避免残留
  821. portalWrapper.value?.remove();
  822. portalWrapper.value = null;
  823. // 组件卸载时主动断开所有任务的流式连接
  824. abortAllStreams();
  825. // Abort in-flight FileReader if any
  826. if (activeFileReader.value) {
  827. activeFileReader.value.abort();
  828. activeFileReader.value = null;
  829. }
  830. });
  831. </script>
  832. <style scoped lang="less">
  833. @import '../styles/tokens.less';
  834. .ai-container {
  835. display: flex;
  836. height: 700px;
  837. gap: 10px;
  838. overflow: hidden;
  839. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  840. /* 拖拽提示遮罩的定位基准 */
  841. position: relative;
  842. }
  843. /* 拖拽上传提示:z-index 低于弹框的收起/最大化按钮(10),拖拽时窗口控件仍保持可见 */
  844. .drop-overlay {
  845. position: absolute;
  846. top: 0;
  847. right: 0;
  848. bottom: 0;
  849. left: 0;
  850. z-index: 5;
  851. display: flex;
  852. align-items: center;
  853. justify-content: center;
  854. pointer-events: none;
  855. background: @color-primary-a10;
  856. border: 2px dashed @color-primary-a50;
  857. border-radius: 4px;
  858. }
  859. .drop-overlay-text {
  860. padding: 8px 20px;
  861. border-radius: 6px;
  862. background: @color-panel-bg;
  863. border: 1px solid @color-panel-border;
  864. color: @color-text;
  865. font-size: 13px;
  866. }
  867. .collapse-btn {
  868. position: absolute;
  869. top: 16px;
  870. left: 24px;
  871. z-index: 10;
  872. width: 32px;
  873. height: 32px;
  874. background: #1a6696;
  875. border-radius: 4px;
  876. display: flex;
  877. align-items: center;
  878. justify-content: center;
  879. cursor: pointer;
  880. transition: background 0.3s;
  881. &:hover {
  882. background: #1f7ab8;
  883. }
  884. .collapse-icon {
  885. width: 16px;
  886. height: 16px;
  887. background-image: var(--img-chat-collapse-icon);
  888. background-repeat: no-repeat;
  889. background-size: 100% 100%;
  890. &.collapsed {
  891. background-image: var(--img-chat-expand-icon);
  892. }
  893. }
  894. }
  895. .task-panel-wrapper {
  896. width: 300px;
  897. flex-shrink: 0;
  898. overflow: hidden;
  899. transition:
  900. width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  901. margin 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  902. opacity 0.25s ease;
  903. &.collapsed {
  904. width: 0;
  905. margin: 0;
  906. opacity: 0;
  907. }
  908. }
  909. .chat-panel {
  910. flex: 1;
  911. display: flex;
  912. position: relative;
  913. flex-direction: column;
  914. background-image: var(--img-chat-panel-bg);
  915. background-repeat: no-repeat;
  916. background-size: 100% 100%;
  917. border-radius: 4px;
  918. overflow: hidden;
  919. min-width: 0;
  920. }
  921. .right-panel {
  922. width: calc(50% - 140px);
  923. height: 100%;
  924. display: flex;
  925. flex-direction: column;
  926. background-image: var(--img-chat-panel-bg);
  927. background-repeat: no-repeat;
  928. background-size: 100% 100%;
  929. border-radius: 4px;
  930. &.full {
  931. flex: 1;
  932. width: auto;
  933. min-width: 0;
  934. }
  935. flex-shrink: 0;
  936. }
  937. /* 右侧面板滑入滑出过渡动画 */
  938. :deep(.right-panel-slide-enter-active) {
  939. transition:
  940. opacity 0.3s ease,
  941. transform 0.3s ease;
  942. }
  943. :deep(.right-panel-slide-leave-active) {
  944. transition:
  945. opacity 0.25s ease,
  946. transform 0.25s ease;
  947. }
  948. :deep(.right-panel-slide-enter-from) {
  949. opacity: 0;
  950. transform: translateX(30px);
  951. }
  952. :deep(.right-panel-slide-leave-to) {
  953. opacity: 0;
  954. transform: translateX(30px);
  955. }
  956. .draggable-title {
  957. cursor: grab;
  958. user-select: none;
  959. &:active {
  960. cursor: grabbing;
  961. }
  962. &.no-drag {
  963. cursor: default;
  964. &:active {
  965. cursor: default;
  966. }
  967. }
  968. }
  969. </style>
  970. <style lang="less">
  971. @import '../styles/modal.less';
  972. </style>