<template>
|
<div class="chat-container">
|
<!-- 左侧历史对话列表 -->
|
<div class="sidebar">
|
<div class="sidebar-header">
|
<el-button type="primary" icon="el-icon-plus" @click="createNewChat">
|
新建对话
|
</el-button>
|
</div>
|
|
<div class="chat-list">
|
<div
|
v-for="(chat, index) in chatList"
|
:key="index"
|
:class="['chat-item', currentChatIndex === index ? 'active' : '']"
|
@click="switchChat(index)"
|
>
|
<div class="chat-item-icon">
|
<i class="el-icon-chat-dot-round" />
|
</div>
|
<div class="chat-item-content">
|
<div class="chat-item-title">{{ chat.title }}</div>
|
<div class="chat-item-time">{{ chat.time }}</div>
|
</div>
|
<div class="chat-item-delete" @click.stop="deleteChat(index)">
|
<i class="el-icon-close" />
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<!-- 聊天主区域 -->
|
<div class="chat-main">
|
<!-- 消息列表 -->
|
<div ref="messageList" class="message-list">
|
<div
|
v-for="(message, index) in messages"
|
:key="index"
|
:class="['message-item', message.role === 'user' ? 'message-user' : 'message-ai']"
|
>
|
<!-- AI 头像 -->
|
<div v-if="message.role === 'assistant'" class="avatar avatar-ai">
|
<span>AI</span>
|
</div>
|
|
<!-- 消息内容 -->
|
<div class="message-content">
|
<div class="message-info">
|
<span class="sender-name">{{ message.role === 'user' ? '我' : 'AI 助手' }}</span>
|
<span class="message-time">{{ message.time }}</span>
|
</div>
|
<div class="message-text" v-html="formattedContent(message.content)" />
|
|
<!-- 流式加载中的光标 -->
|
<span v-if="message.streaming" class="cursor-blink">|</span>
|
</div>
|
|
<!-- 用户头像 -->
|
<div v-if="message.role === 'user'" class="avatar avatar-user">
|
<span>我</span>
|
</div>
|
</div>
|
|
<!-- 加载中提示 -->
|
<div v-if="isLoading" class="loading-indicator">
|
<span class="loading-dot" />
|
<span class="loading-dot" />
|
<span class="loading-dot" />
|
</div>
|
</div>
|
|
<!-- 输入区域 -->
|
<div class="input-area">
|
<div class="input-wrapper">
|
<el-input
|
v-model="userInput"
|
type="textarea"
|
:rows="3"
|
autofocus
|
placeholder="请输入您的问题... (回车发送)"
|
resize="none"
|
class="chat-input"
|
@keyup.enter.native="sendMessage"
|
@keydown.enter.native="e => e.preventDefault()"
|
/>
|
<!-- @keydown.enter.exact.prevent="sendMessage"-->
|
<el-button
|
type="primary"
|
:disabled="!userInput.trim() || isLoading"
|
class="send-button"
|
@click="sendMessage"
|
>
|
{{ isLoading ? '思考中...' : '发送' }}
|
</el-button>
|
</div>
|
<div class="input-tips">
|
<span>AI 生成内容仅供参考,请谨慎甄别</span>
|
</div>
|
</div>
|
</div>
|
|
</div>
|
</template>
|
|
<script>
|
import { marked } from 'marked'
|
|
// 配置 marked 选项
|
marked.setOptions({
|
breaks: true, // 支持 GFM 换行
|
gfm: true, // GitHub Flavored Markdown
|
sanitize: false // 不过滤 HTML(如果需要 XSS 保护,可以使用 DOMPurify)
|
})
|
|
export default {
|
name: 'ProcessSetting',
|
data() {
|
return {
|
userInput: '',
|
messages: [],
|
isLoading: false,
|
currentStreamingIndex: null,
|
// 阿里云千问配置
|
API_KEY: 'sk-825b87021a0a4dfb9d3fdf20acb4fcc2', // 请替换为你的 API Key
|
API_URL: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
|
// 历史对话相关
|
chatList: [],
|
currentChatIndex: 0
|
}
|
},
|
watch: {
|
// 监听消息变化,自动更新标题
|
messages: {
|
handler() {
|
this.updateChatTitle()
|
this.saveChatHistory()
|
},
|
deep: true
|
}
|
},
|
mounted() {
|
// 初始化欢迎消息
|
this.createNewChat()
|
// 从本地存储加载历史对话
|
this.loadChatHistory()
|
},
|
methods: {
|
// 使用 marked 库渲染 Markdown
|
formattedContent(content) {
|
if (!content) return ''
|
|
try {
|
return marked.parse(content)
|
} catch (e) {
|
console.error('Markdown 解析失败:', e)
|
return content.replace(/\n/g, '<br>')
|
}
|
},
|
|
// 处理 Enter 键 - 直接发送
|
handleEnterKey(event) {
|
event.preventDefault()
|
this.sendMessage()
|
},
|
|
// 发送消息
|
async sendMessage() {
|
const text = this.userInput.trim()
|
if (!text || this.isLoading) return
|
|
// 添加用户消息
|
this.addMessage('user', text)
|
this.userInput = ''
|
|
// 创建 AI 回复消息(空内容)
|
const aiMessageIndex = this.addMessage('assistant', '', true)
|
|
// 调用阿里云千问流式接口
|
await this.fetchQwenStreamingResponse(aiMessageIndex)
|
},
|
|
// 添加消息到列表
|
addMessage(role, content, streaming = false) {
|
const now = new Date()
|
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
|
|
const message = {
|
role,
|
content,
|
time,
|
streaming
|
}
|
|
this.messages.push(message)
|
this.$nextTick(() => {
|
this.scrollToBottom()
|
})
|
|
return this.messages.length - 1
|
},
|
|
// 阿里云千问流式响应
|
async fetchQwenStreamingResponse(messageIndex) {
|
this.isLoading = true
|
this.currentStreamingIndex = messageIndex
|
|
try {
|
// 构建历史对话(用于上下文)
|
const conversationHistory = this.messages
|
.filter((msg, idx) => idx < this.messages.length - 1 || msg.role === 'user')
|
.map(msg => ({
|
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
content: msg.content
|
}))
|
|
// 获取最后一条用户消息作为当前问题
|
const lastUserMessage = this.messages[this.messages.length - 2]
|
if (lastUserMessage) {
|
conversationHistory.push({
|
role: 'user',
|
content: lastUserMessage.content
|
})
|
}
|
|
const response = await fetch(this.API_URL, {
|
method: 'POST',
|
headers: {
|
'Content-Type': 'application/json',
|
'Authorization': `Bearer ${this.API_KEY}`
|
},
|
body: JSON.stringify({
|
model: 'qwen-plus', // 或 qwen-turbo, qwen-max 等
|
messages: conversationHistory,
|
stream: true, // 启用流式输出
|
temperature: 0.7
|
})
|
})
|
|
if (!response.ok) {
|
throw new Error(`HTTP error! status: ${response.status}`)
|
}
|
|
// 处理流式响应
|
const reader = response.body.getReader()
|
const decoder = new TextDecoder('utf-8')
|
let accumulatedContent = ''
|
|
// eslint-disable-next-line no-constant-condition
|
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(6)
|
|
// [DONE] 标记表示流式结束
|
if (data.trim() === '[DONE]') {
|
continue
|
}
|
|
try {
|
const parsed = JSON.parse(data)
|
const delta = parsed.choices?.[0]?.delta?.content
|
|
if (delta) {
|
accumulatedContent += delta
|
|
// 使用 $set 确保 Vue2 的响应式更新
|
this.$set(this.messages[messageIndex], 'content', accumulatedContent)
|
|
// 滚动到底部
|
this.$nextTick(() => {
|
this.scrollToBottom()
|
})
|
}
|
} catch (e) {
|
console.error('解析 SSE 数据失败:', e)
|
}
|
}
|
}
|
}
|
|
// 结束流式状态
|
this.$set(this.messages[messageIndex], 'streaming', false)
|
} catch (error) {
|
console.error('调用千问接口失败:', error)
|
this.$message.error('AI 响应失败:' + error.message)
|
|
// 设置错误信息到消息中
|
this.$set(this.messages[messageIndex], 'content', '抱歉,响应失败:' + error.message)
|
this.$set(this.messages[messageIndex], 'streaming', false)
|
} finally {
|
this.isLoading = false
|
this.currentStreamingIndex = null
|
}
|
},
|
|
// 滚动到底部
|
scrollToBottom() {
|
const container = this.$refs.messageList
|
if (container) {
|
this.$nextTick(() => {
|
container.scrollTop = container.scrollHeight
|
})
|
}
|
},
|
|
// 创建新对话
|
createNewChat() {
|
const now = new Date()
|
const time = `${now.getMonth() + 1}/${now.getDate()} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
|
|
const newChat = {
|
title: '新对话',
|
time: time,
|
messages: [{
|
role: 'assistant',
|
content: '您好!我是 AI 助手,请问有什么可以帮您?',
|
time: time,
|
streaming: false
|
}]
|
}
|
|
this.chatList.unshift(newChat)
|
this.currentChatIndex = 0
|
this.messages = newChat.messages
|
this.saveChatHistory()
|
},
|
|
// 切换对话
|
switchChat(index) {
|
this.currentChatIndex = index
|
this.messages = this.chatList[index].messages
|
this.$nextTick(() => {
|
this.scrollToBottom()
|
})
|
},
|
|
// 删除对话
|
deleteChat(index) {
|
this.$confirm('确定要删除这个对话吗?', '提示', {
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'warning'
|
}).then(() => {
|
this.chatList.splice(index, 1)
|
|
if (this.chatList.length === 0) {
|
this.createNewChat()
|
} else if (index === this.currentChatIndex) {
|
this.currentChatIndex = Math.max(0, index - 1)
|
this.switchChat(this.currentChatIndex)
|
} else if (index < this.currentChatIndex) {
|
this.currentChatIndex--
|
}
|
|
this.saveChatHistory()
|
this.$message.success('删除成功')
|
}).catch(() => {})
|
},
|
|
// 更新当前对话标题
|
updateChatTitle() {
|
if (this.messages.length > 0 && this.chatList[this.currentChatIndex]) {
|
const firstUserMessage = this.messages.find(msg => msg.role === 'user')
|
if (firstUserMessage) {
|
const title = firstUserMessage.content.substring(0, 20) + (firstUserMessage.content.length > 20 ? '...' : '')
|
this.$set(this.chatList[this.currentChatIndex], 'title', title)
|
this.saveChatHistory()
|
}
|
}
|
},
|
|
// 保存对话历史到本地存储
|
saveChatHistory() {
|
try {
|
localStorage.setItem('chatHistory', JSON.stringify({
|
chatList: this.chatList,
|
currentChatIndex: this.currentChatIndex
|
}))
|
} catch (e) {
|
console.error('保存聊天记录失败:', e)
|
}
|
},
|
|
// 从本地存储加载对话历史
|
loadChatHistory() {
|
try {
|
const saved = localStorage.getItem('chatHistory')
|
if (saved) {
|
const data = JSON.parse(saved)
|
this.chatList = data.chatList || []
|
this.currentChatIndex = data.currentChatIndex || 0
|
|
if (this.chatList.length > 0) {
|
this.messages = this.chatList[this.currentChatIndex].messages
|
} else {
|
this.createNewChat()
|
}
|
}
|
} catch (e) {
|
console.error('加载聊天记录失败:', e)
|
this.createNewChat()
|
}
|
}
|
}
|
}
|
</script>
|
|
<style scoped lang="scss">
|
.chat-container {
|
display: flex;
|
height: 100vh;
|
background: #f5f7fa;
|
}
|
|
.sidebar {
|
width: 260px;
|
background: #1a1a2e;
|
display: flex;
|
flex-direction: column;
|
//border-radius: 8px 0 0 8px;
|
overflow: hidden;
|
|
.sidebar-header {
|
padding: 16px;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
button {
|
width: 100%;
|
background: rgba(255, 255, 255, 0.1);
|
border-color: transparent;
|
color: white;
|
|
&:hover {
|
background: rgba(255, 255, 255, 0.2);
|
}
|
}
|
}
|
|
.chat-list {
|
flex: 1;
|
overflow-y: auto;
|
padding: 8px;
|
|
&::-webkit-scrollbar {
|
width: 4px;
|
}
|
|
&::-webkit-scrollbar-thumb {
|
background: rgba(255, 255, 255, 0.2);
|
border-radius: 2px;
|
}
|
}
|
|
.chat-item {
|
display: flex;
|
align-items: center;
|
padding: 12px;
|
margin-bottom: 4px;
|
border-radius: 8px;
|
cursor: pointer;
|
transition: all 0.3s;
|
position: relative;
|
|
&:hover {
|
background: rgba(255, 255, 255, 0.1);
|
|
.chat-item-delete {
|
opacity: 1;
|
}
|
}
|
|
&.active {
|
background: rgba(64, 158, 255, 0.2);
|
border-left: 3px solid #409eff;
|
}
|
|
.chat-item-icon {
|
width: 32px;
|
height: 32px;
|
border-radius: 8px;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
color: white;
|
font-size: 16px;
|
margin-right: 12px;
|
flex-shrink: 0;
|
}
|
|
.chat-item-content {
|
flex: 1;
|
overflow: hidden;
|
|
.chat-item-title {
|
color: white;
|
font-size: 14px;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
margin-bottom: 4px;
|
}
|
|
.chat-item-time {
|
color: rgba(255, 255, 255, 0.5);
|
font-size: 12px;
|
}
|
}
|
|
.chat-item-delete {
|
opacity: 0;
|
color: rgba(255, 255, 255, 0.6);
|
font-size: 14px;
|
padding: 4px;
|
transition: opacity 0.3s;
|
|
&:hover {
|
color: #ff4d4f;
|
}
|
}
|
}
|
}
|
|
.chat-main {
|
flex: 1;
|
overflow: hidden;
|
padding: 20px;
|
background: white;
|
border-radius: 0 8px 8px 0;
|
}
|
|
.message-list {
|
height: 85%;
|
overflow-y: auto;
|
padding: 0 10px;
|
|
&::-webkit-scrollbar {
|
width: 6px;
|
}
|
|
&::-webkit-scrollbar-thumb {
|
background: #c0c4cc;
|
border-radius: 3px;
|
}
|
}
|
|
.message-item {
|
display: flex;
|
margin-bottom: 24px;
|
align-items: flex-start;
|
|
&.message-user {
|
flex-direction: row-reverse;
|
|
.message-content {
|
margin-right: 12px;
|
|
.message-info {
|
justify-content: flex-end;
|
}
|
|
.message-text {
|
background: #409eff;
|
color: white;
|
border-radius: 12px 12px 0 12px;
|
}
|
}
|
}
|
|
&.message-ai {
|
.message-content {
|
margin-left: 12px;
|
}
|
}
|
}
|
|
.avatar {
|
width: 40px;
|
height: 40px;
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 14px;
|
font-weight: bold;
|
flex-shrink: 0;
|
|
&.avatar-ai {
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
color: white;
|
}
|
|
&.avatar-user {
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
color: white;
|
}
|
}
|
|
.message-content {
|
max-width: 70%;
|
|
.message-info {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
margin-bottom: 6px;
|
font-size: 12px;
|
color: #909399;
|
}
|
|
.message-text {
|
padding: 12px 16px;
|
line-height: 1.6;
|
word-break: break-word;
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
::v-deep {
|
pre {
|
background: #282c34;
|
color: #abb2bf;
|
padding: 12px;
|
border-radius: 6px;
|
overflow-x: auto;
|
margin: 8px 0;
|
}
|
|
code {
|
background: rgba(27,31,35,0.05);
|
padding: 2px 4px;
|
border-radius: 3px;
|
font-family: Consolas, monospace;
|
}
|
|
pre code {
|
background: transparent;
|
padding: 0;
|
}
|
|
strong {
|
color: #303133;
|
}
|
|
table {
|
border-collapse: collapse;
|
width: 100%;
|
margin: 12px 0;
|
font-size: 14px;
|
|
th, td {
|
border: 1px solid #dcdfe6;
|
padding: 8px 12px;
|
text-align: left;
|
}
|
|
th {
|
background: #f5f7fa;
|
font-weight: 600;
|
color: #606266;
|
}
|
|
tr:nth-child(even) {
|
background: #fafafa;
|
}
|
|
tr:hover {
|
background: #f5f7fa;
|
}
|
}
|
|
blockquote {
|
border-left: 4px solid #409eff;
|
margin: 12px 0;
|
padding: 8px 16px;
|
background: #ecf5ff;
|
color: #606266;
|
}
|
|
ul, ol {
|
padding-left: 24px;
|
margin: 8px 0;
|
|
li {
|
margin: 4px 0;
|
}
|
}
|
|
h1, h2, h3, h4, h5, h6 {
|
margin: 16px 0 8px;
|
color: #303133;
|
font-weight: 600;
|
}
|
|
h1 { font-size: 24px; }
|
h2 { font-size: 20px; }
|
h3 { font-size: 18px; }
|
h4 { font-size: 16px; }
|
}
|
}
|
}
|
|
.cursor-blink {
|
display: inline-block;
|
animation: blink 1s infinite;
|
color: #409eff;
|
font-weight: bold;
|
}
|
|
@keyframes blink {
|
0%, 50% { opacity: 1; }
|
51%, 100% { opacity: 0; }
|
}
|
|
.loading-indicator {
|
display: flex;
|
justify-content: center;
|
gap: 4px;
|
padding: 16px;
|
|
.loading-dot {
|
width: 8px;
|
height: 8px;
|
background: #409eff;
|
border-radius: 50%;
|
animation: bounce 1.4s infinite ease-in-out both;
|
|
&:nth-child(1) {
|
animation-delay: -0.32s;
|
}
|
|
&:nth-child(2) {
|
animation-delay: -0.16s;
|
}
|
}
|
}
|
|
@keyframes bounce {
|
0%, 80%, 100% {
|
transform: scale(0);
|
}
|
40% {
|
transform: scale(1);
|
}
|
}
|
|
.input-area {
|
background: white;
|
padding: 16px 20px ;
|
border-top: 1px solid #e4e7ed;
|
|
.input-wrapper {
|
display: flex;
|
gap: 12px;
|
align-items: flex-end;
|
max-width: 1200px;
|
margin: 0 auto;
|
}
|
|
.chat-input {
|
flex: 1;
|
|
::v-deep textarea {
|
resize: none;
|
padding: 12px 16px;
|
font-size: 14px;
|
line-height: 1.6;
|
|
&:focus {
|
border-color: #409eff;
|
}
|
}
|
}
|
|
.send-button {
|
min-width: 80px;
|
height: auto;
|
padding: 12px 24px;
|
}
|
|
.input-tips {
|
text-align: center;
|
margin-top: 8px;
|
font-size: 12px;
|
color: #909399;
|
}
|
}
|
</style>
|