AIFlowChat.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. <script setup>
  2. import {
  3. onMounted,
  4. ref,
  5. nextTick,
  6. watch,
  7. onUnmounted
  8. } from 'vue';
  9. import {
  10. useRoute
  11. } from 'vue-router';
  12. import {
  13. getFlowChatStatus,
  14. getHistoryChatList
  15. } from '@/api/ai'
  16. import {
  17. Top,
  18. Plus,
  19. Check,
  20. Loading,
  21. Monitor
  22. } from '@element-plus/icons-vue'
  23. import {
  24. useUserStore
  25. } from '@/store'
  26. import {
  27. marked
  28. } from 'marked';
  29. import hljs from 'highlight.js/lib/common';
  30. // 引入 github 代码主题
  31. import 'highlight.js/styles/github.css';
  32. import config from '@/config'
  33. import {
  34. fetchEventSource
  35. } from '@microsoft/fetch-event-source';
  36. const emits = defineEmits(['updateURL', 'removeEdit']);
  37. const user = ref(useUserStore().userData);
  38. // 对话历史
  39. const chatHistory = ref([]);
  40. // 加载状态
  41. const loading = ref(false);
  42. // 输入框内容
  43. const prompt = ref('');
  44. // 当前AI流式响应内容
  45. const currentAIResponse = ref('');
  46. const paramId = ref(useRoute().params.id);
  47. const paramType = ref(useRoute().params.type);
  48. const conversationId = ref('');
  49. const chatBody = ref(null);
  50. const previewUrl = ref('');
  51. onMounted(() => {
  52. if (paramId.value.trim()) init();
  53. })
  54. // 监听对话历史变化,自动滚动到底部
  55. watch(chatHistory, () => {
  56. scrollToBottom();
  57. }, {
  58. deep: true
  59. })
  60. let timerId = null;
  61. const init = async () => {
  62. if (paramType.value === 'chat') {
  63. conversationId.value = paramId.value;
  64. initChatList();
  65. return;
  66. }
  67. let flowChatData = await getFlowChatStatus(paramId.value);
  68. if (flowChatData.state) {
  69. if (flowChatData.data.status === 'succeeded') {
  70. conversationId.value = flowChatData.data.conversationId;
  71. loading.value = false;
  72. initChatList();
  73. stopTimer();
  74. } else {
  75. let inputs = JSON.parse(flowChatData.data.inputs);
  76. chatHistory.value = [{
  77. query: inputs.query,
  78. AIoutputs: ''
  79. }]
  80. loading.value = true;
  81. startTimer();
  82. }
  83. }
  84. }
  85. // 启动定时器
  86. const startTimer = () => {
  87. if (timerId) return
  88. timerId = setInterval(() => {
  89. init();
  90. }, 3000)
  91. }
  92. // 停止定时器
  93. const stopTimer = () => {
  94. if (timerId) {
  95. clearInterval(timerId)
  96. timerId = null;
  97. }
  98. }
  99. const initChatList = async () => {
  100. let flowData = await getHistoryChatList(conversationId.value);
  101. if (flowData.state) {
  102. versionIndex.value = 1;
  103. chatHistory.value = flowData.data.data.map(node => {
  104. node['AIoutputs'] = returnAIoutputs(node.answer);
  105. return node
  106. });
  107. hljs.highlightAll();
  108. if (previewUrl.value) emits('updateURL', previewUrl.value);
  109. }
  110. }
  111. const reloadMessage = (value) => {
  112. prompt.value = value;
  113. sendMessage();
  114. }
  115. const sendMessage = () => {
  116. if (!prompt.value.trim() || loading.value) return;
  117. AIMessage(prompt.value)
  118. }
  119. const returnAIoutputs = (data) => {
  120. let str = '';
  121. if (data.indexOf('```markdown') > -1) {
  122. str = data.replaceAll('\n', '</n>').replaceAll('```markdown', '').replaceAll('```', '');
  123. } else {
  124. str = data;
  125. }
  126. let obj = str.split('<workark>');
  127. let newData = obj.map(node => {
  128. try {
  129. return JSON.parse(node)
  130. } catch {
  131. return {}
  132. }
  133. });
  134. let urlList = newData.filter(node => node.type === 'url');
  135. if (urlList.length > 0) previewUrl.value = urlList[urlList.length - 1].url;
  136. return deduplicateByTypeAndId(newData);
  137. }
  138. const AIMessage = async (userMessage) => {
  139. try {
  140. // 1. 添加用户消息到历史记录
  141. chatHistory.value.push({
  142. isUser: true,
  143. query: userMessage,
  144. AIoutputs: ''
  145. })
  146. // 2. 初始化状态
  147. loading.value = true;
  148. currentAIResponse.value = ''
  149. prompt.value = '' // 清空输入框
  150. let result = '';
  151. let setTime = null;
  152. let dom = '';
  153. if (domItem.value.name && domItem.value.parts) {
  154. dom = JSON.stringify(domItem.value.parts.map(node => {
  155. return {
  156. fileName: domItem.value.file,
  157. line: node.lineNumber,
  158. componentName: domItem.value.name
  159. }
  160. }))
  161. domItem.value = {};
  162. emits('removeEdit');
  163. }
  164. fetchEventSource(`${config.baseURL}/api/ai/chat/run/7`, {
  165. method: 'POST',
  166. headers: {
  167. 'Accept': 'text/event-stream',
  168. "Content-Type": "application/json",
  169. "token": useUserStore().token
  170. },
  171. timeout: 1200000000000000, // 超时
  172. openWhenHidden: true,
  173. body: JSON.stringify({
  174. query: userMessage,
  175. conversationId: conversationId.value,
  176. inputs: {
  177. websiteURL: "",
  178. fileURL: "",
  179. dom: dom
  180. }
  181. }),
  182. onmessage(event) {
  183. // 处理接收到的消息
  184. result += event.data;
  185. if (!setTime) {
  186. setTime = setTimeout(() => {
  187. if (result.indexOf('markdown') > -1) {
  188. initChatList();
  189. } else {
  190. chatHistory.value[chatHistory.value.length - 1].AIoutputs =
  191. returnAIoutputs(result);
  192. scrollToBottom();
  193. clearTimeout(setTime);
  194. loading.value = false;
  195. setTime = null;
  196. if (previewUrl.value) emits('updateURL', previewUrl.value);
  197. }
  198. }, 1000)
  199. }
  200. },
  201. onopen(response) {
  202. // 连接打开时的回调
  203. console.log('Connection opened:', response.status);
  204. },
  205. onerror(error) {
  206. // 错误处理
  207. console.error('Error:', error);
  208. },
  209. onclose() {
  210. console.log('close');
  211. // 连接关闭时的回调
  212. loading.value = false;
  213. }
  214. })
  215. } catch (error) {
  216. console.error("请求失败:", error);
  217. }
  218. }
  219. const scrollToBottom = () => {
  220. nextTick(() => {
  221. if (chatBody.value) {
  222. chatBody.value.scrollTop = chatBody.value.scrollHeight
  223. }
  224. })
  225. }
  226. const returnUserInputs = (data) => {
  227. if (!data) return '';
  228. return JSON.parse(data).query;
  229. }
  230. const deduplicateByTypeAndId = (arr) => {
  231. const map = new Map();
  232. arr.forEach(item => {
  233. const key = item.type === 'step' && item.id ? `${item.type}-${item.id}` : item.type;
  234. map.set(key, item); // 后面覆盖前面的
  235. });
  236. return Array.from(map.values());
  237. }
  238. /**
  239. * 使用 marked 解析 Markdown
  240. * @param markdown 解析的文本
  241. */
  242. const versionIndex = ref(1);
  243. const renderMarkdown = markdown => {
  244. const renderer = new marked.Renderer();
  245. renderer.code = ({
  246. text,
  247. lang,
  248. escaped
  249. }) => {
  250. const language = hljs.getLanguage(lang) ? lang : 'plaintext';
  251. const highlighted = hljs.highlight(text, {
  252. language
  253. }).value;
  254. return `<div class="hljs-box"><div class="hljs-title">${language.toLocaleUpperCase()}</div><pre class="hljs-pre"><code class="hljs language-${language}">${highlighted}</code></pre></div>`;
  255. };
  256. renderer.link = (href) => {
  257. let divBox = document.createElement('div');
  258. let div = document.createElement('div');
  259. div.innerHTML = `version-${versionIndex.value}`
  260. div.id = href.href;
  261. div.className = 'version-href'
  262. versionIndex.value++;
  263. divBox.appendChild(div);
  264. previewUrl.value = href.href;
  265. return divBox.innerHTML;
  266. };
  267. return marked('```markdown' + markdown.replaceAll('</n>', '\n') + '```', {
  268. renderer
  269. });
  270. };
  271. const AIClick = (url) => {
  272. emits('updateURL', url);
  273. }
  274. const selectVisible = ref(false);
  275. const functionList = ref(['登录功能']);
  276. const selectFunction = data => {
  277. AIMessage('新增' + data);
  278. selectVisible.value = false;
  279. }
  280. // 组件卸载时清除定时器(重要!)
  281. onUnmounted(() => {
  282. stopTimer()
  283. })
  284. const showLine = (type, item, list) => {
  285. let arr = list.filter(node => node.type === 'step');
  286. let index = arr.findIndex(node => node.id === item.id);
  287. if (type === 'top') {
  288. if (index <= 0) return false;
  289. } else {
  290. if (index >= arr.length - 1) return false;
  291. }
  292. return true;
  293. }
  294. const domItem = ref({});
  295. const selectItem = (item) => {
  296. domItem.value = item;
  297. }
  298. defineExpose({
  299. selectItem
  300. })
  301. </script>
  302. <template>
  303. <div class="ai-chat-container">
  304. <div class="chat-body" ref="chatBody">
  305. <div v-for="(message, index) in chatHistory" :key="index">
  306. <div class="user-message">
  307. <div class="message user-message-box">
  308. <div class="message-content" v-if="message.query" v-html="message.query"></div>
  309. </div>
  310. </div>
  311. <div class="message ai-message" v-if="!loading || message.AIoutputs" style="margin-top: 15px;">
  312. <div class="message-header">
  313. <el-avatar :size="30"
  314. src="https://file-node.oss-cn-shanghai.aliyuncs.com/youji/f9617c7f80da485cb3cc72b6accc62ed">
  315. </el-avatar>
  316. <div class="message-header-label" style="width: 100px;">AI助手</div>
  317. </div>
  318. <div class="message-content">
  319. <div class="ai-website-boxs">
  320. <div v-for="(item,index) in message.AIoutputs">
  321. <div class="wk-query" v-if="item.type === 'query'">{{item.label}}</div>
  322. <div class="wk-markdown wk-query" v-if="item.type === 'markdown'"
  323. v-html="renderMarkdown(item.label)">
  324. </div>
  325. <div :class="'wk-step timeline-'+item.status" v-if="item.type === 'step'">
  326. <div :class="'step-icon ' + item.status">
  327. <el-icon>
  328. <Check v-if="item.status === 'success'" />
  329. <Loading v-else />
  330. </el-icon>
  331. </div>
  332. <div class="wk-content">
  333. <div class="title">{{ item.label }}</div>
  334. <div class="content" v-if="item.status === 'success'">
  335. <el-icon>
  336. <Monitor />
  337. </el-icon>
  338. <span class="content-label">{{ item.label }}</span>
  339. <span class="content-describe">{{ item.describe }}</span>
  340. </div>
  341. </div>
  342. <div class="line line-top" v-if="showLine('top',item,message.AIoutputs)"></div>
  343. <div class="line line-bottom" v-if="showLine('bottom',item,message.AIoutputs)">
  344. </div>
  345. </div>
  346. <div class="wk-url" v-if="item.type === 'url'">
  347. <div class="label">{{item.label || '网站生成成功'}}</div>
  348. <el-button size="small" type="primary" @click="AIClick(item.url)">点击查看</el-button>
  349. </div>
  350. </div>
  351. </div>
  352. <!-- <el-button size="small" @click="reloadMessage(message.query)" :disabled="loading"
  353. style="margin-top: 10px;" v-if="message.AIoutputs.indexOf('系统繁忙,请重试')>-1">
  354. 点击重试
  355. </el-button> -->
  356. </div>
  357. </div>
  358. </div>
  359. <div v-if="loading" class="typing-indicator">
  360. <div class="typing-dot"></div>
  361. <div class="typing-dot"></div>
  362. <div class="typing-dot"></div>
  363. <span style="margin-left: 10px;">思考中...</span>
  364. </div>
  365. </div>
  366. <div class="input-container">
  367. <div class="input-box">
  368. <div class="input-tag" v-if="domItem.name">
  369. <el-tag effect="plain" round size="small">{{domItem.name}}</el-tag>
  370. </div>
  371. <el-input type="textarea" v-model="prompt" placeholder="给AI发送消息" resize="none" :rows="4"
  372. :autosize="{ minRows: 2, maxRows: 6 }">
  373. </el-input>
  374. <div class="input-button">
  375. <el-button style="margin-left: 10px;" size="default" :icon="Plus" :disabled="loading || !previewUrl"
  376. circle @click="selectVisible = true">
  377. </el-button>
  378. <el-button circle type="primary" @click="sendMessage" :disabled="!prompt.trim() || loading"
  379. :icon="Top">
  380. </el-button>
  381. </div>
  382. </div>
  383. </div>
  384. <el-dialog v-model="selectVisible" title="新增功能" width="800px" class="ai-dialog select-dialog">
  385. <div class="select-item">
  386. <el-row :gutter="15">
  387. <el-col :span="6" v-for="(item,index) in functionList" :key="index">
  388. <el-button style="width: 100%;" @click="selectFunction(item)">
  389. {{item}}
  390. </el-button>
  391. </el-col>
  392. </el-row>
  393. </div>
  394. </el-dialog>
  395. </div>
  396. </template>
  397. <style lang="scss">
  398. .message-image {
  399. width: 30px;
  400. }
  401. .hljs-box {
  402. border-radius: 5px;
  403. overflow: hidden;
  404. .hljs-title {
  405. background: #f2f3f5;
  406. padding: 5px 10px;
  407. color: #354052;
  408. font-weight: bold;
  409. border-bottom: 1px solid #d0d3d9;
  410. }
  411. .language-html,
  412. .language-markdown {
  413. background: #f1f2f5;
  414. }
  415. .hljs-pre {
  416. margin: 0;
  417. }
  418. }
  419. .version-href {
  420. border: 1px solid var(--el-border-color);
  421. padding: 5px 15px;
  422. border-radius: 6px;
  423. cursor: pointer;
  424. display: inline-block;
  425. margin-top: 5px;
  426. }
  427. .wk-query {
  428. padding: 12px 0;
  429. font-size: 15px;
  430. font-weight: 500;
  431. &:last-child {
  432. padding-bottom: 0;
  433. }
  434. }
  435. .wk-step {
  436. padding-top: 15px;
  437. padding-left: 20px;
  438. position: relative;
  439. .line {
  440. position: absolute;
  441. width: 1px;
  442. border-left: 1px dashed var(--el-border-color);
  443. left: 7px;
  444. z-index: 4;
  445. }
  446. .line-top {
  447. top: 0;
  448. height: 17px;
  449. }
  450. .line-bottom {
  451. top: 20px;
  452. bottom: 0;
  453. }
  454. .step-icon {
  455. position: absolute;
  456. left: 0;
  457. background: var(--el-color-warning);
  458. color: #fff;
  459. width: 14px;
  460. height: 14px;
  461. display: flex;
  462. align-items: center;
  463. justify-content: center;
  464. font-size: 12px;
  465. border-radius: 16px;
  466. top: 18px;
  467. z-index: 9;
  468. &.success {
  469. background: var(--el-color-success);
  470. }
  471. }
  472. &.timeline-warning {
  473. .step-icon {
  474. animation: rotating 2s linear infinite;
  475. }
  476. }
  477. }
  478. .wk-url {
  479. padding-top: 15px;
  480. .label {
  481. font-size: 15px;
  482. font-weight: 500;
  483. margin-bottom: 10px;
  484. }
  485. }
  486. .wk-timeline {
  487. .el-timeline-item__icon {
  488. font-size: 10px;
  489. }
  490. .el-timeline-item {
  491. padding-bottom: 10px;
  492. }
  493. }
  494. .wk-content {
  495. .title {
  496. color: #222;
  497. }
  498. .content {
  499. background: rgb(246, 247, 249);
  500. border: 1px solid rgb(234, 234, 234);
  501. height: 30px;
  502. line-height: 28px;
  503. border-radius: 30px;
  504. padding: 0 15px;
  505. font-size: 13px;
  506. box-sizing: border-box;
  507. color: rgb(120, 121, 121);
  508. margin-top: 10px;
  509. display: flex;
  510. align-items: center;
  511. }
  512. .content-label {
  513. margin: 0 10px 0 5px;
  514. }
  515. .content-describe {
  516. font-size: 12px;
  517. }
  518. }
  519. </style>
  520. <style scoped lang="scss">
  521. .ai-chat-container {
  522. width: 100%;
  523. height: 100%;
  524. background: white;
  525. border-radius: 16px;
  526. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
  527. overflow: hidden;
  528. display: flex;
  529. flex-direction: column;
  530. }
  531. /* 聊天内容区域 */
  532. .chat-body {
  533. flex: 1;
  534. padding: 20px;
  535. overflow-y: auto;
  536. background: #f8fafc;
  537. display: flex;
  538. flex-direction: column;
  539. gap: 15px;
  540. overflow-x: hidden;
  541. }
  542. .message {
  543. padding: 12px 16px;
  544. border-radius: 18px;
  545. position: relative;
  546. animation: fadeIn 0.3s ease;
  547. line-height: 1.5;
  548. display: inline-block;
  549. max-width: 100%;
  550. }
  551. .user-message {
  552. display: flex;
  553. justify-content: end;
  554. .user-message-box {
  555. background: #7a72cf;
  556. color: white;
  557. border-bottom-right-radius: 4px;
  558. }
  559. }
  560. .ai-message {
  561. background: #ffffff;
  562. border: 1px solid #e9ecef;
  563. margin-right: auto;
  564. border-bottom-left-radius: 4px;
  565. width: 100%;
  566. }
  567. .message-header {
  568. display: flex;
  569. align-items: center;
  570. font-weight: 500;
  571. }
  572. .message-header-label {
  573. flex: 1;
  574. width: 0;
  575. overflow: hidden;
  576. margin-left: 5px;
  577. font-weight: bold;
  578. }
  579. .message-time {
  580. font-size: 0.7rem;
  581. opacity: 0.7;
  582. margin-top: 5px;
  583. text-align: right;
  584. }
  585. /* 加载指示器 */
  586. .typing-indicator {
  587. display: flex;
  588. align-items: center;
  589. padding: 12px 16px;
  590. background: white;
  591. border: 1px solid #e9ecef;
  592. border-radius: 18px;
  593. width: fit-content;
  594. margin-bottom: 15px;
  595. border-bottom-left-radius: 4px;
  596. }
  597. .typing-dot {
  598. width: 8px;
  599. height: 8px;
  600. background: #6c757d;
  601. border-radius: 50%;
  602. margin: 0 3px;
  603. animation: typing 1.4s infinite;
  604. }
  605. .typing-dot:nth-child(1) {
  606. animation-delay: 0s;
  607. }
  608. .typing-dot:nth-child(2) {
  609. animation-delay: 0.2s;
  610. }
  611. .typing-dot:nth-child(3) {
  612. animation-delay: 0.4s;
  613. }
  614. /* 输入区域 */
  615. .input-container {
  616. padding: 10px;
  617. box-sizing: border-box;
  618. background: #f8fafc;
  619. .input-box {
  620. background: #ffffff;
  621. padding: 5px 0;
  622. border: 1px solid var(--el-border-color);
  623. border-radius: 10px;
  624. }
  625. .input-tag {
  626. padding: 5px 10px;
  627. }
  628. :deep(.el-textarea__inner) {
  629. border: none;
  630. background: transparent;
  631. box-shadow: none;
  632. &:hover {
  633. box-shadow: none;
  634. }
  635. }
  636. }
  637. .input-button {
  638. margin-top: 10px;
  639. text-align: right;
  640. padding: 5px 10px;
  641. }
  642. /* 动画 */
  643. @keyframes fadeIn {
  644. from {
  645. opacity: 0;
  646. transform: translateY(10px);
  647. }
  648. to {
  649. opacity: 1;
  650. transform: translateY(0);
  651. }
  652. }
  653. @keyframes typing {
  654. 0%,
  655. 60%,
  656. 100% {
  657. transform: translateY(0);
  658. }
  659. 30% {
  660. transform: translateY(-5px);
  661. }
  662. }
  663. </style>