xinsi_book/src/components/AIChatModuleVue2/AIChat.vue
caoyuchun b3a47589d8 cyc
2025-10-23 10:39:42 +08:00

790 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="ai-chat-container">
<div class="chat-messages" ref="messagesContainer">
<div v-for="(message, index) in messages" :key="index" :class="['message', message.role]">
<div class="avatar">
<div v-if="message.role === 'user'" class="user-avatar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div v-else class="ai-avatar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
<circle cx="17" cy="7" r="5" />
</svg>
</div>
</div>
<div class="message-content">
<div class="message-text" v-html="message.content"></div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
</div>
<div v-if="isLoading" class="message assistant">
<div class="avatar">
<div class="ai-avatar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
<circle cx="17" cy="7" r="5" />
</svg>
</div>
</div>
<div class="message-content">
<div class="message-text typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
<div v-if="messages.length === 0" class="welcome-message">
<div class="welcome-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12" />
<circle cx="17" cy="7" r="5" />
</svg>
</div>
<h3 v-html="formatMessage('你好我是AI助手')"></h3>
<p v-html="formatMessage('有什么我可以帮你的吗?')"></p>
</div>
</div>
<div class="chat-input">
<div class="input-container">
<input v-model="inputMessage" @keyup.enter="sendMessage" :disabled="isLoading" placeholder="输入消息..."
class="message-input" />
<button @click="sendMessage" :disabled="isLoading || !inputMessage.trim()" class="send-button"
:class="{ 'loading': isLoading }">
<svg v-if="!isLoading" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76" />
<line x1="16.24" y1="16.24" x2="19.07" y2="19.07" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24" />
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script>
// Vue 2 导入方式
import { AIAssistant } from './aiAssistant.js';
import ClipboardJS from 'clipboard';
export default {
name: 'AIChat',
props: {
chatModelUrl: {
type: String,
default: 'https://qianfan.baidubce.com/v2',
required: false
},
modelAuthKey: {
type: String,
required: true
},
knowledgeBaseId: {
type: String,
default: null
},
knowledgeBaseUrl: {
type: String,
default: '/api/knowledgebases/query'
},
knowledgeBaseAuthKey: {
type: String,
required: true
}
},
data() {
return {
messages: [],
inputMessage: '',
isLoading: false,
eventSource: null,
aiAssistant: null,
clipboard: null
}
},
mounted() {
// 初始化AI助手实例
this.aiAssistant = new AIAssistant({
chatModelUrl: this.chatModelUrl,
modelAuthKey: this.modelAuthKey,
knowledgeBaseId: this.knowledgeBaseId,
knowledgeBaseUrl: this.knowledgeBaseUrl,
knowledgeBaseAuthKey: this.knowledgeBaseAuthKey
});
// 初始化剪贴板功能
this.clipboard = new ClipboardJS('.copy-button');
// 初始化时渲染可能存在的内容
this.$nextTick(() => {
this.renderMathJax();
this.highlightCode();
});
},
beforeDestroy() {
if (this.eventSource) {
this.eventSource.close();
}
if (this.clipboard) {
this.clipboard.destroy();
}
},
methods: {
formatTime(timestamp) {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
},
scrollToBottom() {
this.$nextTick(() => {
if (this.$refs.messagesContainer) {
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
}
});
},
clearChat() {
this.messages = [];
},
async sendMessage() {
const message = this.inputMessage.trim();
if (!message || this.isLoading) return;
// 添加用户消息
this.messages.push({
role: 'user',
content: message,
timestamp: Date.now()
});
this.inputMessage = '';
this.isLoading = true;
this.scrollToBottom();
try {
// 添加助手的空消息占位
const assistantMessageIndex = this.messages.length;
this.messages.push({
role: 'assistant',
content: '',
timestamp: Date.now()
});
// 使用AI助手发送消息
const reader = await this.aiAssistant.sendMessage(message);
const decoder = new TextDecoder();
let accumulatedContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.slice(5).trim();
if (data === '[DONE]') {
break;
}
try {
const parsed = JSON.parse(data);
// 根据 OpenAI API 格式解析数据
const content = parsed.choices[0].delta.content || '';
if (content) {
accumulatedContent += content;
// 实时更新内容并格式化
this.messages[assistantMessageIndex].content = accumulatedContent;
this.scrollToBottom();
}
} catch (e) {
console.warn('Error parsing JSON:', e);
}
}
}
}
// 渲染数学公式和代码高亮
this.$nextTick(() => {
this.renderMathJax();
this.highlightCode();
});
// 发送新消息事件
this.$emit('new-message');
} catch (error) {
console.error('Error sending message:', error);
this.messages.push({
role: 'assistant',
content: '抱歉,发送消息时出现错误: ' + error.message,
timestamp: Date.now()
});
} finally {
this.isLoading = false;
this.scrollToBottom();
}
},
// 渲染数学公式
renderMathJax() {
if (window.MathJax) {
window.MathJax.Hub.Queue(["Typeset", window.MathJax.Hub]);
}
},
// 代码高亮
highlightCode() {
this.$nextTick(() => {
// 查找所有代码块元素
const codeBlocks = this.$refs.messagesContainer.querySelectorAll('pre code') || [];
// 如果有Prism.js则使用它来高亮代码
if (window.Prism) {
codeBlocks.forEach((block) => {
// 获取语言类名
const classes = block.className.split(' ');
const langClass = classes.find(cls => cls.startsWith('language-'));
if (langClass) {
// 如果指定了语言,则使用指定的语言进行高亮
const lang = langClass.replace('language-', '');
if (window.Prism.languages[lang]) {
block.className = `language-${lang}`;
window.Prism.highlightElement(block);
return;
}
}
// 默认高亮
window.Prism.highlightElement(block);
});
} else {
// 如果没有Prism.js则至少确保基本样式
codeBlocks.forEach((block) => {
block.classList.add('no-highlight');
});
}
});
},
// 格式化消息内容处理HTML和Markdown格式
formatMessage(content) {
if (!content) return '';
// 处理HTML实体编码
content = content.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// 处理代码块 - 将 ```code``` 转换为 <pre><code>code</code></pre>
content = content.replace(/```(\w*)\s*([\s\S]*?)```/g, (match, lang, code) => {
if (lang) {
return `<pre><code class="language-${lang}">${code.trim()}</code></pre>`;
} else {
return `<pre><code>${code.trim()}</code></pre>`;
}
});
// 处理行内代码 - 将 `code` 转换为 <code>code</code>
content = content.replace(/`([^`]+)`/g, '<code>$1</code>');
// 处理粗体文本 - 将 **bold** 转换为 <strong>bold</strong>
content = content.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// 处理斜体文本 - 将 *italic* 转换为 <em>italic</em>
content = content.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// 处理删除线 - 将 ~~text~~ 转换为 <del>text</del>
content = content.replace(/~~([^~]+)~~/g, '<del>$1</del>');
// 处理链接 - 将 [text](url) 转换为 <a href="url">text</a>
content = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
// 处理无序列表 - 将 * item 转换为 <li>item</li>
content = content.replace(/^\s*\*\s+(.*)$/gm, '<li>$1</li>');
// 包裹相邻的li元素到ul中
content = content.replace(/((?:<li>.*<\/li>\s*)+)/g, '<ul>$1</ul>');
// 处理有序列表 - 将 1. item 转换为 <li>item</li>
content = content.replace(/^\s*\d+\.\s+(.*)$/gm, '<li>$1</li>');
// 包裹相邻的li元素到ol中
content = content.replace(/((?:<li>.*<\/li>\s*)+)/g, '<ol>$1</ol>');
// 处理数学公式(行内)
content = content.replace(/\$([^\$]+)\$/g, '<span class="math-inline">$1</span>');
// 处理数学公式(块级)
content = content.replace(/\$\$([^\$]+)\$\$/g, '<div class="math-display">$1</div>');
// 处理换行
content = content.replace(/\n/g, '<br>');
return content;
}
}
};
</script>
<style scoped>
.ai-chat-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 400px;
max-height: calc(80vh - 20px);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
}
.chat-header {
background: linear-gradient(120deg, #4a6cf7 0%, #6a11cb 100%);
color: white;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
align-items: center;
gap: 12px;
}
.header-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.header-text h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.status {
margin: 0;
font-size: 12px;
opacity: 0.9;
margin-top: 2px;
}
.icon-button {
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 8px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: white;
}
.icon-button:hover {
background: rgba(255, 255, 255, 0.3);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
background-color: #fff;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
/* 隐藏滚动条但保持可滚动 */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
.welcome-message {
text-align: center;
padding: 40px 20px;
color: #666;
margin: auto;
}
.welcome-icon {
margin-bottom: 20px;
color: #4a6cf7;
}
.welcome-message h3 {
margin: 0 0 10px 0;
color: #333;
}
.welcome-message p {
margin: 0;
color: #666;
}
.message {
display: flex;
gap: 10px;
max-width: 95%;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message.user .message-content {
background: linear-gradient(120deg, #4a6cf7 0%, #6a11cb 100%);
color: white;
border-top-right-radius: 4px;
}
.message.assistant .message-content {
background-color: #f0f4f8;
color: #333;
border-top-left-radius: 4px;
}
.avatar {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.user-avatar,
.ai-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-avatar {
background: linear-gradient(120deg, #4a6cf7 0%, #6a11cb 100%);
color: white;
}
.ai-avatar {
background: #e4edf5;
color: #4a6cf7;
}
.message-content {
padding: 10px 14px;
border-radius: 18px;
position: relative;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.message-text {
word-wrap: break-word;
line-height: 1.6;
font-size: 15px;
}
/* 代码块样式 */
.message-text pre {
background-color: #2d2d2d;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin: 12px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
position: relative;
}
.message-text pre code {
font-family: 'Courier New', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
background: none;
color: #f8f8f2;
text-shadow: none;
padding: 0;
font-size: 14px;
line-height: 1.5;
display: block;
white-space: pre;
word-wrap: normal;
}
.message-text pre code.no-highlight {
color: #f8f8f2;
font-size: 14px;
line-height: 1.5;
}
.message-text strong {
font-weight: 700;
color: #333;
}
.message-text em {
font-style: italic;
}
.message-text del {
text-decoration: line-through;
}
.message-text a {
color: #4a6cf7;
text-decoration: none;
}
.message-text a:hover {
text-decoration: underline;
}
.message-text ul,
.message-text ol {
padding-left: 20px;
margin: 10px 0;
}
.message-text li {
margin: 5px 0;
}
.message-text ul ul,
.message-text ol ol,
.message-text ul ol,
.message-text ol ul {
margin: 0;
}
.message-text code {
font-family: 'Courier New', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
background-color: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
color: #d6336c;
}
.message-text pre code {
background-color: transparent;
padding: 0;
color: #f8f8f2;
}
/* 行内数学公式 */
.message-text span.math-inline {
color: #e83e8c;
}
/* 块级数学公式 */
.message-text div.math-display {
text-align: center;
margin: 10px 0;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.message-time {
font-size: 11px;
opacity: 0.7;
text-align: right;
margin-top: 6px;
}
.typing-indicator {
display: flex;
align-items: center;
gap: 4px;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #999;
display: inline-block;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) {
animation-delay: 0s;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-5px);
}
}
.chat-input {
padding: 16px;
background: white;
border-top: 1px solid #eee;
}
.input-container {
display: flex;
gap: 10px;
align-items: center;
}
.message-input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 24px;
outline: none;
font-size: 15px;
transition: border-color 0.2s ease;
background: #f8fafc;
}
.message-input:focus {
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.2);
}
.send-button {
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: linear-gradient(120deg, #4a6cf7 0%, #6a11cb 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.send-button:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(74, 108, 247, 0.3);
}
.send-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.send-button.loading {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@media (max-width: 600px) {
.ai-chat-container {
height: 100%;
border-radius: 0;
}
.message {
max-width: 98%;
}
.chat-header {
padding: 12px 16px;
}
.chat-messages {
padding: 12px;
}
}
</style>