123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807 |
- <template>
- <div class="chat-container">
- <div class="chat-messages" ref="messageBox">
- <div v-for="(message, index) in chatHistory"
- :key="index"
- :class="['message', message.type]">
- <div class="message-content">
- <div v-if="isLoading && message === currentStreamingMessage && message.type === 'ai'" class="message-loading">
- <i class="el-icon-loading"></i>
- </div>
- <div v-if="message.type === 'ai'" class="message-actions">
- <template v-if="message === currentStreamingMessage">
- <el-button
- type="text"
- @click="stopGeneration"
- title="停止生成">
- 停止生成
- </el-button>
- </template>
- <template v-else>
- <el-button type="text" @click="copyMessage(message.content)" title="复制">
- <i class="el-icon-document-copy"></i>
- </el-button>
- <el-button type="text"
- @click="regenerateResponse(index)"
- title="重新生成"
- :disabled="isLoading">
- <i class="el-icon-refresh"></i>
- </el-button>
- </template>
-
- </div>
- <!-- <div class="content-text">{{ message.isTyping ? message.displayContent : message.content }}</div> -->
- <!-- <div class="content-text markdown-body"
- v-html="message.isTyping ?
- renderMarkdown(message.displayContent) :
- renderMarkdown(message.content)">
-
- </div> -->
- <!-- <div class="content-text markdown-body"
- v-html="message.isTyping ?
- renderContent(message.displayContent) :
- renderContent(message.content)">
- </div> -->
- <div class="content-text markdown-body">
- <template v-for="(block, blockIndex) in parseMessage(
- message.isTyping ? message.displayContent : message.content
- )" >
- <code-block :key="blockIndex" v-if="block.type === 'code'"
- :language="block.language"
- :code="block.content" />
- <div :key="blockIndex" v-else v-html="renderMarkdown(block.content)"></div>
- </template>
- </div>
- <div class="message-time">{{ message.timestamp }}</div>
- </div>
- </div>
- </div>
-
- <div class="input-area">
- <!-- <div class="typing-status" v-if="isLoading">AI正在思考中...</div> -->
- <select v-model="selectedQuestion" class="preset-questions">
- <option value="">选择预设问题...</option>
- <option v-for="(q, index) in presetQuestions"
- :key="index"
- :value="q.question">
- {{ q.question }}
- </option>
- </select>
-
- <div class="input-group">
- <input type="text"
- v-model="userInput"
- @keyup.enter="sendMessage"
- placeholder="输入您的问题...">
- <button @click="sendMessage" :disabled="isLoading">发送</button>
- </div>
- </div>
- </div>
- </template>
- <script>
- import axios from 'axios'
- import MarkdownIt from 'markdown-it'
- import hljs from 'highlight.js';
- import 'highlight.js/styles/github.css'; // 或其他主题样式
- import 'github-markdown-css/github-markdown.css';
- import DOMPurify from 'dompurify'; // 用
- import CodeBlock from './CodeBlock.vue';
- import { h, render } from 'vue';
- export default {
- name: 'AIChatBox',
- components: {
- CodeBlock
- },
- data() {
- return {
- userInput: '',
- selectedQuestion: '',
- chatHistory: [],
- presetQuestions: [
- {
- question: "你好,请问你是谁?",
- },
- {
- question: "现在几点了?",
- },
- {
- question: "你能做什么?",
- },
- {
- question: "今天天气怎么样?",
- }
- ],
- isLoading: false,
- currentStreamingMessage: null,
- controller: null, // 用于中止请求
- typingSpeed: 20, // 打字机效果的速度(毫秒)
- scrollLock:true,
- md: null,
- }
- },
-
- created() {
- // 从 localStorage 加载历史记录
- const savedHistory = localStorage.getItem('chatHistory')
- if (savedHistory) {
- this.chatHistory = JSON.parse(savedHistory)
- }
- // 初始化 markdown-it
- this.md = new MarkdownIt({
- // highlight: function(str, lang) {
- // if (lang && hljs.getLanguage(lang)) {
- // try {
- // // return `<div class="code-block">
- // // <div class="code-block-header">
- // // <span class="language-label">${lang}</span>
- // // <button class="copy-button" onclick="copyCode(this)">复制</button>
- // // </div>
- // // <pre><code class="hljs ${lang}">${hljs.highlight(str, { language: lang }).value}</code></pre>
- // // </div>`;
- // return hljs.highlight(str, { language: lang }).value;
- // } catch (__) {}
- // }
- // return `<pre><code class="hljs">${this.md.utils.escapeHtml(str)}</code></pre>`;
- // },
- highlight: (str, lang) => {
- // 返回一个占位符,后续会被替换为组件
- return `<code-block language="${lang}" code="${this.escapeHtml(str)}"></code-block>`;
- },
- html: true, // 允许 HTML 标签
- breaks: true,
- linkify: true,
- });
- // 添加复制功能到全局
- window.copyCode = async (button) => {
- const codeBlock = button.closest('.code-block');
- const code = codeBlock.querySelector('code').textContent;
-
- try {
- await navigator.clipboard.writeText(code);
- const originalText = button.textContent;
- button.textContent = '已复制';
- setTimeout(() => {
- button.textContent = originalText;
- }, 2000);
- } catch (err) {
- console.error('复制失败:', err);
- }
- };
- },
-
- watch: {
- chatHistory: {
- handler(newVal) {
- // 保存到 localStorage
- localStorage.setItem('chatHistory', JSON.stringify(newVal))
- },
- deep: true
- },
- selectedQuestion(val) {
- if (val) {
- this.userInput = val;
- }
- }
- },
- mounted() {
- // 添加滚动事件监听
- const messageBox = this.$refs.messageBox;
- if (messageBox) {
- messageBox.addEventListener('scroll', this.handleScroll);
- }
-
- // 初始滚动到底部
- this.scrollToBottom();
- },
- beforeDestroy() {
- // 移除滚动事件监听
- const messageBox = this.$refs.messageBox;
- if (messageBox) {
- messageBox.removeEventListener('scroll', this.handleScroll);
- }
- },
- updated() {
- // 当内容更新时滚动到底部
- this.scrollToBottom();
- },
-
- methods: {
- //发送信息
- async sendMessage() {
- if (!this.userInput.trim()) return;
-
- const userMessage = {
- type: 'user',
- content: this.userInput,
- timestamp: new Date().toLocaleTimeString()
- };
-
- this.chatHistory.push(userMessage);
- this.isLoading = true;
-
- const aiMessage = {
- type: 'ai',
- content: '',
- displayContent: '', // 用于打字机效果
- isTyping: true,
- timestamp: new Date().toLocaleTimeString()
- };
-
- this.chatHistory.push(aiMessage);
- this.currentStreamingMessage = aiMessage;
-
- try {
- await this.getAIResponse(this.userInput);
- } catch (error) {
- if (error.name === 'AbortError') {
- this.currentStreamingMessage.content += '\n[生成已停止]';
- } else {
- console.error('AI响应错误:', error);
- this.currentStreamingMessage.content = '抱歉,服务出现了问题,请稍后再试。';
- }
- } finally {
- this.isLoading = false;
- this.userInput = '';
- this.selectedQuestion = '';
- await this.finishTypingEffect(this.currentStreamingMessage);
- this.currentStreamingMessage = null;
- this.controller = null;
- this.$nextTick(() => {
- this.scrollToBottom();
- });
- }
- },
- //获取回复信息
- async getAIResponse(message) {
- this.controller = new AbortController();
- const formatCodeBlock = (text) => {
- return text.replace(/```(\w+)?\n([\s\S]+?)```/g, (match, lang, code) => {
- return `\n\`\`\`${lang || ''}\n${code.trim()}\n\`\`\`\n`;
- });
- };
- try {
- let data = {
- messages: [
- {
- role: 'user',
- content: message
- }
- ],
- // model: 'deepseek-r1:14b',
- stream: true,
- }
- const response = await fetch('/AI/api/AI/chat', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Accept': 'text/event-stream',
- },
- body: JSON.stringify(data),
- signal: this.controller.signal
- });
- console.log(response)
- if (!response.ok) throw new Error('AI API 调用失败');
- const reader = response.body.getReader();
- const decoder = new TextDecoder('utf-8');
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- const chunk = decoder.decode(value);
- const lines = chunk
- .split('\n')
- .filter(line => line.trim() !== '' && line.trim() !== 'data: [DONE]');
- for (const line of lines) {
- try {
- const jsonStr = line.replace(/^data: /, '');
- const json = JSON.parse(jsonStr);
-
- if (json.choices[0]?.delta?.content) {
- const newContent = json.choices[0].delta.content;
- this.currentStreamingMessage.content += newContent;
- // await this.typeWriter(this.currentStreamingMessage, newContent);
- // 格式化完整的内容
- const formattedContent = formatCodeBlock(this.currentStreamingMessage.content);
- await this.typeWriter(this.currentStreamingMessage, newContent, formattedContent);
- }
- } catch (e) {
- console.error('解析响应数据出错:', e);
- }
- }
- }
- } catch (error) {
- throw error;
- }
- },
-
- async typeWriter(message, newContent,formattedContent) {
- const currentLength = message.displayContent.length;
- const targetLength = message.content.length;
-
- if (currentLength < targetLength) {
- message.displayContent = message.content.substring(0, currentLength + newContent.length);
- this.$nextTick(() => {
- this.scrollToBottom();
- });
- }
- },
- async finishTypingEffect(message) {
- message.displayContent = message.content;
- message.isTyping = false;
- },
- //停止生成
- stopGeneration() {
- if (this.controller) {
- this.controller.abort();
- }
- },
- //重新生成
- async regenerateResponse(index) {
- const originalUserMessage = this.chatHistory[index - 1];
- if (!originalUserMessage || originalUserMessage.type !== 'user') return;
-
- // 删除当前回复
- this.chatHistory.splice(index, 1);
-
- // 重新发送请求
- const aiMessage = {
- type: 'ai',
- content: '',
- displayContent: '',
- isTyping: true,
- timestamp: new Date().toLocaleTimeString()
- };
-
- this.chatHistory.push(aiMessage);
- this.currentStreamingMessage = aiMessage;
- this.isLoading = true;
-
- try {
- await this.getAIResponse(originalUserMessage.content);
- } catch (error) {
- console.error('重新生成失败:', error);
- this.currentStreamingMessage.content = '重新生成失败,请稍后再试。';
- } finally {
- this.isLoading = false;
- await this.finishTypingEffect(this.currentStreamingMessage);
- this.currentStreamingMessage = null;
- }
- },
- //复制信息
- async copyMessage(content) {
- try {
- await navigator.clipboard.writeText(content);
- // 可以添加一个提示,表示复制成功
- this.$message?.success?.('复制成功') || alert('复制成功');
- } catch (err) {
- console.error('复制失败:', err);
- this.$message?.error?.('复制失败') || alert('复制失败');
- }
- },
- //滚动到最底端
- scrollToBottom() {
- if (!this.scrollLock) return;
-
- this.$nextTick(() => {
- const messageBox = this.$refs.messageBox;
- if (messageBox) {
- // 使用平滑滚动效果
- messageBox.scrollTo({
- top: messageBox.scrollHeight,
- behavior: 'instant'
- });
- }
- });
- },
- // 监听滚动事件
- handleScroll() {
- const messageBox = this.$refs.messageBox;
- if (!messageBox) return;
- // 计算是否在底部附近(允许 100px 的误差)
- const isNearBottom =
- messageBox.scrollHeight - messageBox.scrollTop - messageBox.clientHeight < 100;
-
- this.scrollLock = isNearBottom;
- },
- //清空历史
- clearHistory() {
- if (confirm('确定要清空聊天记录吗?')) {
- this.chatHistory = [];
- localStorage.removeItem('chatHistory');
- }
- },
- // renderMarkdown(text) {
- // try {
- // text = this.preprocessCodeBlocks(text);
- // const rendered = this.md.render(text);
- // return DOMPurify.sanitize(rendered);
- // } catch (e) {
- // console.error('Markdown rendering error:', e);
- // return text;
- // }
- // },
- parseMessage(text) {
- const blocks = [];
- let currentText = '';
-
- // 使用正则表达式匹配代码块
- const regex = /```(\w*)\n([\s\S]+?)```/g;
- let lastIndex = 0;
- let match;
-
- while ((match = regex.exec(text)) !== null) {
- // 添加代码块之前的文本
- if (match.index > lastIndex) {
- blocks.push({
- type: 'text',
- content: text.slice(lastIndex, match.index)
- });
- }
-
- // 添加代码块
- blocks.push({
- type: 'code',
- language: match[1] || '',
- content: match[2].trim()
- });
-
- lastIndex = regex.lastIndex;
- }
-
- // 添加剩余的文本
- if (lastIndex < text.length) {
- blocks.push({
- type: 'text',
- content: text.slice(lastIndex)
- });
- }
-
- return blocks;
- },
- renderMarkdown(text) {
- // 只渲染非代码块的文本
- return this.md.render(text);
- },
-
- preprocessCodeBlocks(text) {
- // 确保代码块格式正确
- return text.replace(/```(\w*)\n([\s\S]+?)```/g, (_, lang, code) => {
- return `\n\`\`\`${lang}\n${code.trim()}\n\`\`\`\n`;
- });
- },
- escapeHtml(text) {
- return text
- .replace(/&/g, '&')
- .replace(/"/g, '"')
- .replace(/'/g, ''')
- .replace(/</g, '<')
- .replace(/>/g, '>');
- },
- }
- }
- </script>
- <style scoped>
- .chat-container {
- width: 100%;
- max-width: 600px;
- margin: 0 auto;
- border: 1px solid #ddd;
- border-radius: 8px;
- overflow: hidden;
- }
- .chat-messages {
- height: 400px;
- overflow-y: auto;
- padding: 20px;
- background: #f5f5f5;
- scroll-behavior: smooth;
- }
- .message {
- margin-bottom: 15px;
- /* max-width: 80%; */
- position:relative;
- }
- .message.user {
- /* margin-left: auto; */
- display: flex;
- flex-direction: row-reverse;
- }
- .message-content {
- padding: 10px 15px;
- border-radius: 15px;
- display: inline-block;
- white-space: pre-wrap;
- word-break: break-word;
- position: relative;
- padding-right: 30px; /* 为按钮留出空间 */
- max-width: 80%;
- }
- .user .message-content {
- /* background: #007bff; */
- background: white;
- color: white;
- }
- .ai .message-content {
- background: white;
- color: #333;
- }
- .input-area {
- padding: 15px;
- background: white;
- }
- .input-group {
- display: flex;
- margin-top: 10px;
- }
- input {
- flex: 1;
- padding: 8px;
- border: 1px solid #ddd;
- border-radius: 4px;
- margin-right: 10px;
- }
- /* button {
- padding: 8px 20px;
- background: #007bff;
- color: white;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- }
- button:hover {
- background: #0056b3;
- } */
- .preset-questions {
- width: 100%;
- padding: 8px;
- border: 1px solid #ddd;
- border-radius: 4px;
- }
- .typing-status {
- padding: 8px;
- color: #666;
- font-style: italic;
- }
- .message-time {
- font-size: 0.8em;
- color: #999;
- margin-top: 4px;
- }
- /* 添加一个清空历史记录的按钮样式 */
- .clear-history {
- position: absolute;
- top: 10px;
- right: 10px;
- padding: 5px 10px;
- background: #dc3545;
- color: white;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- }
- .message-loading{
- position: absolute;
- left: -20px;
- top: 0px;
- width: 20px;
- height: 20px;
- }
- .message-actions {
- position: absolute;
- /* top: 5px; */
- top: calc(100% + 5px);
- /* right: 5px; */
- display: flex;
- gap: 5px;
- opacity: 1;
- transition: opacity 0.2s;
- }
- .message:hover .message-actions {
- /* opacity: 1; */
- }
- .action-btn {
- padding: 4px 8px;
- background: rgba(255, 255, 255, 0.9);
- border: 1px solid #ddd;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- color: #666;
- transition: all 0.2s;
- }
- .action-btn:hover {
- background: #f0f0f0;
- color: #333;
- }
- .action-btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- .content-text {
- white-space: pre-wrap;
- word-break: break-word;
- }
- /* 添加闪烁的光标效果 */
- .message.ai .content-text[data-typing="true"]::after {
- content: '|';
- animation: blink 1s infinite;
- }
- @keyframes blink {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0; }
- }
- /* 自定义滚动条样式 */
- .chat-messages::-webkit-scrollbar {
- width: 8px;
- }
- .chat-messages::-webkit-scrollbar-track {
- background: #f1f1f1;
- border-radius: 4px;
- }
- .chat-messages::-webkit-scrollbar-thumb {
- background: #888;
- border-radius: 4px;
- }
- .chat-messages::-webkit-scrollbar-thumb:hover {
- background: #555;
- }
- /* 导入 GitHub Markdown 样式 */
- /* @import 'github-markdown-css'; */
- /* 自定义 Markdown 样式 */
- .markdown-body {
- font-size: 14px;
- line-height: 1.6;
- }
- .markdown-body pre {
- background-color: #f6f8fa;
- border-radius: 6px;
- padding: 16px;
- margin: 8px 0;
- }
- .markdown-body code {
- font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
- font-size: 12px;
- padding: 2px 4px;
- background-color: rgba(27,31,35,0.05);
- border-radius: 3px;
- }
- .markdown-body pre code {
- padding: 0;
- background-color: transparent;
- }
- /* 代码块复制按钮 */
- .code-block-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- background: #f1f1f1;
- padding: 4px 8px;
- border-radius: 6px 6px 0 0;
- font-size: 12px;
- }
- .copy-button {
- padding: 2px 6px;
- font-size: 12px;
- color: #666;
- background: transparent;
- border: 1px solid #ddd;
- border-radius: 3px;
- cursor: pointer;
- }
- .copy-button:hover {
- background: #e9e9e9;
- }
- /* .code-block {
- margin: 1em 0;
- border-radius: 6px;
- overflow: hidden;
- background: #f6f8fa;
- }
- .code-block-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 8px 16px;
- background: #f1f1f1;
- border-bottom: 1px solid #ddd;
- }
- .language-label {
- font-size: 12px;
- color: #666;
- text-transform: uppercase;
- }
- .copy-button {
- padding: 4px 8px;
- font-size: 12px;
- color: #666;
- background: transparent;
- border: 1px solid #ddd;
- border-radius: 3px;
- cursor: pointer;
- transition: all 0.2s;
- }
- .copy-button:hover {
- background: #e9e9e9;
- }
- .code-block pre {
- margin: 0;
- padding: 16px;
- }
- .code-block code {
- font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
- font-size: 13px;
- line-height: 1.45;
- }
- .message .code-block {
- max-width: 100%;
- box-sizing: border-box;
- } */
- </style>
|