AIChatBox.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  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. <button class="action-btn" @click="copyMessage(message.content)" title="复制">
  13. <i class="fas fa-copy"></i>
  14. </button>
  15. <button v-if="message === currentStreamingMessage"
  16. class="action-btn"
  17. @click="stopGeneration"
  18. title="停止生成">
  19. <i class="fas fa-stop"></i>
  20. </button>
  21. <button class="action-btn"
  22. @click="regenerateResponse(index)"
  23. title="重新生成"
  24. :disabled="isLoading">
  25. <i class="fas fa-redo"></i>
  26. </button>
  27. </div>
  28. <div class="content-text">{{ message.isTyping ? message.displayContent : message.content }}</div>
  29. <div class="message-time">{{ message.timestamp }}</div>
  30. </div>
  31. </div>
  32. </div>
  33. <div class="input-area">
  34. <!-- <div class="typing-status" v-if="isLoading">AI正在思考中...</div> -->
  35. <select v-model="selectedQuestion" class="preset-questions">
  36. <option value="">选择预设问题...</option>
  37. <option v-for="(q, index) in presetQuestions"
  38. :key="index"
  39. :value="q.question">
  40. {{ q.question }}
  41. </option>
  42. </select>
  43. <div class="input-group">
  44. <input type="text"
  45. v-model="userInput"
  46. @keyup.enter="sendMessage"
  47. placeholder="输入您的问题...">
  48. <button @click="sendMessage" :disabled="isLoading">发送</button>
  49. </div>
  50. </div>
  51. </div>
  52. </template>
  53. <script>
  54. import axios from 'axios'
  55. export default {
  56. name: 'AIChatBox',
  57. data() {
  58. return {
  59. userInput: '',
  60. selectedQuestion: '',
  61. chatHistory: [],
  62. presetQuestions: [
  63. {
  64. question: "你好,请问你是谁?",
  65. },
  66. {
  67. question: "现在几点了?",
  68. },
  69. {
  70. question: "你能做什么?",
  71. },
  72. {
  73. question: "今天天气怎么样?",
  74. }
  75. ],
  76. isLoading: false,
  77. currentStreamingMessage: null,
  78. controller: null, // 用于中止请求
  79. typingSpeed: 20, // 打字机效果的速度(毫秒)
  80. scrollLock:true,
  81. }
  82. },
  83. created() {
  84. // 从 localStorage 加载历史记录
  85. const savedHistory = localStorage.getItem('chatHistory')
  86. if (savedHistory) {
  87. this.chatHistory = JSON.parse(savedHistory)
  88. }
  89. },
  90. watch: {
  91. chatHistory: {
  92. handler(newVal) {
  93. // 保存到 localStorage
  94. localStorage.setItem('chatHistory', JSON.stringify(newVal))
  95. },
  96. deep: true
  97. },
  98. selectedQuestion(val) {
  99. if (val) {
  100. this.userInput = val;
  101. }
  102. }
  103. },
  104. mounted() {
  105. // 添加滚动事件监听
  106. const messageBox = this.$refs.messageBox;
  107. if (messageBox) {
  108. messageBox.addEventListener('scroll', this.handleScroll);
  109. }
  110. // 初始滚动到底部
  111. this.scrollToBottom();
  112. },
  113. beforeDestroy() {
  114. // 移除滚动事件监听
  115. const messageBox = this.$refs.messageBox;
  116. if (messageBox) {
  117. messageBox.removeEventListener('scroll', this.handleScroll);
  118. }
  119. },
  120. updated() {
  121. // 当内容更新时滚动到底部
  122. this.scrollToBottom();
  123. },
  124. methods: {
  125. //发送信息
  126. async sendMessage() {
  127. if (!this.userInput.trim()) return;
  128. const userMessage = {
  129. type: 'user',
  130. content: this.userInput,
  131. timestamp: new Date().toLocaleTimeString()
  132. };
  133. this.chatHistory.push(userMessage);
  134. this.isLoading = true;
  135. const aiMessage = {
  136. type: 'ai',
  137. content: '',
  138. displayContent: '', // 用于打字机效果
  139. isTyping: true,
  140. timestamp: new Date().toLocaleTimeString()
  141. };
  142. this.chatHistory.push(aiMessage);
  143. this.currentStreamingMessage = aiMessage;
  144. try {
  145. await this.getAIResponse(this.userInput);
  146. } catch (error) {
  147. if (error.name === 'AbortError') {
  148. this.currentStreamingMessage.content += '\n[生成已停止]';
  149. } else {
  150. console.error('AI响应错误:', error);
  151. this.currentStreamingMessage.content = '抱歉,服务出现了问题,请稍后再试。';
  152. }
  153. } finally {
  154. this.isLoading = false;
  155. this.userInput = '';
  156. this.selectedQuestion = '';
  157. await this.finishTypingEffect(this.currentStreamingMessage);
  158. this.currentStreamingMessage = null;
  159. this.controller = null;
  160. this.$nextTick(() => {
  161. this.scrollToBottom();
  162. });
  163. }
  164. },
  165. //获取回复信息
  166. async getAIResponse(message) {
  167. this.controller = new AbortController();
  168. try {
  169. const response = await fetch('/api/AI/chat', {
  170. method: 'POST',
  171. headers: {
  172. 'Content-Type': 'application/json',
  173. 'Accept': 'text/event-stream',
  174. },
  175. body: JSON.stringify({
  176. messages: [{
  177. role: 'user',
  178. content: message
  179. }],
  180. // model: 'gpt-3.5-turbo',
  181. stream: true,
  182. }),
  183. signal: this.controller.signal
  184. });
  185. if (!response.ok) throw new Error('AI API 调用失败');
  186. const reader = response.body.getReader();
  187. const decoder = new TextDecoder('utf-8');
  188. while (true) {
  189. const { done, value } = await reader.read();
  190. if (done) break;
  191. const chunk = decoder.decode(value);
  192. const lines = chunk
  193. .split('\n')
  194. .filter(line => line.trim() !== '' && line.trim() !== 'data: [DONE]');
  195. for (const line of lines) {
  196. try {
  197. const jsonStr = line.replace(/^data: /, '');
  198. const json = JSON.parse(jsonStr);
  199. if (json.choices[0]?.delta?.content) {
  200. const newContent = json.choices[0].delta.content;
  201. this.currentStreamingMessage.content += newContent;
  202. await this.typeWriter(this.currentStreamingMessage, newContent);
  203. }
  204. } catch (e) {
  205. console.error('解析响应数据出错:', e);
  206. }
  207. }
  208. }
  209. } catch (error) {
  210. throw error;
  211. }
  212. },
  213. async typeWriter(message, newContent) {
  214. const currentLength = message.displayContent.length;
  215. const targetLength = message.content.length;
  216. if (currentLength < targetLength) {
  217. message.displayContent = message.content.substring(0, currentLength + newContent.length);
  218. this.$nextTick(() => {
  219. this.scrollToBottom();
  220. });
  221. }
  222. },
  223. async finishTypingEffect(message) {
  224. message.displayContent = message.content;
  225. message.isTyping = false;
  226. },
  227. //停止生成
  228. stopGeneration() {
  229. if (this.controller) {
  230. this.controller.abort();
  231. }
  232. },
  233. //重新生成
  234. async regenerateResponse(index) {
  235. const originalUserMessage = this.chatHistory[index - 1];
  236. if (!originalUserMessage || originalUserMessage.type !== 'user') return;
  237. // 删除当前回复
  238. this.chatHistory.splice(index, 1);
  239. // 重新发送请求
  240. const aiMessage = {
  241. type: 'ai',
  242. content: '',
  243. displayContent: '',
  244. isTyping: true,
  245. timestamp: new Date().toLocaleTimeString()
  246. };
  247. this.chatHistory.push(aiMessage);
  248. this.currentStreamingMessage = aiMessage;
  249. this.isLoading = true;
  250. try {
  251. await this.getAIResponse(originalUserMessage.content);
  252. } catch (error) {
  253. console.error('重新生成失败:', error);
  254. this.currentStreamingMessage.content = '重新生成失败,请稍后再试。';
  255. } finally {
  256. this.isLoading = false;
  257. await this.finishTypingEffect(this.currentStreamingMessage);
  258. this.currentStreamingMessage = null;
  259. }
  260. },
  261. //复制信息
  262. async copyMessage(content) {
  263. try {
  264. await navigator.clipboard.writeText(content);
  265. // 可以添加一个提示,表示复制成功
  266. this.$message?.success?.('复制成功') || alert('复制成功');
  267. } catch (err) {
  268. console.error('复制失败:', err);
  269. this.$message?.error?.('复制失败') || alert('复制失败');
  270. }
  271. },
  272. //滚动到最底端
  273. scrollToBottom() {
  274. if (!this.scrollLock) return;
  275. this.$nextTick(() => {
  276. const messageBox = this.$refs.messageBox;
  277. if (messageBox) {
  278. // 使用平滑滚动效果
  279. messageBox.scrollTo({
  280. top: messageBox.scrollHeight,
  281. behavior: 'instant'
  282. });
  283. }
  284. });
  285. },
  286. // 监听滚动事件
  287. handleScroll() {
  288. const messageBox = this.$refs.messageBox;
  289. if (!messageBox) return;
  290. // 计算是否在底部附近(允许 100px 的误差)
  291. const isNearBottom =
  292. messageBox.scrollHeight - messageBox.scrollTop - messageBox.clientHeight < 100;
  293. this.scrollLock = isNearBottom;
  294. },
  295. //清空历史
  296. clearHistory() {
  297. if (confirm('确定要清空聊天记录吗?')) {
  298. this.chatHistory = [];
  299. localStorage.removeItem('chatHistory');
  300. }
  301. }
  302. }
  303. }
  304. </script>
  305. <style scoped>
  306. .chat-container {
  307. width: 100%;
  308. max-width: 600px;
  309. margin: 0 auto;
  310. border: 1px solid #ddd;
  311. border-radius: 8px;
  312. overflow: hidden;
  313. }
  314. .chat-messages {
  315. height: 400px;
  316. overflow-y: auto;
  317. padding: 20px;
  318. background: #f5f5f5;
  319. scroll-behavior: smooth;
  320. }
  321. .message {
  322. margin-bottom: 15px;
  323. /* max-width: 80%; */
  324. position:relative;
  325. }
  326. .message.user {
  327. /* margin-left: auto; */
  328. display: flex;
  329. flex-direction: row-reverse;
  330. }
  331. .message-content {
  332. padding: 10px 15px;
  333. border-radius: 15px;
  334. display: inline-block;
  335. white-space: pre-wrap;
  336. word-break: break-word;
  337. position: relative;
  338. padding-right: 30px; /* 为按钮留出空间 */
  339. max-width: 80%;
  340. }
  341. .user .message-content {
  342. background: #007bff;
  343. color: white;
  344. }
  345. .ai .message-content {
  346. background: white;
  347. color: #333;
  348. }
  349. .input-area {
  350. padding: 15px;
  351. background: white;
  352. }
  353. .input-group {
  354. display: flex;
  355. margin-top: 10px;
  356. }
  357. input {
  358. flex: 1;
  359. padding: 8px;
  360. border: 1px solid #ddd;
  361. border-radius: 4px;
  362. margin-right: 10px;
  363. }
  364. button {
  365. padding: 8px 20px;
  366. background: #007bff;
  367. color: white;
  368. border: none;
  369. border-radius: 4px;
  370. cursor: pointer;
  371. }
  372. button:hover {
  373. background: #0056b3;
  374. }
  375. .preset-questions {
  376. width: 100%;
  377. padding: 8px;
  378. border: 1px solid #ddd;
  379. border-radius: 4px;
  380. }
  381. .typing-status {
  382. padding: 8px;
  383. color: #666;
  384. font-style: italic;
  385. }
  386. .message-time {
  387. font-size: 0.8em;
  388. color: #999;
  389. margin-top: 4px;
  390. }
  391. /* 添加一个清空历史记录的按钮样式 */
  392. .clear-history {
  393. position: absolute;
  394. top: 10px;
  395. right: 10px;
  396. padding: 5px 10px;
  397. background: #dc3545;
  398. color: white;
  399. border: none;
  400. border-radius: 4px;
  401. cursor: pointer;
  402. }
  403. .message-loading{
  404. position: absolute;
  405. left: -20px;
  406. top: 0px;
  407. width: 20px;
  408. height: 20px;
  409. }
  410. .message-actions {
  411. position: absolute;
  412. /* top: 5px; */
  413. top: calc(100% + 5px);
  414. /* right: 5px; */
  415. display: flex;
  416. gap: 5px;
  417. opacity: 1;
  418. transition: opacity 0.2s;
  419. }
  420. .message:hover .message-actions {
  421. /* opacity: 1; */
  422. }
  423. .action-btn {
  424. padding: 4px 8px;
  425. background: rgba(255, 255, 255, 0.9);
  426. border: 1px solid #ddd;
  427. border-radius: 4px;
  428. cursor: pointer;
  429. font-size: 12px;
  430. color: #666;
  431. transition: all 0.2s;
  432. }
  433. .action-btn:hover {
  434. background: #f0f0f0;
  435. color: #333;
  436. }
  437. .action-btn:disabled {
  438. opacity: 0.5;
  439. cursor: not-allowed;
  440. }
  441. .content-text {
  442. white-space: pre-wrap;
  443. word-break: break-word;
  444. }
  445. /* 添加闪烁的光标效果 */
  446. .message.ai .content-text[data-typing="true"]::after {
  447. content: '|';
  448. animation: blink 1s infinite;
  449. }
  450. @keyframes blink {
  451. 0%, 100% { opacity: 1; }
  452. 50% { opacity: 0; }
  453. }
  454. /* 自定义滚动条样式 */
  455. .chat-messages::-webkit-scrollbar {
  456. width: 8px;
  457. }
  458. .chat-messages::-webkit-scrollbar-track {
  459. background: #f1f1f1;
  460. border-radius: 4px;
  461. }
  462. .chat-messages::-webkit-scrollbar-thumb {
  463. background: #888;
  464. border-radius: 4px;
  465. }
  466. .chat-messages::-webkit-scrollbar-thumb:hover {
  467. background: #555;
  468. }
  469. </style>