zhuliu пре 7 месеци
родитељ
комит
5d30396f09
2 измењених фајлова са 468 додато и 16 уклоњено
  1. 282 16
      src/utils/model/AIChat/AIChatBox.vue
  2. 186 0
      src/utils/model/AIChat/CodeBlock.vue

+ 282 - 16
src/utils/model/AIChat/AIChatBox.vue

@@ -25,7 +25,28 @@
               <i class="fas fa-redo"></i>
             </button>
           </div>
-          <div class="content-text">{{ message.isTyping ? message.displayContent : message.content }}</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>
@@ -55,9 +76,21 @@
 
 <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: '',
@@ -82,6 +115,7 @@ export default {
       controller: null, // 用于中止请求
       typingSpeed: 20, // 打字机效果的速度(毫秒)
       scrollLock:true,
+      md: null,
     }
   },
   
@@ -91,6 +125,49 @@ export default {
     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: {
@@ -180,25 +257,32 @@ export default {
     //获取回复信息
     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 {
-        const response = await fetch('/api/AI/chat', {
+        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({
-            messages: [{
-              role: 'user',
-              content: message
-            }],
-            // model: 'gpt-3.5-turbo',
-            stream: true,
-          }),
+          body: JSON.stringify(data),
           signal: this.controller.signal
         });
-
+        console.log(response)
         if (!response.ok) throw new Error('AI API 调用失败');
 
         const reader = response.body.getReader();
@@ -221,7 +305,10 @@ export default {
               if (json.choices[0]?.delta?.content) {
                 const newContent = json.choices[0].delta.content;
                 this.currentStreamingMessage.content += newContent;
-                await this.typeWriter(this.currentStreamingMessage, 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);
@@ -233,7 +320,7 @@ export default {
       }
     },
     
-    async typeWriter(message, newContent) {
+    async typeWriter(message, newContent,formattedContent) {
       const currentLength = message.displayContent.length;
       const targetLength = message.content.length;
       
@@ -336,7 +423,76 @@ export default {
         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, '&amp;')
+        .replace(/"/g, '&quot;')
+        .replace(/'/g, '&#39;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;');
+    },
   }
 }
 </script>
@@ -383,7 +539,8 @@ export default {
 }
 
 .user .message-content {
-  background: #007bff;
+  /* background: #007bff; */
+  background: white;
   color: white;
 }
 
@@ -533,4 +690,113 @@ button:hover {
 .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> 

+ 186 - 0
src/utils/model/AIChat/CodeBlock.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="code-block">
+    <div class="code-block-header">
+      <span class="language-label">{{ displayLanguage }}</span>
+      <button class="copy-button" @click="copyCode">
+        {{ copied ? '已复制' : '复制' }}
+      </button>
+    </div>
+    <pre><code :class="['hljs', language]" v-html="highlightedCode" ref="codeEl"></code></pre>
+  </div>
+</template>
+
+<script>
+import hljs from 'highlight.js';
+
+export default {
+  name: 'CodeBlock',
+  props: {
+    code: {
+      type: String,
+      required: true,
+      default: ''
+    },
+    language: {
+      type: String,
+      default: ''
+    }
+  },
+  
+  data() {
+    return {
+      copied: false
+    }
+  },
+
+  computed: {
+    displayLanguage() {
+      return this.language || '文本';
+    },
+
+    highlightedCode() {
+      if (this.language && hljs.getLanguage(this.language)) {
+        try {
+          return hljs.highlight(this.code, { language: this.language }).value;
+        } catch (e) {
+          console.error('代码高亮失败:', e);
+        }
+      }
+      // 如果没有指定语言或高亮失败,返回转义后的代码
+      return hljs.highlightAuto(this.code).value;
+    }
+  },
+
+  methods: {
+    async copyCode() {
+      try {
+        // await navigator.clipboard.writeText(this.code);
+        await this.copyToClipboard(this.code);
+        this.copied = true;
+        setTimeout(() => {
+          this.copied = false;
+        }, 2000);
+      } catch (err) {
+        console.error('复制失败:', err);
+        this.fallbackCopy();
+      }
+    },
+    async copyToClipboard(text) {
+      if (navigator.clipboard && window.isSecureContext) {
+        // 使用现代 Clipboard API
+        return navigator.clipboard.writeText(text);
+      } else {
+        // 使用传统方法
+        return this.fallbackCopy();
+      }
+    },
+
+    fallbackCopy() {
+      try {
+        // 创建临时文本区域
+        const textArea = document.createElement('textarea');
+        textArea.value = this.code;
+        
+        // 确保文本区域不可见
+        textArea.style.position = 'fixed';
+        textArea.style.left = '-999999px';
+        textArea.style.top = '-999999px';
+        document.body.appendChild(textArea);
+        
+        // 选择并复制文本
+        textArea.select();
+        document.execCommand('copy');
+        
+        // 清理
+        document.body.removeChild(textArea);
+        
+        // 更新状态
+        this.copied = true;
+        setTimeout(() => {
+          this.copied = false;
+        }, 2000);
+
+        return Promise.resolve();
+      } catch (err) {
+        return Promise.reject(err);
+      }
+    },
+
+    // 解码 HTML 实体
+    decodeHtml(html) {
+      const txt = document.createElement('textarea');
+      txt.innerHTML = html;
+      return txt.value;
+    }
+  },
+
+  created() {
+    // 解码传入的代码(如果包含 HTML 实体)
+    this.code = this.decodeHtml(this.code);
+  }
+}
+</script>
+
+<style scoped>
+.code-block {
+  margin: 1em 0;
+  border-radius: 6px;
+  overflow: hidden;
+  background: #f6f8fa;
+  border: 1px solid #e1e4e8;
+}
+
+.code-block-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 16px;
+  background: #f1f1f1;
+  border-bottom: 1px solid #e1e4e8;
+}
+
+.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;
+  position: relative;
+}
+
+.copy-button:hover {
+  background: #e9e9e9;
+}
+.copy-button.copied {
+  background: #28a745;
+  color: white;
+  border-color: #28a745;
+}
+
+.copy-button:disabled {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+
+.code-block pre {
+  margin: 0;
+  padding: 16px;
+  overflow-x: auto;
+}
+
+.code-block code {
+  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+  font-size: 13px;
+  line-height: 1.45;
+  display: block;
+}
+</style>