|
@@ -25,7 +25,28 @@
|
|
<i class="fas fa-redo"></i>
|
|
<i class="fas fa-redo"></i>
|
|
</button>
|
|
</button>
|
|
</div>
|
|
</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 class="message-time">{{ message.timestamp }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@@ -55,9 +76,21 @@
|
|
|
|
|
|
<script>
|
|
<script>
|
|
import axios from 'axios'
|
|
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 {
|
|
export default {
|
|
name: 'AIChatBox',
|
|
name: 'AIChatBox',
|
|
|
|
+ components: {
|
|
|
|
+ CodeBlock
|
|
|
|
+ },
|
|
data() {
|
|
data() {
|
|
return {
|
|
return {
|
|
userInput: '',
|
|
userInput: '',
|
|
@@ -82,6 +115,7 @@ export default {
|
|
controller: null, // 用于中止请求
|
|
controller: null, // 用于中止请求
|
|
typingSpeed: 20, // 打字机效果的速度(毫秒)
|
|
typingSpeed: 20, // 打字机效果的速度(毫秒)
|
|
scrollLock:true,
|
|
scrollLock:true,
|
|
|
|
+ md: null,
|
|
}
|
|
}
|
|
},
|
|
},
|
|
|
|
|
|
@@ -91,6 +125,49 @@ export default {
|
|
if (savedHistory) {
|
|
if (savedHistory) {
|
|
this.chatHistory = JSON.parse(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: {
|
|
watch: {
|
|
@@ -180,25 +257,32 @@ export default {
|
|
//获取回复信息
|
|
//获取回复信息
|
|
async getAIResponse(message) {
|
|
async getAIResponse(message) {
|
|
this.controller = new AbortController();
|
|
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 {
|
|
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',
|
|
method: 'POST',
|
|
headers: {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'text/event-stream',
|
|
'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
|
|
signal: this.controller.signal
|
|
});
|
|
});
|
|
-
|
|
|
|
|
|
+ console.log(response)
|
|
if (!response.ok) throw new Error('AI API 调用失败');
|
|
if (!response.ok) throw new Error('AI API 调用失败');
|
|
|
|
|
|
const reader = response.body.getReader();
|
|
const reader = response.body.getReader();
|
|
@@ -221,7 +305,10 @@ export default {
|
|
if (json.choices[0]?.delta?.content) {
|
|
if (json.choices[0]?.delta?.content) {
|
|
const newContent = json.choices[0].delta.content;
|
|
const newContent = json.choices[0].delta.content;
|
|
this.currentStreamingMessage.content += newContent;
|
|
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) {
|
|
} catch (e) {
|
|
console.error('解析响应数据出错:', 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 currentLength = message.displayContent.length;
|
|
const targetLength = message.content.length;
|
|
const targetLength = message.content.length;
|
|
|
|
|
|
@@ -336,7 +423,76 @@ export default {
|
|
this.chatHistory = [];
|
|
this.chatHistory = [];
|
|
localStorage.removeItem('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>
|
|
</script>
|
|
@@ -383,7 +539,8 @@ export default {
|
|
}
|
|
}
|
|
|
|
|
|
.user .message-content {
|
|
.user .message-content {
|
|
- background: #007bff;
|
|
|
|
|
|
+ /* background: #007bff; */
|
|
|
|
+ background: white;
|
|
color: white;
|
|
color: white;
|
|
}
|
|
}
|
|
|
|
|
|
@@ -533,4 +690,113 @@ button:hover {
|
|
.chat-messages::-webkit-scrollbar-thumb:hover {
|
|
.chat-messages::-webkit-scrollbar-thumb:hover {
|
|
background: #555;
|
|
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>
|
|
</style>
|