AiAssistantModal.vue 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914
  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. @pin-task="handlePinTask"
  31. />
  32. </div>
  33. <!-- 中间聊天区域 -->
  34. <div class="chat-panel">
  35. <!-- 顶部任务选择器 -->
  36. <div class="chat-header">
  37. <template v-if="panelCollapsed">
  38. <div :class="['nav-btn', { disabled: !canGoPrev }]" @click="goToPrev">
  39. <div class="nav-icon prev-icon"></div>
  40. </div>
  41. <div :class="['nav-btn', { disabled: !canGoNext }]" @click="goToNext">
  42. <div class="nav-icon next-icon"></div>
  43. </div>
  44. <div class="nav-btn" @click="createNewTask">
  45. <div class="nav-icon add-icon"></div>
  46. </div>
  47. </template>
  48. <div v-if="currentTask?.sessionId" class="chat-header-title-wrapper">
  49. <template v-if="editingTitle">
  50. <input v-model="editTitleValue" class="title-edit-input" @keyup.enter="saveTitle" @keyup.escape="cancelEditTitle" @blur="saveTitle" />
  51. </template>
  52. <template v-else>
  53. <div class="title-edit-btn" @click="startEditTitle" title="编辑标题"></div>
  54. <span class="chat-header-title">{{ currentTask.name }}</span>
  55. </template>
  56. </div>
  57. <div v-if="currentWordUrl" class="upload-bg" @click="downloadWordReport">
  58. <div class="download-icon"></div>
  59. </div>
  60. </div>
  61. <!-- 附件列表面板 -->
  62. <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="openFilePreview" />
  63. <!-- 消息列表 -->
  64. <ChatMessages
  65. ref="chatMessagesRef"
  66. :messages="messages"
  67. @file-preview="openFilePreview"
  68. @download-word="downloadWordFile"
  69. @approve="handleApprove"
  70. @reject="handleReject"
  71. />
  72. <!-- 下载确认条 -->
  73. <div v-if="downloadConfirm" class="download-confirm-bar">
  74. <span class="download-confirm-text">是否下载 {{ downloadConfirm.filename }} 到本地?</span>
  75. <div class="download-confirm-actions">
  76. <button class="download-confirm-btn download-btn" @click="doDownload">下载</button>
  77. <button class="download-confirm-btn cancel-btn" @click="downloadConfirm = null">取消</button>
  78. </div>
  79. </div>
  80. <!-- 输入区域 -->
  81. <ChatInputArea
  82. ref="chatInputRef"
  83. v-model="inputMessage"
  84. :pendingFile="pendingFile"
  85. :loading="loading || !!pendingApprovalSessionId"
  86. :contextUsed="contextUsed"
  87. :contextMax="contextMax"
  88. :contextPercent="contextPercent"
  89. :contextBreakdown="contextBreakdown"
  90. :initialThinkLevel="thinkLevel"
  91. :initialEditMode="editMode"
  92. :taskList="taskList"
  93. @send="handleSendMessage"
  94. @file-upload="triggerFileUpload"
  95. @file-change="handleFileUpload"
  96. @remove-pending-file="removePendingFile"
  97. @think-level-change="handleThinkLevelChange"
  98. @edit-mode-change="handleEditModeChange"
  99. @insert-session="handleInsertSession"
  100. />
  101. </div>
  102. <!-- 右侧文件预览面板 -->
  103. <div v-if="previewFile" class="right-panel">
  104. <FilePreviewPanel :file="previewFile" @close="removePreviewFile" />
  105. </div>
  106. </div>
  107. </a-modal>
  108. </template>
  109. <script setup lang="ts">
  110. import { ref, nextTick, watch, computed, onMounted, onBeforeUnmount } from 'vue';
  111. import dayjs from 'dayjs';
  112. import {
  113. unifiedStream,
  114. reviewPdfStream,
  115. chatResumeStream,
  116. getHistoryList,
  117. getDetail,
  118. deleteSession,
  119. pinSession,
  120. editSessionTitle,
  121. getContext,
  122. switchThinking,
  123. getModelMsg,
  124. getSessionMode,
  125. putSessionMode,
  126. } from '../api';
  127. import { message, Modal } from 'ant-design-vue';
  128. import TaskListPanel from './chatModal/TaskListPanel.vue';
  129. import FileListPanel from './chatModal/FileListPanel.vue';
  130. import ChatMessages from './chatModal/ChatMessages.vue';
  131. import ChatInputArea from './chatModal/ChatInputArea.vue';
  132. import FilePreviewPanel from './chatModal/FilePreviewPanel.vue';
  133. import type { AttachedFile, Message, Task } from './chatModal/types';
  134. interface Props {
  135. visible: boolean;
  136. }
  137. interface Emits {
  138. (e: 'close'): void;
  139. }
  140. const props = defineProps<Props>();
  141. const emit = defineEmits<Emits>();
  142. const taskList = ref<Task[]>([]);
  143. // 从接口获取会话列表
  144. const fetchSessionList = async () => {
  145. try {
  146. const res = await getHistoryList({});
  147. const sessions = res?.sessions || res?.data || [];
  148. if (Array.isArray(sessions)) {
  149. const apiTasks: Task[] = sessions.map((item: { session_id: string; title: string; updated_at: string; sort_order?: number }) => ({
  150. id: item.session_id,
  151. name: item.title || '未命名会话',
  152. sessionId: item.session_id,
  153. messages: [],
  154. attachedFiles: [],
  155. updatedAt: item.updated_at,
  156. sortOrder: item.sort_order,
  157. }));
  158. const mergedTasks = apiTasks.map((apiTask) => {
  159. const existing = taskList.value.find((t) => t.id === apiTask.id);
  160. if (existing) {
  161. return {
  162. ...apiTask,
  163. messages: existing.messages,
  164. attachedFiles: existing.attachedFiles,
  165. wordUrl: existing.wordUrl,
  166. };
  167. }
  168. return apiTask;
  169. });
  170. const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTasks.find((a) => a.id === t.id));
  171. taskList.value = [...localOnlyTasks, ...mergedTasks];
  172. return mergedTasks;
  173. }
  174. } catch (error) {
  175. console.error('获取会话列表失败:', error);
  176. }
  177. return [];
  178. };
  179. const currentTaskId = ref('');
  180. const chatInputRef = ref<InstanceType<typeof ChatInputArea>>();
  181. const chatMessagesRef = ref<InstanceType<typeof ChatMessages>>();
  182. const previewFile = ref<AttachedFile | null>(null);
  183. const showFileList = ref(false);
  184. const pendingFile = ref<AttachedFile | null>(null);
  185. const messages = ref<Message[]>([]);
  186. const contextUsed = ref(0);
  187. const contextMax = ref(1000000);
  188. const contextPercent = ref(0);
  189. const thinkLevel = ref('off');
  190. const editMode = ref('full');
  191. const pendingApprovalSessionId = ref('');
  192. const pendingApprovalThreadId = ref('');
  193. const downloadConfirm = ref<{ url: string; filename: string } | null>(null);
  194. const contextBreakdown = ref({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 });
  195. const panelCollapsed = ref(false);
  196. const togglePanel = () => {
  197. panelCollapsed.value = !panelCollapsed.value;
  198. };
  199. const editingTitle = ref(false);
  200. const editTitleValue = ref('');
  201. const startEditTitle = () => {
  202. if (!currentTask.value?.sessionId) return;
  203. editTitleValue.value = currentTask.value.name;
  204. editingTitle.value = true;
  205. nextTick(() => {
  206. const input = document.querySelector('.title-edit-input') as HTMLInputElement;
  207. input?.focus();
  208. input?.select();
  209. });
  210. };
  211. const saveTitle = async () => {
  212. const newTitle = editTitleValue.value.trim();
  213. if (!currentTask.value?.sessionId || !newTitle || newTitle === currentTask.value.name) {
  214. editingTitle.value = false;
  215. return;
  216. }
  217. try {
  218. await editSessionTitle(currentTask.value.sessionId, newTitle);
  219. currentTask.value.name = newTitle;
  220. message.success('标题修改成功');
  221. } catch {
  222. message.error('标题修改失败');
  223. }
  224. editingTitle.value = false;
  225. };
  226. const cancelEditTitle = () => {
  227. editingTitle.value = false;
  228. };
  229. const isFullscreen = ref(false);
  230. const toggleFullscreen = () => {
  231. isFullscreen.value = !isFullscreen.value;
  232. nextTick(() => {
  233. const wraps = document.querySelectorAll<HTMLElement>('.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]');
  234. wraps.forEach((wrap) => {
  235. if (isFullscreen.value) {
  236. wrap.style.position = '';
  237. wrap.style.left = '';
  238. wrap.style.top = '0';
  239. wrap.style.right = '';
  240. wrap.style.bottom = '';
  241. wrap.style.width = '';
  242. wrap.style.height = '';
  243. wrap.style.margin = '0';
  244. wrap.style.padding = '0';
  245. wrap.style.transform = '';
  246. } else {
  247. wrap.style.position = '';
  248. wrap.style.left = '';
  249. wrap.style.top = '';
  250. wrap.style.right = '';
  251. wrap.style.bottom = '';
  252. wrap.style.width = '';
  253. wrap.style.height = '';
  254. wrap.style.margin = '';
  255. wrap.style.padding = '';
  256. wrap.style.transform = '';
  257. }
  258. });
  259. draggedEl = null;
  260. if (!isFullscreen.value) {
  261. centerModal();
  262. }
  263. });
  264. };
  265. const inputMessage = ref('');
  266. const loading = ref(false);
  267. const getCurrentSessionId = (): string => {
  268. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  269. return currentTask?.sessionId || '';
  270. };
  271. const historyLoadedTasks = ref<Set<string>>(new Set());
  272. const setCurrentSessionId = (sessionId: string) => {
  273. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  274. if (currentTask && currentTask.id !== sessionId) {
  275. taskList.value = taskList.value.filter((t) => t.id !== sessionId);
  276. currentTask.id = sessionId;
  277. currentTask.sessionId = sessionId;
  278. currentTaskId.value = sessionId;
  279. historyLoadedTasks.value.add(sessionId);
  280. }
  281. };
  282. const switchTask = async (taskId: string) => {
  283. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  284. if (currentTask) {
  285. currentTask.messages = [...messages.value];
  286. }
  287. currentTaskId.value = taskId;
  288. const newTask = taskList.value.find((t) => t.id === taskId);
  289. if (!newTask) return;
  290. inputMessage.value = '';
  291. pendingFile.value = null;
  292. previewFile.value = null;
  293. showFileList.value = false;
  294. if (newTask.sessionId && !historyLoadedTasks.value.has(taskId)) {
  295. loading.value = true;
  296. try {
  297. const res = await getDetail(newTask.sessionId);
  298. const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
  299. if (Array.isArray(messages)) {
  300. newTask.messages = transformHistoryToMessages(messages);
  301. }
  302. historyLoadedTasks.value.add(taskId);
  303. } catch (error) {
  304. console.error('加载历史会话失败:', error);
  305. } finally {
  306. loading.value = false;
  307. }
  308. }
  309. if (newTask.sessionId) {
  310. fetchContext(newTask.sessionId);
  311. fetchSessionMode(newTask.sessionId);
  312. } else {
  313. contextUsed.value = 0;
  314. contextPercent.value = 0;
  315. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  316. editMode.value = 'full';
  317. }
  318. messages.value = newTask.messages && newTask.messages.length > 0 ? [...newTask.messages] : getDefaultMessages();
  319. await nextTick();
  320. chatMessagesRef.value?.forceScrollToBottom();
  321. };
  322. const createNewTask = () => {
  323. const existingBlank = taskList.value.find((t) => !t.sessionId);
  324. if (existingBlank) {
  325. switchTask(existingBlank.id);
  326. return;
  327. }
  328. const newTask: Task = {
  329. id: `task-${Date.now()}`,
  330. name: '新任务',
  331. sessionId: '',
  332. messages: getDefaultMessages(),
  333. attachedFiles: [],
  334. };
  335. contextUsed.value = 0;
  336. contextPercent.value = 0;
  337. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  338. editMode.value = 'full';
  339. taskList.value.unshift(newTask);
  340. switchTask(newTask.id);
  341. };
  342. const handlePinTask = async (task: Task) => {
  343. if (!task.sessionId) {
  344. message.warning('请先发送消息后再置顶');
  345. return;
  346. }
  347. try {
  348. await pinSession(task.sessionId);
  349. await fetchSessionList();
  350. } catch {
  351. message.error('置顶操作失败');
  352. }
  353. };
  354. const handleThinkLevelChange = async (levelKey: string) => {
  355. try {
  356. await switchThinking(levelKey);
  357. } catch {
  358. message.error('切换思考级别失败');
  359. }
  360. };
  361. const handleInsertSession = (_taskId: string, _taskName: string) => {
  362. // 会话引用已由 ChatInputArea 内部以 chip 形式管理
  363. };
  364. const handleEditModeChange = async (modeKey: string) => {
  365. editMode.value = modeKey;
  366. const sessionId = getCurrentSessionId();
  367. if (!sessionId) return;
  368. try {
  369. await putSessionMode(sessionId, modeKey);
  370. } catch {
  371. message.error('切换模式失败');
  372. }
  373. };
  374. const handleApprove = async () => {
  375. const sessionId = pendingApprovalSessionId.value || getCurrentSessionId();
  376. if (!sessionId) return;
  377. const approveTask = taskList.value.find((t) => t.sessionId === sessionId);
  378. if (!approveTask) return;
  379. // 清除中断消息的 pendingApproval 状态,获取中断前累计时长和思考内容
  380. const taskMessages = approveTask.messages;
  381. const lastMsg = taskMessages[taskMessages.length - 1];
  382. const prevElapsedMs = lastMsg?.pendingApprovalData?.elapsedMs || 0;
  383. const prevThinkingContent = lastMsg?.pendingApprovalData?.prevThinkingContent || lastMsg?.thinkingContent || '';
  384. if (lastMsg && lastMsg.isPendingApproval) {
  385. lastMsg.isPendingApproval = false;
  386. }
  387. const now = dayjs();
  388. const aiMsgIndex = taskMessages.length;
  389. const aiMsg: Message = {
  390. type: 'ai',
  391. content: '',
  392. thinkingContent: prevThinkingContent,
  393. time: now.format('HH:mm'),
  394. createdAt: now.toISOString(),
  395. isLoading: true,
  396. thinkingSteps: [],
  397. generateStartTime: Date.now(),
  398. baseDurationMs: prevElapsedMs,
  399. };
  400. taskMessages.push(aiMsg);
  401. approveTask.messages = [...taskMessages];
  402. if (currentTaskId.value === approveTask.id) {
  403. messages.value = approveTask.messages;
  404. await scrollToBottom();
  405. }
  406. loading.value = true;
  407. approveTask.isStreaming = true;
  408. try {
  409. const result = await chatResumeStream(
  410. {
  411. session_id: sessionId,
  412. thread_id: pendingApprovalThreadId.value || undefined,
  413. action: 'approve',
  414. },
  415. (chunk: string) => {
  416. parseSSEData(chunk, aiMsgIndex, approveTask.id);
  417. if (currentTaskId.value === approveTask.id) scrollToBottom();
  418. }
  419. );
  420. if (result.session_id) {
  421. setCurrentSessionId(result.session_id);
  422. }
  423. } catch (error) {
  424. console.error('审批继续执行失败:', error);
  425. message.error('审批失败,请重试');
  426. if (aiMsg.isLoading) {
  427. aiMsg.isLoading = false;
  428. aiMsg.content = '抱歉,审批请求失败,请稍后重试。';
  429. }
  430. } finally {
  431. loading.value = false;
  432. approveTask.isStreaming = false;
  433. pendingApprovalSessionId.value = '';
  434. pendingApprovalThreadId.value = '';
  435. if (currentTaskId.value === approveTask.id) {
  436. await scrollToBottom();
  437. }
  438. }
  439. };
  440. const handleReject = async () => {
  441. const sessionId = pendingApprovalSessionId.value || getCurrentSessionId();
  442. if (!sessionId) return;
  443. const rejectTask = taskList.value.find((t) => t.sessionId === sessionId);
  444. if (!rejectTask) return;
  445. // 清除中断消息的 pendingApproval 状态
  446. const taskMessages = rejectTask.messages;
  447. const lastMsg = taskMessages[taskMessages.length - 1];
  448. if (lastMsg && lastMsg.isPendingApproval) {
  449. lastMsg.isPendingApproval = false;
  450. }
  451. try {
  452. await chatResumeStream(
  453. {
  454. session_id: sessionId,
  455. thread_id: pendingApprovalThreadId.value || undefined,
  456. action: 'reject',
  457. },
  458. () => {}
  459. );
  460. } catch (error) {
  461. console.error('拒绝操作失败:', error);
  462. }
  463. const now = dayjs();
  464. const rejectMsg: Message = {
  465. type: 'ai',
  466. content: '已拒绝执行计划。',
  467. time: now.format('HH:mm'),
  468. createdAt: now.toISOString(),
  469. };
  470. taskMessages.push(rejectMsg);
  471. rejectTask.messages = [...taskMessages];
  472. pendingApprovalSessionId.value = '';
  473. pendingApprovalThreadId.value = '';
  474. if (currentTaskId.value === rejectTask.id) {
  475. messages.value = rejectTask.messages;
  476. await scrollToBottom();
  477. }
  478. };
  479. const handleDeleteTask = (task: Task, _index: number) => {
  480. Modal.confirm({
  481. title: '提示',
  482. content: '是否删除该对话?',
  483. okText: '确认',
  484. cancelText: '取消',
  485. wrapClassName: 'delete-task-confirm-modal',
  486. onOk: async () => {
  487. try {
  488. if (task.sessionId) {
  489. await deleteSession(task.sessionId);
  490. }
  491. const realIndex = taskList.value.findIndex((t) => t.id === task.id);
  492. if (realIndex !== -1) {
  493. taskList.value.splice(realIndex, 1);
  494. }
  495. if (currentTaskId.value === task.id) {
  496. if (taskList.value.length > 0) {
  497. switchTask(taskList.value[0].id);
  498. } else {
  499. messages.value = [];
  500. currentTaskId.value = '';
  501. }
  502. }
  503. message.success('删除成功');
  504. } catch {
  505. message.error('删除失败');
  506. }
  507. },
  508. });
  509. };
  510. const getDefaultMessages = (): Message[] => {
  511. return [];
  512. };
  513. const normalizeLineBreaks = (str: string): string => {
  514. if (!str) return '';
  515. return str.replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
  516. };
  517. const transformHistoryToMessages = (data: Array<{ role?: string; type?: string; content: string; created_at?: string }>): Message[] => {
  518. const messages: Message[] = [];
  519. for (const item of data) {
  520. const normalizedContent = normalizeLineBreaks(item.content);
  521. const time = item.created_at ? dayjs(item.created_at).format('HH:mm') : '';
  522. const createdAt = item.created_at || '';
  523. // 新格式:role 字段 (user/assistant)
  524. if (item.role === 'user') {
  525. messages.push({ type: 'user', content: normalizedContent, time, createdAt });
  526. } else if (item.role === 'assistant') {
  527. messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
  528. }
  529. // 兼容旧格式:type 字段
  530. else if (item.type === 'user_message') {
  531. messages.push({ type: 'user', content: normalizedContent, time, createdAt });
  532. } else if (item.type === 'model_thinking') {
  533. const lastAiMsg = messages[messages.length - 1];
  534. if (lastAiMsg && lastAiMsg.type === 'ai') {
  535. lastAiMsg.content += normalizedContent;
  536. lastAiMsg.content = lastAiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
  537. } else {
  538. messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
  539. }
  540. }
  541. }
  542. return messages;
  543. };
  544. const triggerFileUpload = () => {
  545. chatInputRef.value?.fileInputRef?.click();
  546. };
  547. const handleFileUpload = async (event: Event) => {
  548. const target = event.target as HTMLInputElement;
  549. const file = target.files?.[0];
  550. if (!file) return;
  551. try {
  552. const newFile: AttachedFile = {
  553. id: `file-${Date.now()}`,
  554. name: file.name,
  555. size: file.size,
  556. type: file.type,
  557. uploadTime: dayjs().format('YYYY-MM-DD HH:mm'),
  558. originalFile: file,
  559. };
  560. const reader = new FileReader();
  561. reader.onload = (e) => {
  562. newFile.preview = e.target?.result as string;
  563. };
  564. reader.readAsDataURL(file);
  565. pendingFile.value = newFile;
  566. message.success(`文件 "${file.name}" 已添加,点击发送按钮提交`);
  567. } catch (error) {
  568. console.error('文件处理失败:', error);
  569. message.error('文件处理失败,请重试');
  570. }
  571. target.value = '';
  572. };
  573. const removePendingFile = () => {
  574. pendingFile.value = null;
  575. };
  576. const removePreviewFile = () => {
  577. previewFile.value = null;
  578. };
  579. const openFilePreview = (file: AttachedFile) => {
  580. previewFile.value = file;
  581. const msgIndex = messages.value.findIndex((m) => m.attachedFile?.id === file.id);
  582. if (msgIndex !== -1) {
  583. nextTick(() => {
  584. const messageElements = chatMessagesRef.value?.messagesRef?.querySelectorAll('.message-wrapper');
  585. if (messageElements && messageElements[msgIndex]) {
  586. (messageElements[msgIndex] as HTMLElement).scrollIntoView({
  587. behavior: 'smooth',
  588. block: 'center',
  589. });
  590. }
  591. });
  592. }
  593. };
  594. const getCurrentTaskFiles = (): AttachedFile[] => {
  595. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  596. return currentTask?.attachedFiles || [];
  597. };
  598. const currentTask = computed(() => taskList.value.find((t) => t.id === currentTaskId.value));
  599. const currentWordUrl = computed(() => {
  600. return currentTask.value?.wordUrl || '';
  601. });
  602. const currentTaskIndex = computed(() => taskList.value.findIndex((t) => t.id === currentTaskId.value));
  603. const canGoPrev = computed(() => currentTaskIndex.value > 0);
  604. const canGoNext = computed(() => currentTaskIndex.value >= 0 && currentTaskIndex.value < taskList.value.length - 1);
  605. const goToPrev = () => {
  606. if (!canGoPrev.value) return;
  607. const prevTask = taskList.value[currentTaskIndex.value - 1];
  608. if (prevTask) switchTask(prevTask.id);
  609. };
  610. const goToNext = () => {
  611. if (!canGoNext.value) return;
  612. const nextTask = taskList.value[currentTaskIndex.value + 1];
  613. if (nextTask) switchTask(nextTask.id);
  614. };
  615. const downloadWordReport = () => {
  616. const url = currentWordUrl.value;
  617. if (!url) return;
  618. const a = document.createElement('a');
  619. a.href = url;
  620. a.download = '';
  621. a.target = '_blank';
  622. document.body.appendChild(a);
  623. a.click();
  624. document.body.removeChild(a);
  625. };
  626. const doDownload = () => {
  627. if (!downloadConfirm.value) return;
  628. const a = document.createElement('a');
  629. a.href = downloadConfirm.value.url;
  630. a.download = downloadConfirm.value.filename;
  631. document.body.appendChild(a);
  632. a.click();
  633. document.body.removeChild(a);
  634. downloadConfirm.value = null;
  635. };
  636. const downloadWordFile = (url: string) => {
  637. if (!url) return;
  638. const a = document.createElement('a');
  639. a.href = url;
  640. a.download = '';
  641. a.target = '_blank';
  642. document.body.appendChild(a);
  643. a.click();
  644. document.body.removeChild(a);
  645. };
  646. const handleSendMessage = async () => {
  647. const hasText = inputMessage.value.trim();
  648. const hasFile = pendingFile.value;
  649. if (!hasText) {
  650. message.warning('请输入消息内容');
  651. return;
  652. }
  653. if (loading.value) return;
  654. if (pendingApprovalSessionId.value) {
  655. message.warning('请先审批执行计划');
  656. return;
  657. }
  658. const userInput = inputMessage.value.trim();
  659. const fileToSend = pendingFile.value;
  660. inputMessage.value = '';
  661. pendingFile.value = null;
  662. try {
  663. loading.value = true;
  664. await nextTick();
  665. chatMessagesRef.value?.forceScrollToBottom();
  666. if (hasFile) {
  667. await sendWithAttachment(fileToSend!, userInput);
  668. } else {
  669. await sendTextOnly(userInput);
  670. }
  671. } catch (error) {
  672. console.error('发送消息失败:', error);
  673. message.error('发送失败,请重试');
  674. const lastMsg = messages.value[messages.value.length - 1];
  675. if (lastMsg && lastMsg.type === 'ai' && lastMsg.isLoading) {
  676. messages.value.pop();
  677. }
  678. const errorMsg: Message = {
  679. type: 'ai',
  680. content: '抱歉,请求失败,请稍后重试。',
  681. time: dayjs().format('HH:mm'),
  682. };
  683. messages.value.push(errorMsg);
  684. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  685. if (currentTask) {
  686. currentTask.messages = [...messages.value];
  687. }
  688. } finally {
  689. loading.value = false;
  690. await scrollToBottom();
  691. }
  692. };
  693. const extractAgentName = (toolName: string): string => {
  694. const nameMap: Record<string, string> = {
  695. form_review: '形式审查',
  696. verify_coal_face_ventilation: '采煤工作面需风量计算核验',
  697. verify_tunneling_face_ventilation: '掘进工作面需风量计算核验',
  698. verify_chamber_ventilation: '硐室需风量计算核验',
  699. verify_other_points_ventilation: '其他地点需风量计算核验',
  700. check_data_by_gas_report: '瓦斯数据一致性审查',
  701. check_data_by_face_design: '设计规程一致性审查',
  702. check_data_by_vent_report: '测风报表一致性审查',
  703. check_current_month_plan: '日期有效性审查',
  704. get_key_translate: '计算参数对照',
  705. calculate_ventilation: '需风量计算',
  706. get_ventilation_data: '需风量查询',
  707. recalculate_ventilation: '需风量重计算',
  708. get_plan_required_wind: '需风量提取',
  709. get_model_calculated_wind: '三维模型解算风量提取',
  710. get_sensor_wind: '巷道监测风量提取',
  711. merge_wind_by_location: '地点风量智能合并',
  712. check_wind_compliance: '风量检查',
  713. };
  714. return nameMap[toolName] || nameMap[toolName.toLowerCase()] || '智能分析助手';
  715. };
  716. // 累积所有步骤,保留完整历史
  717. const upsertStep = (steps: any[], step: any) => {
  718. steps.push(step);
  719. };
  720. const fetchModelMsg = async () => {
  721. try {
  722. const res = await getModelMsg();
  723. if (res?.thinking_level) {
  724. thinkLevel.value = res.thinking_level;
  725. }
  726. } catch (e) {
  727. console.error('获取模型信息失败:', e);
  728. }
  729. };
  730. const fetchSessionMode = async (sessionId: string) => {
  731. try {
  732. const res = await getSessionMode(sessionId);
  733. if (res?.mode) {
  734. editMode.value = res.mode;
  735. }
  736. } catch (e) {
  737. console.error('获取会话模式失败:', e);
  738. }
  739. };
  740. const fetchContext = async (sessionId: string) => {
  741. try {
  742. const res = await getContext(sessionId);
  743. if (res?.current_usage != null) {
  744. contextUsed.value = res.current_usage;
  745. }
  746. if (res?.total_limit != null) {
  747. contextMax.value = res.total_limit;
  748. }
  749. if (res?.usage_pct != null) {
  750. contextPercent.value = res.usage_pct;
  751. }
  752. if (res?.breakdown) {
  753. contextBreakdown.value = res.breakdown;
  754. }
  755. } catch (e) {
  756. console.error('获取上下文容量失败:', e);
  757. }
  758. };
  759. const handleSSEMessage = (data: any, aiMsgIndex: number, taskId: string) => {
  760. const streamingTask = taskList.value.find((t) => t.id === taskId);
  761. if (!streamingTask) return;
  762. // 当前正在查看流式任务时,新消息被 push 到 messages.value 而非 task.messages
  763. // 所以需要从 messages.value 读取;否则从任务存储的 messages 读取
  764. const isViewingTask = currentTaskId.value === taskId;
  765. const sourceArr = isViewingTask ? messages.value : streamingTask.messages;
  766. const aiMsg = sourceArr[aiMsgIndex];
  767. if (!aiMsg) return;
  768. const normalizedContent = normalizeLineBreaks(data.content || '');
  769. // 初始化 thinkingSteps 数组
  770. if (!aiMsg.thinkingSteps) {
  771. aiMsg.thinkingSteps = [];
  772. }
  773. switch (data.type) {
  774. // token 流式内容 - 直接拼接到消息内容
  775. case 'token':
  776. if (normalizedContent) {
  777. aiMsg.content += normalizedContent;
  778. }
  779. break;
  780. // thinking_token - 模型思考过程内容
  781. case 'thinking_token':
  782. if (normalizedContent) {
  783. aiMsg.thinkingContent = (aiMsg.thinkingContent || '') + normalizedContent;
  784. }
  785. break;
  786. // 系统进度消息 - 仅当 agent 不是 system 时才作为 thinking step
  787. case 'progress': {
  788. if (data.agent && data.agent !== 'system') {
  789. upsertStep(aiMsg.thinkingSteps!, {
  790. type: 'executing',
  791. agent: data.agent,
  792. message: data.message || '',
  793. timestamp: Date.now(),
  794. });
  795. }
  796. break;
  797. }
  798. // agent 开始处理
  799. case 'agent_start': {
  800. upsertStep(aiMsg.thinkingSteps!, {
  801. type: 'agent_start',
  802. agent: data.cn_agent || data.agent || '智能助手',
  803. message: data.message || '开始处理...',
  804. timestamp: Date.now(),
  805. });
  806. break;
  807. }
  808. // agent 任务列表更新(同一 agent 的 todos 更新已有条目,不重复添加)
  809. case 'agent_todos':
  810. case 'updated_todo_list': {
  811. const agentName = data.cn_agent || data.agent || '智能助手';
  812. const existingIdx = aiMsg.thinkingSteps!.findIndex((s) => s.type === 'updated_todo_list' && s.agent === agentName);
  813. if (existingIdx !== -1) {
  814. aiMsg.thinkingSteps![existingIdx].todos = data.todos || [];
  815. aiMsg.thinkingSteps![existingIdx].timestamp = Date.now();
  816. } else {
  817. aiMsg.thinkingSteps!.push({
  818. type: 'updated_todo_list',
  819. agent: agentName,
  820. todos: data.todos || [],
  821. message: data.message || '任务进度已更新',
  822. timestamp: Date.now(),
  823. });
  824. }
  825. break;
  826. }
  827. // agent 执行中(工具调用)
  828. case 'agent_executing':
  829. case 'executing': {
  830. upsertStep(aiMsg.thinkingSteps!, {
  831. type: 'executing',
  832. agent: data.cn_agent || data.agent || '智能助手',
  833. tool: (data.cn_tools || data.tools || []).join('、'),
  834. message: data.message || '',
  835. timestamp: Date.now(),
  836. });
  837. break;
  838. }
  839. // 工具结果
  840. case 'agent_tool_result':
  841. case 'tool_result': {
  842. upsertStep(aiMsg.thinkingSteps!, {
  843. type: 'tool_result',
  844. agent: data.cn_agent || data.agent || '智能助手',
  845. tool: data.cn_tool || data.tool || '',
  846. message: data.message || '执行完成',
  847. timestamp: Date.now(),
  848. });
  849. if (data.download_url) {
  850. downloadConfirm.value = { url: data.download_url, filename: data.filename || '文件' };
  851. }
  852. break;
  853. }
  854. // agent 完成
  855. case 'agent_done': {
  856. upsertStep(aiMsg.thinkingSteps!, {
  857. type: 'agent_done',
  858. agent: data.cn_agent || data.agent || '智能助手',
  859. message: `${(data.message || '审查完成').replace(/(\d+ms)/g, '')}${data.duration_ms ? `(${(data.duration_ms / 1000).toFixed(0)}s)` : ''}${data.progress ? ` [${data.progress}]` : ''}`,
  860. timestamp: Date.now(),
  861. });
  862. // 用后端返回的时间替换前端计时,作为中断前的累计基准
  863. if (data.duration_ms) {
  864. aiMsg.baseDurationMs = data.duration_ms;
  865. }
  866. if (data.preview) {
  867. aiMsg.content += data.preview;
  868. }
  869. break;
  870. }
  871. // 旧格式兼容
  872. case 'model_thinking':
  873. if (normalizedContent) {
  874. aiMsg.content += normalizedContent;
  875. aiMsg.content = aiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
  876. }
  877. break;
  878. case 'tool_select': {
  879. upsertStep(aiMsg.thinkingSteps!, {
  880. type: 'tool_call',
  881. agent: extractAgentName(normalizedContent),
  882. tool: normalizedContent,
  883. message: `选择工具:${normalizedContent}`,
  884. timestamp: Date.now(),
  885. });
  886. break;
  887. }
  888. case 'error':
  889. upsertStep(aiMsg.thinkingSteps!, {
  890. type: 'tool_result',
  891. agent: '系统',
  892. message: `❌ 执行失败:${normalizedContent}`,
  893. timestamp: Date.now(),
  894. });
  895. aiMsg.isLoading = false;
  896. break;
  897. case 'word_download':
  898. if (normalizedContent) {
  899. aiMsg.wordDownloadUrl = normalizedContent;
  900. streamingTask.wordUrl = normalizedContent;
  901. }
  902. break;
  903. case 'done':
  904. if (data.session_id) {
  905. aiMsg.sessionId = data.session_id;
  906. fetchContext(data.session_id);
  907. }
  908. if (data.duration_ms) {
  909. aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + data.duration_ms;
  910. }
  911. aiMsg.isLoading = false;
  912. break;
  913. case 'system':
  914. if (normalizedContent) {
  915. aiMsg.content += normalizedContent.replace(/\n/g, ' \n');
  916. }
  917. break;
  918. case 'interrupt': {
  919. // 优先使用 agent_done 已设置的后端时间,否则用前端计时
  920. const elapsedMs = aiMsg.baseDurationMs || (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
  921. aiMsg.isLoading = false;
  922. aiMsg.isPendingApproval = true;
  923. const interruptSessionId = data.session_id || streamingTask.sessionId;
  924. aiMsg.pendingApprovalData = {
  925. message: data.message || '智能体制定了执行计划,等待您的审批...',
  926. sessionId: interruptSessionId,
  927. threadId: data.thread_id,
  928. interrupts: data.interrupts,
  929. elapsedMs,
  930. prevThinkingContent: aiMsg.thinkingContent,
  931. };
  932. pendingApprovalSessionId.value = interruptSessionId;
  933. pendingApprovalThreadId.value = data.thread_id || '';
  934. break;
  935. }
  936. }
  937. // 保存到流式任务的 messages,并同步 UI
  938. if (isViewingTask) {
  939. streamingTask.messages = [...messages.value];
  940. } else {
  941. streamingTask.messages = [...streamingTask.messages];
  942. }
  943. };
  944. const parseSSEData = (chunk: string, aiMsgIndex: number, taskId: string) => {
  945. const lines = chunk.split('\n');
  946. for (const lineRaw of lines) {
  947. const line = lineRaw.trim();
  948. if (!line) continue;
  949. if (line.startsWith('data:')) {
  950. try {
  951. const jsonStr = line.substring(5).trim();
  952. const data = JSON.parse(jsonStr);
  953. handleSSEMessage(data, aiMsgIndex, taskId);
  954. } catch (error) {
  955. console.error('解析 SSE data 行失败:', error, line);
  956. }
  957. continue;
  958. }
  959. try {
  960. const data = JSON.parse(line);
  961. handleSSEMessage(data, aiMsgIndex, taskId);
  962. continue;
  963. } catch {
  964. // not json
  965. }
  966. handleSSEMessage({ type: 'model_thinking', content: line }, aiMsgIndex, taskId);
  967. }
  968. };
  969. const sendTextOnly = async (userInput: string) => {
  970. const originTaskId = currentTaskId.value;
  971. const now = dayjs();
  972. const userMsg: Message = {
  973. type: 'user',
  974. content: userInput,
  975. time: now.format('HH:mm'),
  976. createdAt: now.toISOString(),
  977. };
  978. messages.value.push(userMsg);
  979. await scrollToBottom();
  980. const aiMsgIndex = messages.value.length;
  981. const aiMsg: Message = {
  982. type: 'ai',
  983. content: '',
  984. time: now.format('HH:mm'),
  985. createdAt: now.toISOString(),
  986. isLoading: true,
  987. thinkingSteps: [],
  988. generateStartTime: Date.now(),
  989. };
  990. messages.value.push(aiMsg);
  991. await scrollToBottom();
  992. const originTask = taskList.value.find((t) => t.id === originTaskId);
  993. if (originTask) originTask.isStreaming = true;
  994. try {
  995. const result = await unifiedStream(
  996. {
  997. message: userInput,
  998. session_id: getCurrentSessionId() || undefined,
  999. mode: editMode.value,
  1000. },
  1001. (chunk: string) => {
  1002. parseSSEData(chunk, aiMsgIndex, originTaskId);
  1003. if (currentTaskId.value === originTaskId) scrollToBottom();
  1004. }
  1005. );
  1006. if (result.session_id) {
  1007. const originTask = taskList.value.find((t) => t.id === originTaskId);
  1008. if (originTask && !originTask.sessionId) {
  1009. originTask.sessionId = result.session_id;
  1010. originTask.name = userInput.substring(0, 20) + (userInput.length > 20 ? '...' : '');
  1011. if (originTask.id !== result.session_id) {
  1012. taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
  1013. originTask.id = result.session_id;
  1014. historyLoadedTasks.value.add(result.session_id);
  1015. }
  1016. if (currentTaskId.value === originTaskId) {
  1017. currentTaskId.value = result.session_id;
  1018. }
  1019. }
  1020. }
  1021. } finally {
  1022. const originTask = taskList.value.find((t) => t.id === originTaskId);
  1023. if (originTask) originTask.isStreaming = false;
  1024. }
  1025. };
  1026. const sendWithAttachment = async (file: AttachedFile, userInput: string) => {
  1027. const originTaskId = currentTaskId.value;
  1028. let currentTask = taskList.value.find((t) => t.id === originTaskId);
  1029. if (currentTask) {
  1030. currentTask.attachedFiles.push(file);
  1031. } else {
  1032. throw new Error('未找到当前任务');
  1033. }
  1034. const now = dayjs();
  1035. const userMsg: Message = {
  1036. type: 'user',
  1037. content: userInput || `上传了文件:${file.name}`,
  1038. time: now.format('HH:mm'),
  1039. createdAt: now.toISOString(),
  1040. attachedFile: file,
  1041. };
  1042. messages.value.push(userMsg);
  1043. await scrollToBottom();
  1044. const aiMsgIndex = messages.value.length;
  1045. const aiMsg: Message = {
  1046. type: 'ai',
  1047. content: '',
  1048. time: now.format('HH:mm'),
  1049. createdAt: now.toISOString(),
  1050. isLoading: true,
  1051. thinkingSteps: [],
  1052. generateStartTime: Date.now(),
  1053. };
  1054. messages.value.push(aiMsg);
  1055. await scrollToBottom();
  1056. if (!file.originalFile) {
  1057. throw new Error('缺少原始文件对象');
  1058. }
  1059. const originTask = taskList.value.find((t) => t.id === originTaskId);
  1060. if (originTask) originTask.isStreaming = true;
  1061. try {
  1062. const result = await reviewPdfStream(
  1063. {
  1064. file: file.originalFile!,
  1065. session_id: getCurrentSessionId() || undefined,
  1066. message: userInput || undefined,
  1067. mode: editMode.value,
  1068. },
  1069. (chunk: string) => {
  1070. parseSSEData(chunk, aiMsgIndex, originTaskId);
  1071. if (currentTaskId.value === originTaskId) scrollToBottom();
  1072. }
  1073. );
  1074. if (result.session_id) {
  1075. const originTask = taskList.value.find((t) => t.id === originTaskId);
  1076. if (originTask && !originTask.sessionId) {
  1077. const taskName = userInput || `文件:${file.name}`;
  1078. originTask.sessionId = result.session_id;
  1079. originTask.name = taskName.substring(0, 20) + (taskName.length > 20 ? '...' : '');
  1080. if (originTask.id !== result.session_id) {
  1081. taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
  1082. originTask.id = result.session_id;
  1083. historyLoadedTasks.value.add(result.session_id);
  1084. }
  1085. if (currentTaskId.value === originTaskId) {
  1086. currentTaskId.value = result.session_id;
  1087. }
  1088. }
  1089. }
  1090. } finally {
  1091. const originTask = taskList.value.find((t) => t.id === originTaskId);
  1092. if (originTask) originTask.isStreaming = false;
  1093. }
  1094. };
  1095. const scrollToBottom = async () => {
  1096. await nextTick();
  1097. if (!chatMessagesRef.value?.isAtBottom) return;
  1098. const el = chatMessagesRef.value?.messagesRef;
  1099. if (el) {
  1100. el.scrollTop = el.scrollHeight;
  1101. }
  1102. };
  1103. watch(
  1104. () => props.visible,
  1105. async (newVisible) => {
  1106. if (!newVisible) {
  1107. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  1108. if (currentTask) {
  1109. currentTask.messages = [...messages.value];
  1110. }
  1111. } else {
  1112. await fetchSessionList();
  1113. fetchModelMsg();
  1114. // 去除重复的空白任务,只保留一个
  1115. taskList.value = taskList.value.filter((task, index) => {
  1116. if (task.sessionId) return true;
  1117. return index === taskList.value.findIndex((t) => !t.sessionId);
  1118. });
  1119. // 默认选中第一条历史记录
  1120. const firstHistoryTask = taskList.value.find((t) => t.sessionId);
  1121. let targetTask: Task;
  1122. if (firstHistoryTask) {
  1123. targetTask = firstHistoryTask;
  1124. } else {
  1125. // 没有历史记录时才创建空白新任务
  1126. targetTask = {
  1127. id: `task-${Date.now()}`,
  1128. name: '新任务',
  1129. sessionId: '',
  1130. messages: getDefaultMessages(),
  1131. attachedFiles: [],
  1132. };
  1133. taskList.value.unshift(targetTask);
  1134. }
  1135. currentTaskId.value = targetTask.id;
  1136. // 如果是历史任务,加载其消息
  1137. if (targetTask.sessionId && !historyLoadedTasks.value.has(targetTask.id)) {
  1138. loading.value = true;
  1139. try {
  1140. const res = await getDetail(targetTask.sessionId);
  1141. const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
  1142. if (Array.isArray(messages)) {
  1143. targetTask.messages = transformHistoryToMessages(messages);
  1144. }
  1145. historyLoadedTasks.value.add(targetTask.id);
  1146. } catch (error) {
  1147. console.error('加载历史会话失败:', error);
  1148. } finally {
  1149. loading.value = false;
  1150. }
  1151. }
  1152. if (targetTask.sessionId) {
  1153. fetchContext(targetTask.sessionId);
  1154. fetchSessionMode(targetTask.sessionId);
  1155. } else {
  1156. contextUsed.value = 0;
  1157. contextPercent.value = 0;
  1158. contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
  1159. editMode.value = 'full';
  1160. }
  1161. messages.value = targetTask.messages && targetTask.messages.length > 0 ? [...targetTask.messages] : getDefaultMessages();
  1162. previewFile.value = null;
  1163. pendingFile.value = null;
  1164. showFileList.value = false;
  1165. inputMessage.value = '';
  1166. await nextTick(() => {
  1167. chatMessagesRef.value?.forceScrollToBottom();
  1168. });
  1169. }
  1170. }
  1171. );
  1172. // 拖拽相关(使用普通变量避免 mousemove 触发 Vue 响应式导致卡顿)
  1173. let isDragging = false;
  1174. let dragStartX = 0;
  1175. let dragStartY = 0;
  1176. let modalStartLeft = 0;
  1177. let modalStartTop = 0;
  1178. let draggedEl: HTMLElement | null = null;
  1179. const findModalWrap = (target: HTMLElement): HTMLElement | null =>
  1180. target.closest<HTMLElement>('.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]');
  1181. const queryModalWrap = (): HTMLElement | null => document.querySelector<HTMLElement>('.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]');
  1182. const centerModal = () => {
  1183. nextTick(() => {
  1184. const wrap = queryModalWrap();
  1185. if (wrap) {
  1186. wrap.style.position = '';
  1187. wrap.style.left = '';
  1188. wrap.style.top = '';
  1189. wrap.style.right = '';
  1190. wrap.style.bottom = '';
  1191. wrap.style.width = '';
  1192. wrap.style.height = '';
  1193. wrap.style.margin = '';
  1194. wrap.style.transform = '';
  1195. }
  1196. draggedEl = null;
  1197. });
  1198. };
  1199. const onDragStart = (e: MouseEvent) => {
  1200. if (isFullscreen.value) return;
  1201. draggedEl = findModalWrap(e.target as HTMLElement) || queryModalWrap();
  1202. if (!draggedEl) return;
  1203. isDragging = true;
  1204. dragStartX = e.clientX;
  1205. dragStartY = e.clientY;
  1206. const rect = draggedEl.getBoundingClientRect();
  1207. modalStartLeft = rect.left;
  1208. modalStartTop = rect.top;
  1209. draggedEl.style.position = 'fixed';
  1210. draggedEl.style.left = `${rect.left}px`;
  1211. draggedEl.style.top = `${rect.top}px`;
  1212. draggedEl.style.margin = '0';
  1213. draggedEl.style.transform = 'none';
  1214. document.addEventListener('mousemove', onDragMove);
  1215. document.addEventListener('mouseup', onDragEnd);
  1216. };
  1217. const onDragMove = (e: MouseEvent) => {
  1218. if (!isDragging || !draggedEl) return;
  1219. draggedEl.style.left = `${modalStartLeft + (e.clientX - dragStartX)}px`;
  1220. draggedEl.style.top = `${modalStartTop + (e.clientY - dragStartY)}px`;
  1221. };
  1222. const onDragEnd = () => {
  1223. isDragging = false;
  1224. document.removeEventListener('mousemove', onDragMove);
  1225. document.removeEventListener('mouseup', onDragEnd);
  1226. };
  1227. watch(
  1228. () => props.visible,
  1229. (newVisible) => {
  1230. if (newVisible) {
  1231. centerModal();
  1232. }
  1233. }
  1234. );
  1235. onMounted(async () => {
  1236. // fetchSessionList moved to watch(visible) to avoid unnecessary API calls
  1237. });
  1238. onBeforeUnmount(() => {
  1239. document.removeEventListener('mousemove', onDragMove);
  1240. document.removeEventListener('mouseup', onDragEnd);
  1241. });
  1242. </script>
  1243. <style scoped lang="less">
  1244. .ai-container {
  1245. display: flex;
  1246. height: 700px;
  1247. gap: 10px;
  1248. overflow: hidden;
  1249. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  1250. }
  1251. .collapse-btn {
  1252. position: absolute;
  1253. top: 16px;
  1254. left: 24px;
  1255. z-index: 10;
  1256. width: 32px;
  1257. height: 32px;
  1258. background: #1a6696;
  1259. border-radius: 4px;
  1260. display: flex;
  1261. align-items: center;
  1262. justify-content: center;
  1263. cursor: pointer;
  1264. transition: background 0.3s;
  1265. &:hover {
  1266. background: #1f7ab8;
  1267. }
  1268. .collapse-icon {
  1269. width: 16px;
  1270. height: 16px;
  1271. background-image: var(--img-chat-collapse-icon);
  1272. background-repeat: no-repeat;
  1273. background-size: 100% 100%;
  1274. &.collapsed {
  1275. background-image: var(--img-chat-expand-icon);
  1276. }
  1277. }
  1278. }
  1279. .task-panel-wrapper {
  1280. width: 300px;
  1281. flex-shrink: 0;
  1282. overflow: hidden;
  1283. transition:
  1284. width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  1285. margin 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  1286. opacity 0.25s ease;
  1287. &.collapsed {
  1288. width: 0;
  1289. margin: 0;
  1290. opacity: 0;
  1291. }
  1292. }
  1293. .chat-panel {
  1294. flex: 1;
  1295. display: flex;
  1296. position: relative;
  1297. flex-direction: column;
  1298. background-image: var(--img-chat-panel-bg);
  1299. background-repeat: no-repeat;
  1300. background-size: 100% 100%;
  1301. border-radius: 4px;
  1302. overflow: hidden;
  1303. min-width: 0;
  1304. .download-confirm-bar {
  1305. display: flex;
  1306. align-items: center;
  1307. justify-content: space-between;
  1308. padding: 10px 16px;
  1309. margin: 0 16px;
  1310. background: #0a2a3f;
  1311. border: 1px solid #1a4a6f;
  1312. border-radius: 8px;
  1313. flex-shrink: 0;
  1314. .download-confirm-text {
  1315. font-size: 13px;
  1316. color: #e0e6ed;
  1317. overflow: hidden;
  1318. text-overflow: ellipsis;
  1319. white-space: nowrap;
  1320. margin-right: 12px;
  1321. }
  1322. .download-confirm-actions {
  1323. display: flex;
  1324. gap: 8px;
  1325. flex-shrink: 0;
  1326. }
  1327. .download-confirm-btn {
  1328. padding: 4px 14px;
  1329. border-radius: 4px;
  1330. font-size: 12px;
  1331. cursor: pointer;
  1332. border: none;
  1333. transition: all 0.2s;
  1334. &.download-btn {
  1335. background: #0a84ff;
  1336. color: #fff;
  1337. &:hover {
  1338. background: #3a9bfd;
  1339. }
  1340. }
  1341. &.cancel-btn {
  1342. background: rgba(255, 255, 255, 0.08);
  1343. color: #e0e6ed;
  1344. border: 1px solid rgba(63, 80, 106, 0.5);
  1345. &:hover {
  1346. background: rgba(255, 255, 255, 0.15);
  1347. }
  1348. }
  1349. }
  1350. }
  1351. :deep(.chat-header) {
  1352. display: flex;
  1353. padding: 12px 20px;
  1354. flex-shrink: 0;
  1355. align-items: center;
  1356. background: rgba(2, 53, 100, 0.3);
  1357. .header-icon {
  1358. width: 35px;
  1359. height: 28px;
  1360. background-image: var(--img-chat-header-icon);
  1361. background-repeat: no-repeat;
  1362. background-size: 100% 100%;
  1363. margin-right: 10px;
  1364. }
  1365. .chat-header-title {
  1366. font-size: 16px;
  1367. color: #e0e6ed;
  1368. overflow: hidden;
  1369. text-overflow: ellipsis;
  1370. white-space: nowrap;
  1371. margin-left: 10px;
  1372. }
  1373. .chat-header-title-wrapper {
  1374. display: flex;
  1375. align-items: center;
  1376. flex: 1;
  1377. min-width: 0;
  1378. &:hover .title-edit-btn {
  1379. opacity: 1;
  1380. }
  1381. .title-edit-btn {
  1382. width: 28px;
  1383. height: 28px;
  1384. flex-shrink: 0;
  1385. background-image: var(--img-chat-edit-icon);
  1386. background-repeat: no-repeat;
  1387. background-size: 16px 16px;
  1388. background-position: center;
  1389. cursor: pointer;
  1390. opacity: 0;
  1391. transition: opacity 0.2s;
  1392. border-radius: 4px;
  1393. margin-right: 4px;
  1394. &:hover {
  1395. background-color: rgba(10, 132, 255, 0.2);
  1396. }
  1397. }
  1398. .chat-header-title {
  1399. margin-left: 0;
  1400. flex: 1;
  1401. min-width: 0;
  1402. }
  1403. .title-edit-input {
  1404. flex: 1;
  1405. min-width: 0;
  1406. height: 28px;
  1407. padding: 0 8px;
  1408. font-size: 14px;
  1409. color: #e0e6ed;
  1410. background: rgba(10, 132, 255, 0.1);
  1411. border: 1px solid rgba(10, 132, 255, 0.5);
  1412. border-radius: 4px;
  1413. outline: none;
  1414. &:focus {
  1415. border-color: rgba(10, 132, 255, 0.8);
  1416. }
  1417. }
  1418. }
  1419. .nav-btn {
  1420. width: 28px;
  1421. height: 28px;
  1422. display: flex;
  1423. align-items: center;
  1424. justify-content: center;
  1425. cursor: pointer;
  1426. border-radius: 4px;
  1427. transition: background 0.2s;
  1428. &:hover:not(.disabled) {
  1429. background: rgba(10, 132, 255, 0.15);
  1430. }
  1431. &.disabled {
  1432. opacity: 0.3;
  1433. cursor: not-allowed;
  1434. }
  1435. .nav-icon {
  1436. width: 16px;
  1437. height: 16px;
  1438. background-repeat: no-repeat;
  1439. background-size: 100% 100%;
  1440. }
  1441. .prev-icon {
  1442. background-image: var(--img-chat-prev-icon);
  1443. }
  1444. .next-icon {
  1445. background-image: var(--img-chat-next-icon);
  1446. }
  1447. .add-icon {
  1448. background-image: var(--img-chat-options-add-icon);
  1449. }
  1450. }
  1451. .upload-bg {
  1452. cursor: pointer;
  1453. width: 36px;
  1454. height: 36px;
  1455. background-image: var(--img-chat-close-bg);
  1456. background-repeat: no-repeat;
  1457. background-size: 100% 100%;
  1458. display: flex;
  1459. align-items: center;
  1460. justify-content: center;
  1461. margin-left: auto;
  1462. position: relative;
  1463. }
  1464. .download-icon {
  1465. width: 20px;
  1466. height: 20px;
  1467. background-image: var(--img-chat-download-icon);
  1468. background-repeat: no-repeat;
  1469. background-size: 100% 100%;
  1470. }
  1471. }
  1472. }
  1473. .right-panel {
  1474. width: calc(50% - 140px);
  1475. height: 100%;
  1476. display: flex;
  1477. flex-direction: column;
  1478. background-image: var(--img-chat-panel-bg);
  1479. background-repeat: no-repeat;
  1480. background-size: 100% 100%;
  1481. border-radius: 4px;
  1482. flex-shrink: 0;
  1483. }
  1484. .draggable-title {
  1485. cursor: grab;
  1486. user-select: none;
  1487. &:active {
  1488. cursor: grabbing;
  1489. }
  1490. &.no-drag {
  1491. cursor: default;
  1492. &:active {
  1493. cursor: default;
  1494. }
  1495. }
  1496. }
  1497. </style>
  1498. <style lang="less">
  1499. .ai-assistant-modal {
  1500. .zxm-modal-content {
  1501. background-image: var(--img-chat-modal-bg);
  1502. background-repeat: no-repeat;
  1503. background-size: 100% 100%;
  1504. background-color: unset !important;
  1505. box-shadow: none;
  1506. padding: 0;
  1507. border: none !important;
  1508. }
  1509. .zxm-modal-close {
  1510. background-image: var(--img-chat-close-bg);
  1511. background-repeat: no-repeat;
  1512. background-size: 100% 100%;
  1513. top: 0px;
  1514. right: 0px;
  1515. width: 38px;
  1516. height: 38px;
  1517. .zxm-modal-close-x {
  1518. width: 38px;
  1519. height: 38px;
  1520. }
  1521. svg {
  1522. width: 26px;
  1523. height: 26px;
  1524. margin-bottom: 4px;
  1525. }
  1526. }
  1527. .maximize-btn {
  1528. position: absolute;
  1529. top: 0px;
  1530. right: 56px;
  1531. width: 38px;
  1532. height: 38px;
  1533. background-image: var(--img-chat-close-bg);
  1534. background-repeat: no-repeat;
  1535. background-size: 100% 100%;
  1536. display: flex;
  1537. align-items: center;
  1538. justify-content: center;
  1539. cursor: pointer;
  1540. z-index: 10;
  1541. .maximize-icon {
  1542. width: 22px;
  1543. height: 22px;
  1544. background-image: var(--img-chat-maximize-icon);
  1545. background-repeat: no-repeat;
  1546. background-size: 100% 100%;
  1547. &.minimized {
  1548. background-image: var(--img-chat-minimize-icon);
  1549. }
  1550. }
  1551. }
  1552. &.fullscreen {
  1553. top: 0 !important;
  1554. padding: 0 !important;
  1555. max-width: 100vw !important;
  1556. margin: 0 !important;
  1557. background-color: #09172c;
  1558. .zxm-modal-wrap,
  1559. .ant-modal-wrap {
  1560. padding: 0 !important;
  1561. margin: 0 !important;
  1562. top: 0 !important;
  1563. }
  1564. .zxm-modal-content {
  1565. width: 100vw !important;
  1566. height: 100vh !important;
  1567. max-width: 100vw !important;
  1568. margin: 0;
  1569. border-radius: 0;
  1570. overflow: hidden;
  1571. background-image: var(--img-chat-fs-modal-bg);
  1572. }
  1573. .zxm-modal-header {
  1574. height: 90px;
  1575. background-image: var(--img-chat-fs-header-bg) !important;
  1576. background-repeat: no-repeat;
  1577. background-size: 100% 100%;
  1578. margin-bottom: 16px;
  1579. }
  1580. .no-drag {
  1581. cursor: default;
  1582. font-size: 32px !important;
  1583. }
  1584. .zxm-modal-title {
  1585. height: 90px;
  1586. }
  1587. .zxm-modal-body {
  1588. height: calc(100vh - 120px);
  1589. overflow: hidden;
  1590. padding: 0 24px 12px;
  1591. }
  1592. .ai-container {
  1593. height: 100%;
  1594. overflow: hidden;
  1595. }
  1596. .chat-panel {
  1597. background-image: var(--img-chat-fs-panel-bg) !important;
  1598. }
  1599. .chat-header {
  1600. padding: 0 32px;
  1601. height: 50px;
  1602. }
  1603. .collapse-btn {
  1604. top: 60px;
  1605. background-image: var(--img-chat-fs-collapse-btn-bg) !important;
  1606. background-repeat: no-repeat;
  1607. background-size: 100% 100%;
  1608. }
  1609. .task-list-panel {
  1610. padding: 20px;
  1611. background-image: var(--img-chat-fs-task-panel-bg) !important;
  1612. background-repeat: no-repeat !important;
  1613. background-size: 100% 100% !important;
  1614. }
  1615. .options-area {
  1616. gap: 12px;
  1617. }
  1618. .options-area-item {
  1619. background-image: var(--img-chat-fs-options-item-bg) !important;
  1620. background-repeat: no-repeat !important;
  1621. background-size: 100% 100% !important;
  1622. &.active,
  1623. &:active {
  1624. background-image: var(--img-chat-fs-options-item-active-bg) !important;
  1625. }
  1626. .options-img {
  1627. background-image: none !important;
  1628. }
  1629. }
  1630. .chat-header-title {
  1631. height: 40px;
  1632. line-height: 40px;
  1633. position: relative;
  1634. background-image: var(--img-chat-fs-header-title-bg) !important;
  1635. background-repeat: no-repeat !important;
  1636. background-size: auto 100% !important;
  1637. padding: 0 28px;
  1638. &::before {
  1639. content: '';
  1640. position: absolute;
  1641. left: 0;
  1642. top: 50%;
  1643. transform: translateY(-50%);
  1644. width: 40px;
  1645. height: 40px;
  1646. background-image: var(--img-chat-fs-header-title-icon);
  1647. background-repeat: no-repeat;
  1648. background-size: 100% 100%;
  1649. }
  1650. }
  1651. .chat-header-title-wrapper {
  1652. .title-edit-input {
  1653. height: 36px;
  1654. font-size: 16px;
  1655. background: rgba(10, 132, 255, 0.15);
  1656. border-color: rgba(10, 132, 255, 0.6);
  1657. }
  1658. .title-edit-btn {
  1659. width: 24px;
  1660. height: 24px;
  1661. background-size: 16px 16px;
  1662. }
  1663. }
  1664. .chat-input-area {
  1665. margin: 24px;
  1666. }
  1667. }
  1668. .zxm-modal-header {
  1669. background-color: unset !important;
  1670. background: unset;
  1671. border: none !important;
  1672. padding: 8px;
  1673. backdrop-filter: unset;
  1674. display: flex;
  1675. justify-content: center;
  1676. }
  1677. .zxm-modal-title {
  1678. width: 100%;
  1679. height: 60px;
  1680. display: flex;
  1681. align-items: center;
  1682. justify-content: center;
  1683. .draggable-title {
  1684. color: #fff;
  1685. font-family: var(--vet-font-family);
  1686. letter-spacing: 3px;
  1687. font-size: 24px;
  1688. font-weight: 100;
  1689. line-height: 1.4;
  1690. }
  1691. }
  1692. .zxm-modal-body {
  1693. padding: 12px 24px;
  1694. }
  1695. }
  1696. .delete-task-confirm-modal {
  1697. .zxm-modal-confirm-title,
  1698. .zxm-modal-confirm-content {
  1699. color: #fff;
  1700. }
  1701. }
  1702. </style>