AIChatBox.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
  1. <template>
  2. <div class="chat-container">
  3. <div class="chat-messages" ref="messageBox">
  4. <div v-for="(message, index) in chatHistory"
  5. :key="index"
  6. :class="['message', message.type]">
  7. <div class="message-content">
  8. <div v-if="isLoading && message === currentStreamingMessage && message.type === 'ai'" class="message-loading">
  9. <i class="el-icon-loading"></i>
  10. </div>
  11. <div v-if="message.type === 'ai'" class="message-actions">
  12. <template v-if="message === currentStreamingMessage">
  13. <el-button
  14. type="text"
  15. @click="stopGeneration"
  16. title="停止生成">
  17. 停止生成
  18. </el-button>
  19. </template>
  20. <template v-else>
  21. <el-button type="text" @click="copyMessage(message.content)" title="复制">
  22. <i class="el-icon-document-copy"></i>
  23. </el-button>
  24. <el-button type="text"
  25. @click="regenerateResponse(index)"
  26. title="重新生成"
  27. :disabled="isLoading">
  28. <i class="el-icon-refresh"></i>
  29. </el-button>
  30. </template>
  31. </div>
  32. <!-- <div class="content-text">{{ message.isTyping ? message.displayContent : message.content }}</div> -->
  33. <!-- <div class="content-text markdown-body"
  34. v-html="message.isTyping ?
  35. renderMarkdown(message.displayContent) :
  36. renderMarkdown(message.content)">
  37. </div> -->
  38. <!-- <div class="content-text markdown-body"
  39. v-html="message.isTyping ?
  40. renderContent(message.displayContent) :
  41. renderContent(message.content)">
  42. </div> -->
  43. <div class="content-text markdown-body">
  44. <template v-for="(block, blockIndex) in parseMessage(
  45. message.isTyping ? message.displayContent : message.content
  46. )" >
  47. <code-block :key="blockIndex" v-if="block.type === 'code'"
  48. :language="block.language"
  49. :code="block.content" />
  50. <div :key="blockIndex" v-else v-html="renderMarkdown(block.content)"></div>
  51. </template>
  52. </div>
  53. <div class="message-time">{{ message.timestamp }}</div>
  54. </div>
  55. </div>
  56. </div>
  57. <div class="input-area">
  58. <!-- <div class="typing-status" v-if="isLoading">AI正在思考中...</div> -->
  59. <select v-model="selectedQuestion" class="preset-questions">
  60. <option value="">选择预设问题...</option>
  61. <option v-for="(q, index) in presetQuestions"
  62. :key="index"
  63. :value="q.question">
  64. {{ q.question }}
  65. </option>
  66. </select>
  67. <div class="input-group">
  68. <input type="text"
  69. v-model="userInput"
  70. @keyup.enter="sendMessage"
  71. placeholder="输入您的问题...">
  72. <button @click="sendMessage" :disabled="isLoading">发送</button>
  73. </div>
  74. </div>
  75. </div>
  76. </template>
  77. <script>
  78. import axios from 'axios'
  79. import MarkdownIt from 'markdown-it'
  80. import hljs from 'highlight.js';
  81. import 'highlight.js/styles/github.css'; // 或其他主题样式
  82. import 'github-markdown-css/github-markdown.css';
  83. import DOMPurify from 'dompurify'; // 用
  84. import CodeBlock from './CodeBlock.vue';
  85. import { h, render } from 'vue';
  86. export default {
  87. name: 'AIChatBox',
  88. components: {
  89. CodeBlock
  90. },
  91. data() {
  92. return {
  93. userInput: '',
  94. selectedQuestion: '',
  95. chatHistory: [],
  96. presetQuestions: [
  97. {
  98. question: "你好,请问你是谁?",
  99. },
  100. {
  101. question: "现在几点了?",
  102. },
  103. {
  104. question: "你能做什么?",
  105. },
  106. {
  107. question: "今天天气怎么样?",
  108. }
  109. ],
  110. isLoading: false,
  111. currentStreamingMessage: null,
  112. controller: null, // 用于中止请求
  113. typingSpeed: 20, // 打字机效果的速度(毫秒)
  114. scrollLock:true,
  115. md: null,
  116. }
  117. },
  118. created() {
  119. // 从 localStorage 加载历史记录
  120. const savedHistory = localStorage.getItem('chatHistory')
  121. if (savedHistory) {
  122. this.chatHistory = JSON.parse(savedHistory)
  123. }
  124. // 初始化 markdown-it
  125. this.md = new MarkdownIt({
  126. // highlight: function(str, lang) {
  127. // if (lang && hljs.getLanguage(lang)) {
  128. // try {
  129. // // return `<div class="code-block">
  130. // // <div class="code-block-header">
  131. // // <span class="language-label">${lang}</span>
  132. // // <button class="copy-button" onclick="copyCode(this)">复制</button>
  133. // // </div>
  134. // // <pre><code class="hljs ${lang}">${hljs.highlight(str, { language: lang }).value}</code></pre>
  135. // // </div>`;
  136. // return hljs.highlight(str, { language: lang }).value;
  137. // } catch (__) {}
  138. // }
  139. // return `<pre><code class="hljs">${this.md.utils.escapeHtml(str)}</code></pre>`;
  140. // },
  141. highlight: (str, lang) => {
  142. // 返回一个占位符,后续会被替换为组件
  143. return `<code-block language="${lang}" code="${this.escapeHtml(str)}"></code-block>`;
  144. },
  145. html: true, // 允许 HTML 标签
  146. breaks: true,
  147. linkify: true,
  148. });
  149. // 添加复制功能到全局
  150. window.copyCode = async (button) => {
  151. const codeBlock = button.closest('.code-block');
  152. const code = codeBlock.querySelector('code').textContent;
  153. try {
  154. await navigator.clipboard.writeText(code);
  155. const originalText = button.textContent;
  156. button.textContent = '已复制';
  157. setTimeout(() => {
  158. button.textContent = originalText;
  159. }, 2000);
  160. } catch (err) {
  161. console.error('复制失败:', err);
  162. }
  163. };
  164. },
  165. watch: {
  166. chatHistory: {
  167. handler(newVal) {
  168. // 保存到 localStorage
  169. localStorage.setItem('chatHistory', JSON.stringify(newVal))
  170. },
  171. deep: true
  172. },
  173. selectedQuestion(val) {
  174. if (val) {
  175. this.userInput = val;
  176. }
  177. }
  178. },
  179. mounted() {
  180. // 添加滚动事件监听
  181. const messageBox = this.$refs.messageBox;
  182. if (messageBox) {
  183. messageBox.addEventListener('scroll', this.handleScroll);
  184. }
  185. // 初始滚动到底部
  186. this.scrollToBottom();
  187. },
  188. beforeDestroy() {
  189. // 移除滚动事件监听
  190. const messageBox = this.$refs.messageBox;
  191. if (messageBox) {
  192. messageBox.removeEventListener('scroll', this.handleScroll);
  193. }
  194. },
  195. updated() {
  196. // 当内容更新时滚动到底部
  197. this.scrollToBottom();
  198. },
  199. methods: {
  200. //发送信息
  201. async sendMessage() {
  202. if (!this.userInput.trim()) return;
  203. const userMessage = {
  204. type: 'user',
  205. content: this.userInput,
  206. timestamp: new Date().toLocaleTimeString()
  207. };
  208. this.chatHistory.push(userMessage);
  209. this.isLoading = true;
  210. const aiMessage = {
  211. type: 'ai',
  212. content: '',
  213. displayContent: '', // 用于打字机效果
  214. isTyping: true,
  215. timestamp: new Date().toLocaleTimeString()
  216. };
  217. this.chatHistory.push(aiMessage);
  218. this.currentStreamingMessage = aiMessage;
  219. try {
  220. await this.getAIResponse(this.userInput);
  221. } catch (error) {
  222. if (error.name === 'AbortError') {
  223. this.currentStreamingMessage.content += '\n[生成已停止]';
  224. } else {
  225. console.error('AI响应错误:', error);
  226. this.currentStreamingMessage.content = '抱歉,服务出现了问题,请稍后再试。';
  227. }
  228. } finally {
  229. this.isLoading = false;
  230. this.userInput = '';
  231. this.selectedQuestion = '';
  232. await this.finishTypingEffect(this.currentStreamingMessage);
  233. this.currentStreamingMessage = null;
  234. this.controller = null;
  235. this.$nextTick(() => {
  236. this.scrollToBottom();
  237. });
  238. }
  239. },
  240. //获取回复信息
  241. async getAIResponse(message) {
  242. this.controller = new AbortController();
  243. const formatCodeBlock = (text) => {
  244. return text.replace(/```(\w+)?\n([\s\S]+?)```/g, (match, lang, code) => {
  245. return `\n\`\`\`${lang || ''}\n${code.trim()}\n\`\`\`\n`;
  246. });
  247. };
  248. try {
  249. let data = {
  250. messages: [
  251. {
  252. role: 'user',
  253. content: message
  254. }
  255. ],
  256. // model: 'deepseek-r1:14b',
  257. stream: true,
  258. }
  259. const response = await fetch('/AI/api/AI/chat', {
  260. method: 'POST',
  261. headers: {
  262. 'Content-Type': 'application/json',
  263. 'Accept': 'text/event-stream',
  264. },
  265. body: JSON.stringify(data),
  266. signal: this.controller.signal
  267. });
  268. console.log(response)
  269. if (!response.ok) throw new Error('AI API 调用失败');
  270. const reader = response.body.getReader();
  271. const decoder = new TextDecoder('utf-8');
  272. while (true) {
  273. const { done, value } = await reader.read();
  274. if (done) break;
  275. const chunk = decoder.decode(value);
  276. const lines = chunk
  277. .split('\n')
  278. .filter(line => line.trim() !== '' && line.trim() !== 'data: [DONE]');
  279. for (const line of lines) {
  280. try {
  281. const jsonStr = line.replace(/^data: /, '');
  282. const json = JSON.parse(jsonStr);
  283. if (json.choices[0]?.delta?.content) {
  284. const newContent = json.choices[0].delta.content;
  285. this.currentStreamingMessage.content += newContent;
  286. // await this.typeWriter(this.currentStreamingMessage, newContent);
  287. // 格式化完整的内容
  288. const formattedContent = formatCodeBlock(this.currentStreamingMessage.content);
  289. await this.typeWriter(this.currentStreamingMessage, newContent, formattedContent);
  290. }
  291. } catch (e) {
  292. console.error('解析响应数据出错:', e);
  293. }
  294. }
  295. }
  296. } catch (error) {
  297. throw error;
  298. }
  299. },
  300. async typeWriter(message, newContent,formattedContent) {
  301. const currentLength = message.displayContent.length;
  302. const targetLength = message.content.length;
  303. if (currentLength < targetLength) {
  304. message.displayContent = message.content.substring(0, currentLength + newContent.length);
  305. this.$nextTick(() => {
  306. this.scrollToBottom();
  307. });
  308. }
  309. },
  310. async finishTypingEffect(message) {
  311. message.displayContent = message.content;
  312. message.isTyping = false;
  313. },
  314. //停止生成
  315. stopGeneration() {
  316. if (this.controller) {
  317. this.controller.abort();
  318. }
  319. },
  320. //重新生成
  321. async regenerateResponse(index) {
  322. const originalUserMessage = this.chatHistory[index - 1];
  323. if (!originalUserMessage || originalUserMessage.type !== 'user') return;
  324. // 删除当前回复
  325. this.chatHistory.splice(index, 1);
  326. // 重新发送请求
  327. const aiMessage = {
  328. type: 'ai',
  329. content: '',
  330. displayContent: '',
  331. isTyping: true,
  332. timestamp: new Date().toLocaleTimeString()
  333. };
  334. this.chatHistory.push(aiMessage);
  335. this.currentStreamingMessage = aiMessage;
  336. this.isLoading = true;
  337. try {
  338. await this.getAIResponse(originalUserMessage.content);
  339. } catch (error) {
  340. console.error('重新生成失败:', error);
  341. this.currentStreamingMessage.content = '重新生成失败,请稍后再试。';
  342. } finally {
  343. this.isLoading = false;
  344. await this.finishTypingEffect(this.currentStreamingMessage);
  345. this.currentStreamingMessage = null;
  346. }
  347. },
  348. //复制信息
  349. async copyMessage(content) {
  350. try {
  351. await navigator.clipboard.writeText(content);
  352. // 可以添加一个提示,表示复制成功
  353. this.$message?.success?.('复制成功') || alert('复制成功');
  354. } catch (err) {
  355. console.error('复制失败:', err);
  356. this.$message?.error?.('复制失败') || alert('复制失败');
  357. }
  358. },
  359. //滚动到最底端
  360. scrollToBottom() {
  361. if (!this.scrollLock) return;
  362. this.$nextTick(() => {
  363. const messageBox = this.$refs.messageBox;
  364. if (messageBox) {
  365. // 使用平滑滚动效果
  366. messageBox.scrollTo({
  367. top: messageBox.scrollHeight,
  368. behavior: 'instant'
  369. });
  370. }
  371. });
  372. },
  373. // 监听滚动事件
  374. handleScroll() {
  375. const messageBox = this.$refs.messageBox;
  376. if (!messageBox) return;
  377. // 计算是否在底部附近(允许 100px 的误差)
  378. const isNearBottom =
  379. messageBox.scrollHeight - messageBox.scrollTop - messageBox.clientHeight < 100;
  380. this.scrollLock = isNearBottom;
  381. },
  382. //清空历史
  383. clearHistory() {
  384. if (confirm('确定要清空聊天记录吗?')) {
  385. this.chatHistory = [];
  386. localStorage.removeItem('chatHistory');
  387. }
  388. },
  389. // renderMarkdown(text) {
  390. // try {
  391. // text = this.preprocessCodeBlocks(text);
  392. // const rendered = this.md.render(text);
  393. // return DOMPurify.sanitize(rendered);
  394. // } catch (e) {
  395. // console.error('Markdown rendering error:', e);
  396. // return text;
  397. // }
  398. // },
  399. parseMessage(text) {
  400. const blocks = [];
  401. let currentText = '';
  402. // 使用正则表达式匹配代码块
  403. const regex = /```(\w*)\n([\s\S]+?)```/g;
  404. let lastIndex = 0;
  405. let match;
  406. while ((match = regex.exec(text)) !== null) {
  407. // 添加代码块之前的文本
  408. if (match.index > lastIndex) {
  409. blocks.push({
  410. type: 'text',
  411. content: text.slice(lastIndex, match.index)
  412. });
  413. }
  414. // 添加代码块
  415. blocks.push({
  416. type: 'code',
  417. language: match[1] || '',
  418. content: match[2].trim()
  419. });
  420. lastIndex = regex.lastIndex;
  421. }
  422. // 添加剩余的文本
  423. if (lastIndex < text.length) {
  424. blocks.push({
  425. type: 'text',
  426. content: text.slice(lastIndex)
  427. });
  428. }
  429. return blocks;
  430. },
  431. renderMarkdown(text) {
  432. // 只渲染非代码块的文本
  433. return this.md.render(text);
  434. },
  435. preprocessCodeBlocks(text) {
  436. // 确保代码块格式正确
  437. return text.replace(/```(\w*)\n([\s\S]+?)```/g, (_, lang, code) => {
  438. return `\n\`\`\`${lang}\n${code.trim()}\n\`\`\`\n`;
  439. });
  440. },
  441. escapeHtml(text) {
  442. return text
  443. .replace(/&/g, '&amp;')
  444. .replace(/"/g, '&quot;')
  445. .replace(/'/g, '&#39;')
  446. .replace(/</g, '&lt;')
  447. .replace(/>/g, '&gt;');
  448. },
  449. }
  450. }
  451. </script>
  452. <style scoped>
  453. .chat-container {
  454. width: 100%;
  455. max-width: 600px;
  456. margin: 0 auto;
  457. border: 1px solid #ddd;
  458. border-radius: 8px;
  459. overflow: hidden;
  460. }
  461. .chat-messages {
  462. height: 400px;
  463. overflow-y: auto;
  464. padding: 20px;
  465. background: #f5f5f5;
  466. scroll-behavior: smooth;
  467. }
  468. .message {
  469. margin-bottom: 15px;
  470. /* max-width: 80%; */
  471. position:relative;
  472. }
  473. .message.user {
  474. /* margin-left: auto; */
  475. display: flex;
  476. flex-direction: row-reverse;
  477. }
  478. .message-content {
  479. padding: 10px 15px;
  480. border-radius: 15px;
  481. display: inline-block;
  482. white-space: pre-wrap;
  483. word-break: break-word;
  484. position: relative;
  485. padding-right: 30px; /* 为按钮留出空间 */
  486. max-width: 80%;
  487. }
  488. .user .message-content {
  489. /* background: #007bff; */
  490. background: white;
  491. color: white;
  492. }
  493. .ai .message-content {
  494. background: white;
  495. color: #333;
  496. }
  497. .input-area {
  498. padding: 15px;
  499. background: white;
  500. }
  501. .input-group {
  502. display: flex;
  503. margin-top: 10px;
  504. }
  505. input {
  506. flex: 1;
  507. padding: 8px;
  508. border: 1px solid #ddd;
  509. border-radius: 4px;
  510. margin-right: 10px;
  511. }
  512. /* button {
  513. padding: 8px 20px;
  514. background: #007bff;
  515. color: white;
  516. border: none;
  517. border-radius: 4px;
  518. cursor: pointer;
  519. }
  520. button:hover {
  521. background: #0056b3;
  522. } */
  523. .preset-questions {
  524. width: 100%;
  525. padding: 8px;
  526. border: 1px solid #ddd;
  527. border-radius: 4px;
  528. }
  529. .typing-status {
  530. padding: 8px;
  531. color: #666;
  532. font-style: italic;
  533. }
  534. .message-time {
  535. font-size: 0.8em;
  536. color: #999;
  537. margin-top: 4px;
  538. }
  539. /* 添加一个清空历史记录的按钮样式 */
  540. .clear-history {
  541. position: absolute;
  542. top: 10px;
  543. right: 10px;
  544. padding: 5px 10px;
  545. background: #dc3545;
  546. color: white;
  547. border: none;
  548. border-radius: 4px;
  549. cursor: pointer;
  550. }
  551. .message-loading{
  552. position: absolute;
  553. left: -20px;
  554. top: 0px;
  555. width: 20px;
  556. height: 20px;
  557. }
  558. .message-actions {
  559. position: absolute;
  560. /* top: 5px; */
  561. top: calc(100% + 5px);
  562. /* right: 5px; */
  563. display: flex;
  564. gap: 5px;
  565. opacity: 1;
  566. transition: opacity 0.2s;
  567. }
  568. .message:hover .message-actions {
  569. /* opacity: 1; */
  570. }
  571. .action-btn {
  572. padding: 4px 8px;
  573. background: rgba(255, 255, 255, 0.9);
  574. border: 1px solid #ddd;
  575. border-radius: 4px;
  576. cursor: pointer;
  577. font-size: 12px;
  578. color: #666;
  579. transition: all 0.2s;
  580. }
  581. .action-btn:hover {
  582. background: #f0f0f0;
  583. color: #333;
  584. }
  585. .action-btn:disabled {
  586. opacity: 0.5;
  587. cursor: not-allowed;
  588. }
  589. .content-text {
  590. white-space: pre-wrap;
  591. word-break: break-word;
  592. }
  593. /* 添加闪烁的光标效果 */
  594. .message.ai .content-text[data-typing="true"]::after {
  595. content: '|';
  596. animation: blink 1s infinite;
  597. }
  598. @keyframes blink {
  599. 0%, 100% { opacity: 1; }
  600. 50% { opacity: 0; }
  601. }
  602. /* 自定义滚动条样式 */
  603. .chat-messages::-webkit-scrollbar {
  604. width: 8px;
  605. }
  606. .chat-messages::-webkit-scrollbar-track {
  607. background: #f1f1f1;
  608. border-radius: 4px;
  609. }
  610. .chat-messages::-webkit-scrollbar-thumb {
  611. background: #888;
  612. border-radius: 4px;
  613. }
  614. .chat-messages::-webkit-scrollbar-thumb:hover {
  615. background: #555;
  616. }
  617. /* 导入 GitHub Markdown 样式 */
  618. /* @import 'github-markdown-css'; */
  619. /* 自定义 Markdown 样式 */
  620. .markdown-body {
  621. font-size: 14px;
  622. line-height: 1.6;
  623. }
  624. .markdown-body pre {
  625. background-color: #f6f8fa;
  626. border-radius: 6px;
  627. padding: 16px;
  628. margin: 8px 0;
  629. }
  630. .markdown-body code {
  631. font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
  632. font-size: 12px;
  633. padding: 2px 4px;
  634. background-color: rgba(27,31,35,0.05);
  635. border-radius: 3px;
  636. }
  637. .markdown-body pre code {
  638. padding: 0;
  639. background-color: transparent;
  640. }
  641. /* 代码块复制按钮 */
  642. .code-block-header {
  643. display: flex;
  644. justify-content: space-between;
  645. align-items: center;
  646. background: #f1f1f1;
  647. padding: 4px 8px;
  648. border-radius: 6px 6px 0 0;
  649. font-size: 12px;
  650. }
  651. .copy-button {
  652. padding: 2px 6px;
  653. font-size: 12px;
  654. color: #666;
  655. background: transparent;
  656. border: 1px solid #ddd;
  657. border-radius: 3px;
  658. cursor: pointer;
  659. }
  660. .copy-button:hover {
  661. background: #e9e9e9;
  662. }
  663. /* .code-block {
  664. margin: 1em 0;
  665. border-radius: 6px;
  666. overflow: hidden;
  667. background: #f6f8fa;
  668. }
  669. .code-block-header {
  670. display: flex;
  671. justify-content: space-between;
  672. align-items: center;
  673. padding: 8px 16px;
  674. background: #f1f1f1;
  675. border-bottom: 1px solid #ddd;
  676. }
  677. .language-label {
  678. font-size: 12px;
  679. color: #666;
  680. text-transform: uppercase;
  681. }
  682. .copy-button {
  683. padding: 4px 8px;
  684. font-size: 12px;
  685. color: #666;
  686. background: transparent;
  687. border: 1px solid #ddd;
  688. border-radius: 3px;
  689. cursor: pointer;
  690. transition: all 0.2s;
  691. }
  692. .copy-button:hover {
  693. background: #e9e9e9;
  694. }
  695. .code-block pre {
  696. margin: 0;
  697. padding: 16px;
  698. }
  699. .code-block code {
  700. font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
  701. font-size: 13px;
  702. line-height: 1.45;
  703. }
  704. .message .code-block {
  705. max-width: 100%;
  706. box-sizing: border-box;
  707. } */
  708. </style>