AiAssistantModal.vue 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398
  1. <template>
  2. <a-modal
  3. :visible="props.visible"
  4. @cancel="emit('close')"
  5. :width="isFullscreen ? '100vw' : 1400"
  6. :footer="null"
  7. destroyOnClose
  8. :class="['ai-assistant-modal', { fullscreen: isFullscreen }]"
  9. >
  10. <template #title>
  11. <div :class="['draggable-title', { 'no-drag': isFullscreen }]" @mousedown="onDragStart">"通安智风"大模型</div>
  12. </template>
  13. <!-- 最大化/还原按钮 -->
  14. <div class="maximize-btn" @click="toggleFullscreen">
  15. <div :class="['maximize-icon', { minimized: isFullscreen }]"></div>
  16. </div>
  17. <!-- 收起/展开按钮 -->
  18. <div class="collapse-btn" @click="togglePanel">
  19. <div :class="['collapse-icon', { collapsed: panelCollapsed }]"></div>
  20. </div>
  21. <div class="ai-container">
  22. <!-- 左侧任务列表 -->
  23. <div :class="['task-panel-wrapper', { collapsed: panelCollapsed }]">
  24. <TaskListPanel
  25. :taskList="taskList"
  26. :currentTaskId="currentTaskId"
  27. @switch-task="switchTask"
  28. @create-task="createNewTask"
  29. @delete-task="handleDeleteTask"
  30. />
  31. </div>
  32. <!-- 中间聊天区域 -->
  33. <div class="chat-panel">
  34. <!-- 顶部任务选择器 -->
  35. <div class="chat-header">
  36. <template v-if="panelCollapsed">
  37. <div :class="['nav-btn', { disabled: !canGoPrev }]" @click="goToPrev">
  38. <div class="nav-icon prev-icon"></div>
  39. </div>
  40. <div :class="['nav-btn', { disabled: !canGoNext }]" @click="goToNext">
  41. <div class="nav-icon next-icon"></div>
  42. </div>
  43. <div class="nav-btn" @click="createNewTask">
  44. <div class="nav-icon add-icon"></div>
  45. </div>
  46. </template>
  47. <span v-if="currentTask?.sessionId" class="chat-header-title">{{ currentTask.name }}</span>
  48. <div v-if="currentWordUrl" class="upload-bg" @click="downloadWordReport">
  49. <div class="download-icon"></div>
  50. </div>
  51. </div>
  52. <!-- 附件列表面板 -->
  53. <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="openFilePreview" />
  54. <!-- 消息列表 -->
  55. <ChatMessages ref="chatMessagesRef" :messages="messages" @file-preview="openFilePreview" @download-word="downloadWordFile" />
  56. <!-- 输入区域 -->
  57. <ChatInputArea
  58. ref="chatInputRef"
  59. v-model="inputMessage"
  60. :pendingFile="pendingFile"
  61. :loading="loading"
  62. @send="handleSendMessage"
  63. @file-upload="triggerFileUpload"
  64. @file-change="handleFileUpload"
  65. @remove-pending-file="removePendingFile"
  66. />
  67. </div>
  68. <!-- 右侧文件预览面板 -->
  69. <div v-if="previewFile" class="right-panel">
  70. <FilePreviewPanel :file="previewFile" @close="removePreviewFile" />
  71. </div>
  72. </div>
  73. </a-modal>
  74. </template>
  75. <script setup lang="ts">
  76. import { ref, nextTick, watch, computed, onMounted, onBeforeUnmount } from 'vue';
  77. import dayjs from 'dayjs';
  78. import { unifiedStream, reviewPdfStream, getHistoryList, getDetail, deleteSession } from '../api';
  79. import { message, Modal } from 'ant-design-vue';
  80. import { isPdfFile } from './chatModal/utils';
  81. import TaskListPanel from './chatModal/TaskListPanel.vue';
  82. import FileListPanel from './chatModal/FileListPanel.vue';
  83. import ChatMessages from './chatModal/ChatMessages.vue';
  84. import ChatInputArea from './chatModal/ChatInputArea.vue';
  85. import FilePreviewPanel from './chatModal/FilePreviewPanel.vue';
  86. import type { AttachedFile, Message, Task } from './chatModal/types';
  87. interface Props {
  88. visible: boolean;
  89. }
  90. interface Emits {
  91. (e: 'close'): void;
  92. }
  93. const props = defineProps<Props>();
  94. const emit = defineEmits<Emits>();
  95. const taskList = ref<Task[]>([]);
  96. // 从接口获取会话列表
  97. const fetchSessionList = async () => {
  98. try {
  99. const res = await getHistoryList({});
  100. const sessions = res?.sessions || res?.data || [];
  101. if (Array.isArray(sessions)) {
  102. const apiTasks: Task[] = sessions.map((item: { session_id: string; title: string; updated_at: string }) => ({
  103. id: item.session_id,
  104. name: item.title || '未命名会话',
  105. sessionId: item.session_id,
  106. messages: [],
  107. attachedFiles: [],
  108. updatedAt: item.updated_at,
  109. }));
  110. const mergedTasks = apiTasks.map((apiTask) => {
  111. const existing = taskList.value.find((t) => t.id === apiTask.id);
  112. if (existing) {
  113. return {
  114. ...apiTask,
  115. messages: existing.messages,
  116. attachedFiles: existing.attachedFiles,
  117. wordUrl: existing.wordUrl,
  118. };
  119. }
  120. return apiTask;
  121. });
  122. const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTasks.find((a) => a.id === t.id));
  123. taskList.value = [...localOnlyTasks, ...mergedTasks];
  124. return mergedTasks;
  125. }
  126. } catch (error) {
  127. console.error('获取会话列表失败:', error);
  128. }
  129. return [];
  130. };
  131. const currentTaskId = ref('');
  132. const chatInputRef = ref<InstanceType<typeof ChatInputArea>>();
  133. const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
  134. const previewFile = ref<AttachedFile | null>(null);
  135. const showFileList = ref(false);
  136. const pendingFile = ref<AttachedFile | null>(null);
  137. const messages = ref<Message[]>([]);
  138. const panelCollapsed = ref(false);
  139. const togglePanel = () => {
  140. panelCollapsed.value = !panelCollapsed.value;
  141. };
  142. const isFullscreen = ref(false);
  143. const toggleFullscreen = () => {
  144. isFullscreen.value = !isFullscreen.value;
  145. nextTick(() => {
  146. const wraps = document.querySelectorAll<HTMLElement>('.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]');
  147. wraps.forEach((wrap) => {
  148. if (isFullscreen.value) {
  149. wrap.style.position = '';
  150. wrap.style.left = '';
  151. wrap.style.top = '0';
  152. wrap.style.right = '';
  153. wrap.style.bottom = '';
  154. wrap.style.width = '';
  155. wrap.style.height = '';
  156. wrap.style.margin = '0';
  157. wrap.style.padding = '0';
  158. wrap.style.transform = '';
  159. } else {
  160. wrap.style.position = '';
  161. wrap.style.left = '';
  162. wrap.style.top = '';
  163. wrap.style.right = '';
  164. wrap.style.bottom = '';
  165. wrap.style.width = '';
  166. wrap.style.height = '';
  167. wrap.style.margin = '';
  168. wrap.style.padding = '';
  169. wrap.style.transform = '';
  170. }
  171. });
  172. draggedEl = null;
  173. if (!isFullscreen.value) {
  174. centerModal();
  175. }
  176. });
  177. };
  178. const inputMessage = ref('');
  179. const loading = ref(false);
  180. const getCurrentSessionId = (): string => {
  181. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  182. return currentTask?.sessionId || '';
  183. };
  184. const historyLoadedTasks = ref<Set<string>>(new Set());
  185. const setCurrentSessionId = (sessionId: string) => {
  186. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  187. if (currentTask && currentTask.id !== sessionId) {
  188. taskList.value = taskList.value.filter((t) => t.id !== sessionId);
  189. currentTask.id = sessionId;
  190. currentTask.sessionId = sessionId;
  191. currentTaskId.value = sessionId;
  192. historyLoadedTasks.value.add(sessionId);
  193. }
  194. };
  195. const switchTask = async (taskId: string) => {
  196. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  197. if (currentTask) {
  198. currentTask.messages = [...messages.value];
  199. }
  200. currentTaskId.value = taskId;
  201. const newTask = taskList.value.find((t) => t.id === taskId);
  202. if (!newTask) return;
  203. if (newTask.sessionId) {
  204. taskList.value = taskList.value.filter((t) => t.sessionId);
  205. }
  206. inputMessage.value = '';
  207. pendingFile.value = null;
  208. previewFile.value = null;
  209. showFileList.value = false;
  210. if (newTask.sessionId && !historyLoadedTasks.value.has(taskId)) {
  211. loading.value = true;
  212. try {
  213. const res = await getDetail(newTask.sessionId);
  214. const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
  215. if (Array.isArray(messages)) {
  216. newTask.messages = transformHistoryToMessages(messages);
  217. }
  218. historyLoadedTasks.value.add(taskId);
  219. } catch (error) {
  220. console.error('加载历史会话失败:', error);
  221. } finally {
  222. loading.value = false;
  223. }
  224. }
  225. messages.value = newTask.messages && newTask.messages.length > 0 ? [...newTask.messages] : getDefaultMessages();
  226. await nextTick();
  227. chatMessagesRef.value?.forceScrollToBottom();
  228. };
  229. const createNewTask = () => {
  230. const existingBlank = taskList.value.find((t) => !t.sessionId);
  231. if (existingBlank) {
  232. switchTask(existingBlank.id);
  233. return;
  234. }
  235. const newTask: Task = {
  236. id: `task-${Date.now()}`,
  237. name: '新任务',
  238. sessionId: '',
  239. messages: getDefaultMessages(),
  240. attachedFiles: [],
  241. };
  242. taskList.value.unshift(newTask);
  243. switchTask(newTask.id);
  244. };
  245. const handleDeleteTask = (task: Task, _index: number) => {
  246. Modal.confirm({
  247. title: '提示',
  248. content: '是否删除该对话?',
  249. okText: '确认',
  250. cancelText: '取消',
  251. wrapClassName: 'delete-task-confirm-modal',
  252. onOk: async () => {
  253. try {
  254. if (task.sessionId) {
  255. await deleteSession(task.sessionId);
  256. }
  257. const realIndex = taskList.value.findIndex((t) => t.id === task.id);
  258. if (realIndex !== -1) {
  259. taskList.value.splice(realIndex, 1);
  260. }
  261. if (currentTaskId.value === task.id) {
  262. if (taskList.value.length > 0) {
  263. switchTask(taskList.value[0].id);
  264. } else {
  265. messages.value = [];
  266. currentTaskId.value = '';
  267. }
  268. }
  269. message.success('删除成功');
  270. } catch {
  271. message.error('删除失败');
  272. }
  273. },
  274. });
  275. };
  276. const getDefaultMessages = (): Message[] => {
  277. return [];
  278. };
  279. const normalizeLineBreaks = (str: string): string => {
  280. if (!str) return '';
  281. return str.replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
  282. };
  283. const transformHistoryToMessages = (data: Array<{ role?: string; type?: string; content: string; created_at?: string }>): Message[] => {
  284. const messages: Message[] = [];
  285. for (const item of data) {
  286. const normalizedContent = normalizeLineBreaks(item.content);
  287. const time = item.created_at ? dayjs(item.created_at).format('HH:mm') : '';
  288. const createdAt = item.created_at || '';
  289. // 新格式:role 字段 (user/assistant)
  290. if (item.role === 'user') {
  291. messages.push({ type: 'user', content: normalizedContent, time, createdAt });
  292. } else if (item.role === 'assistant') {
  293. messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
  294. }
  295. // 兼容旧格式:type 字段
  296. else if (item.type === 'user_message') {
  297. messages.push({ type: 'user', content: normalizedContent, time, createdAt });
  298. } else if (item.type === 'model_thinking') {
  299. const lastAiMsg = messages[messages.length - 1];
  300. if (lastAiMsg && lastAiMsg.type === 'ai') {
  301. lastAiMsg.content += normalizedContent;
  302. lastAiMsg.content = lastAiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
  303. } else {
  304. messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
  305. }
  306. }
  307. }
  308. return messages;
  309. };
  310. const triggerFileUpload = () => {
  311. chatInputRef.value?.fileInputRef?.click();
  312. };
  313. const handleFileUpload = async (event: Event) => {
  314. const target = event.target as HTMLInputElement;
  315. const file = target.files?.[0];
  316. if (!file) return;
  317. if (!isPdfFile(file.name)) {
  318. message.error('仅支持 PDF 文件格式');
  319. target.value = '';
  320. return;
  321. }
  322. try {
  323. const newFile: AttachedFile = {
  324. id: `file-${Date.now()}`,
  325. name: file.name,
  326. size: file.size,
  327. type: file.type,
  328. uploadTime: dayjs().format('YYYY-MM-DD HH:mm'),
  329. originalFile: file,
  330. };
  331. const reader = new FileReader();
  332. reader.onload = (e) => {
  333. newFile.preview = e.target?.result as string;
  334. };
  335. reader.readAsDataURL(file);
  336. pendingFile.value = newFile;
  337. message.success(`文件 "${file.name}" 已添加,点击发送按钮提交`);
  338. } catch (error) {
  339. console.error('文件处理失败:', error);
  340. message.error('文件处理失败,请重试');
  341. }
  342. target.value = '';
  343. };
  344. const removePendingFile = () => {
  345. pendingFile.value = null;
  346. };
  347. const removePreviewFile = () => {
  348. previewFile.value = null;
  349. };
  350. const openFilePreview = (file: AttachedFile) => {
  351. previewFile.value = file;
  352. const msgIndex = messages.value.findIndex((m) => m.attachedFile?.id === file.id);
  353. if (msgIndex !== -1) {
  354. nextTick(() => {
  355. const messageElements = chatMessagesRef.value?.messagesRef?.querySelectorAll('.message-wrapper');
  356. if (messageElements && messageElements[msgIndex]) {
  357. (messageElements[msgIndex] as HTMLElement).scrollIntoView({
  358. behavior: 'smooth',
  359. block: 'center',
  360. });
  361. }
  362. });
  363. }
  364. };
  365. const getCurrentTaskFiles = (): AttachedFile[] => {
  366. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  367. return currentTask?.attachedFiles || [];
  368. };
  369. const currentTask = computed(() => taskList.value.find((t) => t.id === currentTaskId.value));
  370. const currentWordUrl = computed(() => {
  371. return currentTask.value?.wordUrl || '';
  372. });
  373. const currentTaskIndex = computed(() => taskList.value.findIndex((t) => t.id === currentTaskId.value));
  374. const canGoPrev = computed(() => currentTaskIndex.value > 0);
  375. const canGoNext = computed(() => currentTaskIndex.value >= 0 && currentTaskIndex.value < taskList.value.length - 1);
  376. const goToPrev = () => {
  377. if (!canGoPrev.value) return;
  378. const prevTask = taskList.value[currentTaskIndex.value - 1];
  379. if (prevTask) switchTask(prevTask.id);
  380. };
  381. const goToNext = () => {
  382. if (!canGoNext.value) return;
  383. const nextTask = taskList.value[currentTaskIndex.value + 1];
  384. if (nextTask) switchTask(nextTask.id);
  385. };
  386. const downloadWordReport = () => {
  387. const url = currentWordUrl.value;
  388. if (!url) return;
  389. const a = document.createElement('a');
  390. a.href = url;
  391. a.download = '';
  392. a.target = '_blank';
  393. document.body.appendChild(a);
  394. a.click();
  395. document.body.removeChild(a);
  396. };
  397. const downloadWordFile = (url: string) => {
  398. if (!url) return;
  399. const a = document.createElement('a');
  400. a.href = url;
  401. a.download = '';
  402. a.target = '_blank';
  403. document.body.appendChild(a);
  404. a.click();
  405. document.body.removeChild(a);
  406. };
  407. const handleSendMessage = async () => {
  408. const hasText = inputMessage.value.trim();
  409. const hasFile = pendingFile.value;
  410. if (!hasText) {
  411. message.warning('请输入消息内容');
  412. return;
  413. }
  414. if (loading.value) return;
  415. const userInput = inputMessage.value.trim();
  416. const fileToSend = pendingFile.value;
  417. inputMessage.value = '';
  418. pendingFile.value = null;
  419. try {
  420. loading.value = true;
  421. if (hasFile) {
  422. await sendWithAttachment(fileToSend!, userInput);
  423. } else {
  424. await sendTextOnly(userInput);
  425. }
  426. } catch (error) {
  427. console.error('发送消息失败:', error);
  428. message.error('发送失败,请重试');
  429. const lastMsg = messages.value[messages.value.length - 1];
  430. if (lastMsg && lastMsg.type === 'ai' && lastMsg.isLoading) {
  431. messages.value.pop();
  432. }
  433. const errorMsg: Message = {
  434. type: 'ai',
  435. content: '抱歉,请求失败,请稍后重试。',
  436. time: dayjs().format('HH:mm'),
  437. };
  438. messages.value.push(errorMsg);
  439. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  440. if (currentTask) {
  441. currentTask.messages = [...messages.value];
  442. }
  443. } finally {
  444. loading.value = false;
  445. await scrollToBottom();
  446. }
  447. };
  448. const extractAgentName = (toolName: string): string => {
  449. const nameMap: Record<string, string> = {
  450. form_review: '形式审查',
  451. verify_coal_face_ventilation: '采煤工作面需风量计算核验',
  452. verify_tunneling_face_ventilation: '掘进工作面需风量计算核验',
  453. verify_chamber_ventilation: '硐室需风量计算核验',
  454. verify_other_points_ventilation: '其他地点需风量计算核验',
  455. check_data_by_gas_report: '瓦斯数据一致性审查',
  456. check_data_by_face_design: '设计规程一致性审查',
  457. check_data_by_vent_report: '测风报表一致性审查',
  458. check_current_month_plan: '日期有效性审查',
  459. get_key_translate: '计算参数对照',
  460. calculate_ventilation: '需风量计算',
  461. get_ventilation_data: '需风量查询',
  462. recalculate_ventilation: '需风量重计算',
  463. get_plan_required_wind: '需风量提取',
  464. get_model_calculated_wind: '三维模型解算风量提取',
  465. get_sensor_wind: '巷道监测风量提取',
  466. merge_wind_by_location: '地点风量智能合并',
  467. check_wind_compliance: '风量检查',
  468. };
  469. return nameMap[toolName] || nameMap[toolName.toLowerCase()] || '智能分析助手';
  470. };
  471. // 累积所有步骤,保留完整历史
  472. const upsertStep = (steps: any[], step: any) => {
  473. steps.push(step);
  474. };
  475. const handleSSEMessage = (data: any, aiMsgIndex: number) => {
  476. const aiMsg = messages.value[aiMsgIndex];
  477. const normalizedContent = normalizeLineBreaks(data.content || '');
  478. // 初始化 thinkingSteps 数组
  479. if (!aiMsg.thinkingSteps) {
  480. aiMsg.thinkingSteps = [];
  481. }
  482. switch (data.type) {
  483. // token 流式内容 - 直接拼接到消息内容
  484. case 'token':
  485. if (normalizedContent) {
  486. aiMsg.content += normalizedContent;
  487. }
  488. break;
  489. // 系统进度消息 - 仅当 agent 不是 system 时才作为 thinking step
  490. case 'progress': {
  491. if (data.agent && data.agent !== 'system') {
  492. upsertStep(aiMsg.thinkingSteps!, {
  493. type: 'executing',
  494. agent: data.agent,
  495. message: data.message || '',
  496. timestamp: Date.now(),
  497. });
  498. }
  499. break;
  500. }
  501. // agent 开始处理
  502. case 'agent_start': {
  503. upsertStep(aiMsg.thinkingSteps!, {
  504. type: 'agent_start',
  505. agent: data.cn_agent || data.agent || '智能助手',
  506. message: data.message || '开始处理...',
  507. timestamp: Date.now(),
  508. });
  509. break;
  510. }
  511. // agent 任务列表更新(同一 agent 的 todos 更新已有条目,不重复添加)
  512. case 'agent_todos':
  513. case 'updated_todo_list': {
  514. const agentName = data.cn_agent || data.agent || '智能助手';
  515. const existingIdx = aiMsg.thinkingSteps!.findIndex((s) => s.type === 'updated_todo_list' && s.agent === agentName);
  516. if (existingIdx !== -1) {
  517. aiMsg.thinkingSteps![existingIdx].todos = data.todos || [];
  518. aiMsg.thinkingSteps![existingIdx].timestamp = Date.now();
  519. } else {
  520. aiMsg.thinkingSteps!.push({
  521. type: 'updated_todo_list',
  522. agent: agentName,
  523. todos: data.todos || [],
  524. message: data.message || '任务进度已更新',
  525. timestamp: Date.now(),
  526. });
  527. }
  528. break;
  529. }
  530. // agent 执行中(工具调用)
  531. case 'agent_executing':
  532. case 'executing': {
  533. upsertStep(aiMsg.thinkingSteps!, {
  534. type: 'executing',
  535. agent: data.cn_agent || data.agent || '智能助手',
  536. tool: (data.cn_tools || data.tools || []).join('、'),
  537. message: data.message || '',
  538. timestamp: Date.now(),
  539. });
  540. break;
  541. }
  542. // 工具结果
  543. case 'agent_tool_result':
  544. case 'tool_result': {
  545. upsertStep(aiMsg.thinkingSteps!, {
  546. type: 'tool_result',
  547. agent: data.cn_agent || data.agent || '智能助手',
  548. tool: data.cn_tool || data.tool || '',
  549. message: data.message || '执行完成',
  550. timestamp: Date.now(),
  551. });
  552. break;
  553. }
  554. // agent 完成
  555. case 'agent_done': {
  556. upsertStep(aiMsg.thinkingSteps!, {
  557. type: 'agent_done',
  558. agent: data.cn_agent || data.agent || '智能助手',
  559. message: `${(data.message || '审查完成').replace(/(\d+ms)/g, '')}${data.duration_ms ? `(${(data.duration_ms / 1000).toFixed(0)}s)` : ''}${data.progress ? ` [${data.progress}]` : ''}`,
  560. timestamp: Date.now(),
  561. });
  562. if (data.preview) {
  563. aiMsg.content += data.preview;
  564. }
  565. break;
  566. }
  567. // 旧格式兼容
  568. case 'model_thinking':
  569. if (normalizedContent) {
  570. aiMsg.content += normalizedContent;
  571. aiMsg.content = aiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
  572. }
  573. break;
  574. case 'tool_select': {
  575. upsertStep(aiMsg.thinkingSteps!, {
  576. type: 'tool_call',
  577. agent: extractAgentName(normalizedContent),
  578. tool: normalizedContent,
  579. message: `选择工具:${normalizedContent}`,
  580. timestamp: Date.now(),
  581. });
  582. break;
  583. }
  584. case 'error':
  585. upsertStep(aiMsg.thinkingSteps!, {
  586. type: 'tool_result',
  587. agent: '系统',
  588. message: `❌ 执行失败:${normalizedContent}`,
  589. timestamp: Date.now(),
  590. });
  591. aiMsg.isLoading = false;
  592. break;
  593. case 'word_download':
  594. if (normalizedContent) {
  595. aiMsg.wordDownloadUrl = normalizedContent;
  596. const wordTask = taskList.value.find((t) => t.id === currentTaskId.value);
  597. if (wordTask) wordTask.wordUrl = normalizedContent;
  598. }
  599. break;
  600. case 'done':
  601. if (data.session_id) {
  602. aiMsg.sessionId = data.session_id;
  603. }
  604. if (data.duration_ms) {
  605. aiMsg.durationMs = data.duration_ms;
  606. }
  607. aiMsg.isLoading = false;
  608. break;
  609. case 'system':
  610. if (normalizedContent) {
  611. aiMsg.content += normalizedContent.replace(/\n/g, ' \n');
  612. }
  613. break;
  614. }
  615. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  616. if (currentTask) {
  617. currentTask.messages = [...messages.value];
  618. }
  619. };
  620. const parseSSEData = (chunk: string, aiMsgIndex: number) => {
  621. const lines = chunk.split('\n');
  622. for (const lineRaw of lines) {
  623. const line = lineRaw.trim();
  624. if (!line) continue;
  625. if (line.startsWith('data:')) {
  626. try {
  627. const jsonStr = line.substring(5).trim();
  628. const data = JSON.parse(jsonStr);
  629. handleSSEMessage(data, aiMsgIndex);
  630. } catch (error) {
  631. console.error('解析 SSE data 行失败:', error, line);
  632. }
  633. continue;
  634. }
  635. try {
  636. const data = JSON.parse(line);
  637. handleSSEMessage(data, aiMsgIndex);
  638. continue;
  639. } catch {
  640. // not json
  641. }
  642. handleSSEMessage({ type: 'model_thinking', content: line }, aiMsgIndex);
  643. }
  644. };
  645. const sendTextOnly = async (userInput: string) => {
  646. const now = dayjs();
  647. const userMsg: Message = {
  648. type: 'user',
  649. content: userInput,
  650. time: now.format('HH:mm'),
  651. createdAt: now.toISOString(),
  652. };
  653. messages.value.push(userMsg);
  654. await scrollToBottom();
  655. const aiMsgIndex = messages.value.length;
  656. const aiMsg: Message = {
  657. type: 'ai',
  658. content: '',
  659. time: now.format('HH:mm'),
  660. createdAt: now.toISOString(),
  661. isLoading: true,
  662. thinkingSteps: [],
  663. generateStartTime: Date.now(),
  664. };
  665. messages.value.push(aiMsg);
  666. await scrollToBottom();
  667. const result = await unifiedStream(
  668. {
  669. message: userInput,
  670. session_id: getCurrentSessionId() || undefined,
  671. },
  672. (chunk: string) => {
  673. parseSSEData(chunk, aiMsgIndex);
  674. scrollToBottom();
  675. }
  676. );
  677. if (result.session_id) {
  678. const taskBeforeUpdate = taskList.value.find((t) => t.id === currentTaskId.value);
  679. const isNewTask = taskBeforeUpdate && !taskBeforeUpdate.sessionId;
  680. setCurrentSessionId(result.session_id);
  681. if (isNewTask) {
  682. taskBeforeUpdate.name = userInput.substring(0, 20) + (userInput.length > 20 ? '...' : '');
  683. }
  684. }
  685. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  686. if (currentTask) {
  687. currentTask.messages = [...messages.value];
  688. }
  689. };
  690. const sendWithAttachment = async (file: AttachedFile, userInput: string) => {
  691. let currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  692. if (currentTask) {
  693. currentTask.attachedFiles.push(file);
  694. } else {
  695. throw new Error('未找到当前任务');
  696. }
  697. const now = dayjs();
  698. const userMsg: Message = {
  699. type: 'user',
  700. content: userInput || `上传了文件:${file.name}`,
  701. time: now.format('HH:mm'),
  702. createdAt: now.toISOString(),
  703. attachedFile: file,
  704. };
  705. messages.value.push(userMsg);
  706. await scrollToBottom();
  707. const aiMsgIndex = messages.value.length;
  708. const aiMsg: Message = {
  709. type: 'ai',
  710. content: '',
  711. time: now.format('HH:mm'),
  712. createdAt: now.toISOString(),
  713. isLoading: true,
  714. thinkingSteps: [],
  715. generateStartTime: Date.now(),
  716. };
  717. messages.value.push(aiMsg);
  718. await scrollToBottom();
  719. if (!file.originalFile) {
  720. throw new Error('缺少原始文件对象');
  721. }
  722. const result = await reviewPdfStream(
  723. {
  724. file: file.originalFile!,
  725. session_id: getCurrentSessionId() || undefined,
  726. message: userInput || undefined,
  727. },
  728. (chunk: string) => {
  729. parseSSEData(chunk, aiMsgIndex);
  730. scrollToBottom();
  731. }
  732. );
  733. if (result.session_id) {
  734. setCurrentSessionId(result.session_id);
  735. }
  736. currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  737. if (currentTask) {
  738. currentTask.messages = [...messages.value];
  739. }
  740. };
  741. const scrollToBottom = async () => {
  742. await nextTick();
  743. if (!chatMessagesRef.value?.isAtBottom) return;
  744. const el = chatMessagesRef.value?.messagesRef;
  745. if (el) {
  746. el.scrollTop = el.scrollHeight;
  747. }
  748. };
  749. watch(
  750. () => props.visible,
  751. async (newVisible) => {
  752. if (!newVisible) {
  753. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  754. if (currentTask) {
  755. currentTask.messages = [...messages.value];
  756. }
  757. } else {
  758. await fetchSessionList();
  759. // 去除重复的空白任务,只保留一个
  760. taskList.value = taskList.value.filter((task, index) => {
  761. if (task.sessionId) return true;
  762. return index === taskList.value.findIndex((t) => !t.sessionId);
  763. });
  764. // 默认选中第一条历史记录
  765. const firstHistoryTask = taskList.value.find((t) => t.sessionId);
  766. let targetTask: Task;
  767. 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.value.has(targetTask.id)) {
  783. loading.value = true;
  784. try {
  785. const res = await getDetail(targetTask.sessionId);
  786. const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
  787. if (Array.isArray(messages)) {
  788. targetTask.messages = transformHistoryToMessages(messages);
  789. }
  790. historyLoadedTasks.value.add(targetTask.id);
  791. } catch (error) {
  792. console.error('加载历史会话失败:', error);
  793. } finally {
  794. loading.value = false;
  795. }
  796. }
  797. messages.value = targetTask.messages && targetTask.messages.length > 0 ? [...targetTask.messages] : getDefaultMessages();
  798. previewFile.value = null;
  799. pendingFile.value = null;
  800. showFileList.value = false;
  801. inputMessage.value = '';
  802. await nextTick(() => {
  803. chatMessagesRef.value?.forceScrollToBottom();
  804. });
  805. }
  806. }
  807. );
  808. // 拖拽相关(使用普通变量避免 mousemove 触发 Vue 响应式导致卡顿)
  809. let isDragging = false;
  810. let dragStartX = 0;
  811. let dragStartY = 0;
  812. let modalStartLeft = 0;
  813. let modalStartTop = 0;
  814. let draggedEl: HTMLElement | null = null;
  815. const findModalWrap = (target: HTMLElement): HTMLElement | null =>
  816. target.closest<HTMLElement>('.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]');
  817. const queryModalWrap = (): HTMLElement | null => document.querySelector<HTMLElement>('.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]');
  818. const centerModal = () => {
  819. nextTick(() => {
  820. const wrap = queryModalWrap();
  821. if (wrap) {
  822. wrap.style.position = '';
  823. wrap.style.left = '';
  824. wrap.style.top = '';
  825. wrap.style.right = '';
  826. wrap.style.bottom = '';
  827. wrap.style.width = '';
  828. wrap.style.height = '';
  829. wrap.style.margin = '';
  830. wrap.style.transform = '';
  831. }
  832. draggedEl = null;
  833. });
  834. };
  835. const onDragStart = (e: MouseEvent) => {
  836. if (isFullscreen.value) return;
  837. draggedEl = findModalWrap(e.target as HTMLElement) || queryModalWrap();
  838. if (!draggedEl) return;
  839. isDragging = true;
  840. dragStartX = e.clientX;
  841. dragStartY = e.clientY;
  842. const rect = draggedEl.getBoundingClientRect();
  843. modalStartLeft = rect.left;
  844. modalStartTop = rect.top;
  845. draggedEl.style.position = 'fixed';
  846. draggedEl.style.left = `${rect.left}px`;
  847. draggedEl.style.top = `${rect.top}px`;
  848. draggedEl.style.margin = '0';
  849. draggedEl.style.transform = 'none';
  850. document.addEventListener('mousemove', onDragMove);
  851. document.addEventListener('mouseup', onDragEnd);
  852. };
  853. const onDragMove = (e: MouseEvent) => {
  854. if (!isDragging || !draggedEl) return;
  855. draggedEl.style.left = `${modalStartLeft + (e.clientX - dragStartX)}px`;
  856. draggedEl.style.top = `${modalStartTop + (e.clientY - dragStartY)}px`;
  857. };
  858. const onDragEnd = () => {
  859. isDragging = false;
  860. document.removeEventListener('mousemove', onDragMove);
  861. document.removeEventListener('mouseup', onDragEnd);
  862. };
  863. watch(
  864. () => props.visible,
  865. (newVisible) => {
  866. if (newVisible) {
  867. centerModal();
  868. }
  869. }
  870. );
  871. onMounted(async () => {
  872. // fetchSessionList moved to watch(visible) to avoid unnecessary API calls
  873. });
  874. onBeforeUnmount(() => {
  875. document.removeEventListener('mousemove', onDragMove);
  876. document.removeEventListener('mouseup', onDragEnd);
  877. });
  878. </script>
  879. <style scoped lang="less">
  880. .ai-container {
  881. display: flex;
  882. height: 700px;
  883. gap: 10px;
  884. overflow: hidden;
  885. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  886. }
  887. .collapse-btn {
  888. position: absolute;
  889. top: 16px;
  890. left: 24px;
  891. z-index: 10;
  892. width: 32px;
  893. height: 32px;
  894. background: #1a6696;
  895. border-radius: 4px;
  896. display: flex;
  897. align-items: center;
  898. justify-content: center;
  899. cursor: pointer;
  900. transition: background 0.3s;
  901. &:hover {
  902. background: #1f7ab8;
  903. }
  904. .collapse-icon {
  905. width: 16px;
  906. height: 16px;
  907. background-image: var(--img-chat-collapse-icon);
  908. background-repeat: no-repeat;
  909. background-size: 100% 100%;
  910. &.collapsed {
  911. background-image: var(--img-chat-expand-icon);
  912. }
  913. }
  914. }
  915. .task-panel-wrapper {
  916. width: 300px;
  917. flex-shrink: 0;
  918. overflow: hidden;
  919. transition:
  920. width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  921. margin 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  922. opacity 0.25s ease;
  923. &.collapsed {
  924. width: 0;
  925. margin: 0;
  926. opacity: 0;
  927. }
  928. }
  929. .chat-panel {
  930. flex: 1;
  931. display: flex;
  932. position: relative;
  933. flex-direction: column;
  934. background-image: var(--img-chat-panel-bg);
  935. background-repeat: no-repeat;
  936. background-size: 100% 100%;
  937. border-radius: 4px;
  938. overflow: hidden;
  939. min-width: 0;
  940. :deep(.chat-header) {
  941. display: flex;
  942. padding: 12px 20px;
  943. flex-shrink: 0;
  944. align-items: center;
  945. background: rgba(2, 53, 100, 0.3);
  946. .header-icon {
  947. width: 35px;
  948. height: 28px;
  949. background-image: var(--img-chat-header-icon);
  950. background-repeat: no-repeat;
  951. background-size: 100% 100%;
  952. margin-right: 10px;
  953. }
  954. .chat-header-title {
  955. font-size: 16px;
  956. color: #e0e6ed;
  957. overflow: hidden;
  958. text-overflow: ellipsis;
  959. white-space: nowrap;
  960. margin-left: 10px;
  961. }
  962. .nav-btn {
  963. width: 28px;
  964. height: 28px;
  965. display: flex;
  966. align-items: center;
  967. justify-content: center;
  968. cursor: pointer;
  969. border-radius: 4px;
  970. transition: background 0.2s;
  971. &:hover:not(.disabled) {
  972. background: rgba(10, 132, 255, 0.15);
  973. }
  974. &.disabled {
  975. opacity: 0.3;
  976. cursor: not-allowed;
  977. }
  978. .nav-icon {
  979. width: 16px;
  980. height: 16px;
  981. background-repeat: no-repeat;
  982. background-size: 100% 100%;
  983. }
  984. .prev-icon {
  985. background-image: var(--img-chat-prev-icon);
  986. }
  987. .next-icon {
  988. background-image: var(--img-chat-next-icon);
  989. }
  990. .add-icon {
  991. background-image: var(--img-chat-options-add-icon);
  992. }
  993. }
  994. .upload-bg {
  995. cursor: pointer;
  996. width: 36px;
  997. height: 36px;
  998. background-image: var(--img-chat-close-bg);
  999. background-repeat: no-repeat;
  1000. background-size: 100% 100%;
  1001. display: flex;
  1002. align-items: center;
  1003. justify-content: center;
  1004. margin-left: auto;
  1005. position: relative;
  1006. }
  1007. .download-icon {
  1008. width: 20px;
  1009. height: 20px;
  1010. background-image: var(--img-chat-download-icon);
  1011. background-repeat: no-repeat;
  1012. background-size: 100% 100%;
  1013. }
  1014. }
  1015. }
  1016. .right-panel {
  1017. width: calc(50% - 140px);
  1018. height: 100%;
  1019. display: flex;
  1020. flex-direction: column;
  1021. background-image: var(--img-chat-panel-bg);
  1022. background-repeat: no-repeat;
  1023. background-size: 100% 100%;
  1024. border-radius: 4px;
  1025. flex-shrink: 0;
  1026. }
  1027. .draggable-title {
  1028. cursor: grab;
  1029. user-select: none;
  1030. &:active {
  1031. cursor: grabbing;
  1032. }
  1033. &.no-drag {
  1034. cursor: default;
  1035. &:active {
  1036. cursor: default;
  1037. }
  1038. }
  1039. }
  1040. </style>
  1041. <style lang="less">
  1042. .ai-assistant-modal {
  1043. .zxm-modal-content {
  1044. background-image: var(--img-chat-modal-bg);
  1045. background-repeat: no-repeat;
  1046. background-size: 100% 100%;
  1047. background-color: unset !important;
  1048. box-shadow: none;
  1049. padding: 0;
  1050. border: none !important;
  1051. }
  1052. .zxm-modal-close {
  1053. background-image: var(--img-chat-close-bg);
  1054. background-repeat: no-repeat;
  1055. background-size: 100% 100%;
  1056. top: 0px;
  1057. right: 0px;
  1058. width: 38px;
  1059. height: 38px;
  1060. .zxm-modal-close-x {
  1061. width: 38px;
  1062. height: 38px;
  1063. }
  1064. svg {
  1065. width: 26px;
  1066. height: 26px;
  1067. margin-bottom: 4px;
  1068. }
  1069. }
  1070. .maximize-btn {
  1071. position: absolute;
  1072. top: 0px;
  1073. right: 56px;
  1074. width: 38px;
  1075. height: 38px;
  1076. background-image: var(--img-chat-close-bg);
  1077. background-repeat: no-repeat;
  1078. background-size: 100% 100%;
  1079. display: flex;
  1080. align-items: center;
  1081. justify-content: center;
  1082. cursor: pointer;
  1083. z-index: 10;
  1084. .maximize-icon {
  1085. width: 22px;
  1086. height: 22px;
  1087. background-image: var(--img-chat-maximize-icon);
  1088. background-repeat: no-repeat;
  1089. background-size: 100% 100%;
  1090. &.minimized {
  1091. background-image: var(--img-chat-minimize-icon);
  1092. }
  1093. }
  1094. }
  1095. &.fullscreen {
  1096. top: 0 !important;
  1097. padding: 0 !important;
  1098. max-width: 100vw !important;
  1099. margin: 0 !important;
  1100. background-color: #09172c;
  1101. .zxm-modal-wrap,
  1102. .ant-modal-wrap {
  1103. padding: 0 !important;
  1104. margin: 0 !important;
  1105. top: 0 !important;
  1106. }
  1107. .zxm-modal-content {
  1108. width: 100vw !important;
  1109. height: 100vh !important;
  1110. max-width: 100vw !important;
  1111. margin: 0;
  1112. border-radius: 0;
  1113. overflow: hidden;
  1114. background-image: var(--img-chat-fs-modal-bg);
  1115. }
  1116. .zxm-modal-header {
  1117. height: 90px;
  1118. background-image: var(--img-chat-fs-header-bg) !important;
  1119. background-repeat: no-repeat;
  1120. background-size: 100% 100%;
  1121. margin-bottom: 16px;
  1122. }
  1123. .no-drag {
  1124. cursor: default;
  1125. font-size: 32px !important;
  1126. }
  1127. .zxm-modal-title {
  1128. height: 90px;
  1129. }
  1130. .zxm-modal-body {
  1131. height: calc(100vh - 120px);
  1132. overflow: hidden;
  1133. padding: 0 24px 12px;
  1134. }
  1135. .ai-container {
  1136. height: 100%;
  1137. overflow: hidden;
  1138. }
  1139. .chat-panel {
  1140. background-image: var(--img-chat-fs-panel-bg) !important;
  1141. }
  1142. .chat-header {
  1143. padding: 0 32px;
  1144. height: 50px;
  1145. }
  1146. .collapse-btn {
  1147. top: 60px;
  1148. background-image: var(--img-chat-fs-collapse-btn-bg) !important;
  1149. background-repeat: no-repeat;
  1150. background-size: 100% 100%;
  1151. }
  1152. .task-list-panel {
  1153. padding: 20px;
  1154. background-image: var(--img-chat-fs-task-panel-bg) !important;
  1155. background-repeat: no-repeat !important;
  1156. background-size: 100% 100% !important;
  1157. }
  1158. .options-area {
  1159. gap: 12px;
  1160. }
  1161. .options-area-item {
  1162. background-image: var(--img-chat-fs-options-item-bg) !important;
  1163. background-repeat: no-repeat !important;
  1164. background-size: 100% 100% !important;
  1165. &.active,
  1166. &:active {
  1167. background-image: var(--img-chat-fs-options-item-active-bg) !important;
  1168. }
  1169. .options-img {
  1170. background-image: none !important;
  1171. }
  1172. }
  1173. .chat-header-title {
  1174. height: 40px;
  1175. line-height: 40px;
  1176. position: relative;
  1177. background-image: var(--img-chat-fs-header-title-bg) !important;
  1178. background-repeat: no-repeat !important;
  1179. background-size: auto 100% !important;
  1180. padding: 0 28px;
  1181. &::before {
  1182. content: '';
  1183. position: absolute;
  1184. left: 0;
  1185. top: 50%;
  1186. transform: translateY(-50%);
  1187. width: 40px;
  1188. height: 40px;
  1189. background-image: var(--img-chat-fs-header-title-icon);
  1190. background-repeat: no-repeat;
  1191. background-size: 100% 100%;
  1192. }
  1193. }
  1194. .chat-input-area {
  1195. margin: 24px;
  1196. }
  1197. }
  1198. .zxm-modal-header {
  1199. background-color: unset !important;
  1200. background: unset;
  1201. border: none !important;
  1202. padding: 8px;
  1203. backdrop-filter: unset;
  1204. display: flex;
  1205. justify-content: center;
  1206. }
  1207. .zxm-modal-title {
  1208. width: 100%;
  1209. height: 60px;
  1210. display: flex;
  1211. align-items: center;
  1212. justify-content: center;
  1213. .draggable-title {
  1214. color: #fff;
  1215. font-family: var(--vet-font-family);
  1216. letter-spacing: 3px;
  1217. font-size: 24px;
  1218. font-weight: 100;
  1219. line-height: 1.4;
  1220. }
  1221. }
  1222. .zxm-modal-body {
  1223. padding: 12px 24px;
  1224. }
  1225. }
  1226. .delete-task-confirm-modal {
  1227. .zxm-modal-confirm-title,
  1228. .zxm-modal-confirm-content {
  1229. color: #fff;
  1230. }
  1231. }
  1232. </style>